Reading Android notifications with custom views using reflection
Notifications on Android have seen countless changes through the years. What began as little more than a set of texts and icons, has been expanded with media controls, direct reply actions, notification groups/channels and more. However, one thing has been there from the very beginning: RemoteViews as the API for custom views.
This blogpost shows how to read Android notifications with such custom views, how to analyze internal APIs using reflection and which stumbling blocks to watch out for. At the end of this page you will find a showcase that illustrates the techniques used.
Observing notifications from other apps
Any incoming notification can be read by any app, as of Android 15, as long as the user gives permission. Therefore notifications should not contain sensitive data and instead be treated as public domain. This also affects content that may be displayed in a notification later on, like mails or SMS, but we will not delve deeper into this privacy concern at this point.
Notifications can be observed via NotificationListenerService. This service has to be registered in the AndroidManifest and will be started and called by the operating system.
<manifest>
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"/>
<application>
<service
android:name="MyService"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService"/>
</intent-filter>
</service>
</application>
</manifest>
The user has to grant the enabled_notification_listeners
permission for the NotificationListenerService to work. So we check the permission and, if not granted, redirect the user to the settings.
fun hasNotificationListenerPermission(): Boolean {
val componentName = ComponentName(context, MyService::class.java)
val enabledListeners = Settings.Secure.getString(
context.contentResolver,
"enabled_notification_listeners",
)
return enabledListeners.contains(componentName.flattenToString())
}
fun openNotificationListenerSettings() {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
activity.startActivityForResult(intent, 0)
}
Every time a new notification is being posted or updated in the status bar, the NotificationListenerService’s onNotificationPosted
will be invoked.
class MyService : NotificationListenerService() {
override fun onNotificationPosted(statusBarNotification: StatusBarNotification?) {
// Do something with the statusBarNotification
}
}
⚠️ NotificationListenerService may become unreliable during development until it stops completely. This issue has yet to be fixed by Google (see issuetracker.google) and can be workaround by restarting the device (see stackoverflow).
Reading notification contents
onNotificationPosted
receives a StatusBarNotification whose Notification exposes everything the user can see or do. Basic notifications contain pre-determined properties like title, summary or icon. More complex notifications use RemoteViews to render custom views.
The notification in my example is being posted by the app “CamAPS FX”. This app displays medical data in RemoteViews of a local notification. There is no broadcast nor any other interface to access this data without some third-party cloud-based service.
Sample notification from “CamAPS FX”, with “172” being the value we are trying to read
Reading RemoteViews
RemoteViews do not expose their contents, so we use reflection to read their internals. The debugger helps us identifying the properties we need to read data from the custom view. We find out that mActions
lists every callable method and its current value.
RemoteViews with list of actions, each containing method name and value
We access mActions
by retrieving RemoteViews’ members via KClass.memberProperties
, searching for the one with the right name, making it accessible and casting the result to a list of wildcards.
fun getRemoteViewActions(remoteViews: RemoteViews): List<Pair<String, Any?>> {
val actionsProperty = RemoteViews::class.memberProperties
.firstOrNull { it.name == "mActions" }
?: return emptyList()
actionsProperty.isAccessible = true
val actions = actionsProperty.get(remoteViews) as? ArrayList<*>
?: return emptyList()
return actions.mapNotNull(::getRemoteViewAction)
}
We then iterate over this list of actions and access every entry’s method name (mMethodName
) and value (mValue
) via reflection like before.
fun getRemoteViewAction(action: Any): Pair<String, Any?>? {
val memberProperties = action::class.memberProperties
val methodNameProperty = memberProperties
.firstOrNull { it.name == "mMethodName" }
?: return null
methodNameProperty.isAccessible = true
val methodName = methodNameProperty.getter.call(action) as? String
?: return null
val valueProperty = memberProperties
.firstOrNull { it.name == "mValue" }
?: return null
valueProperty.isAccessible = true
val value = valueProperty.getter.call(action)
return methodName to value
}
Now we have access to every piece of data the notification contains. Next we can filter the method names and values for those of interest.
In our example we are looking for some sort of text view that displays a number (“172” in the screenshot above). Therefore we search the actions for one that sets a text (methodName == "setText"
) to a number ((value as? String)?.toFloatOrNull()
).
val remoteViewActions: List<Pair<String, Any?>> = getRemoteViewActions(remoteViews)
val setTextActions = remoteViewActions.filter { it.first == "setText" }
val number = setTextActions.mapNotNull { (it.second as? String)?.toFloatOrNull() }.firstOrNull()
Now we have access to the number in the notification’s custom view.
Beware breaking changes
As neat as this approach is, it also creates a moving target for changes outside of our reach. Updates to the notification-producing app may change the structure of its RemoteViews. Updates to the operating system may change the RemoteViews’ internals. Both would break our mapping logic.
This happened during the writing of this blogpost and its showcase. After updating to Android 15 no data from RemoteViews could be read anymore. The reason for this was the renaming of RemoteViews’ internal properties which now use the hungarian notation and previously did not. actions
became mActions
and thus the filtering logic for that property stopped working.
Conclusion
Reflection is a powerful tool that allows us to read data we are not supposed to. It can be a feasible approach to overcome dead ends. However, this comes at a cost and may be too fragile for long-living software with a broad target audience.
This worked well in my example, in which I wanted to transfer data from an Android app and its notifications to my local Home Assistance instance. I do not plan to publish this solution to any store and I am fine with the service not running for a while in case of a breaking change. You can find the code for this example in the GitHub project below.
Faltenreich/home-assistant-camaps-fx-adapter