通知栏的那些奇技淫巧
一、问题的由来
前几天,一个网友在微信群提了一个问题:
通知栏监听模拟点击如何实现?
我以为业务情景是在自己应用内,防止脚本模拟点击而引申出来的一个需求,心里还在想,是否可以使用自定义View
——onTouchEvent
的参数MotionEvent
的getPressure
来判断是否是模拟点击。后来经过沟通得知,业务需求是如何监听第三方应用的通知栏,实现具体按钮的点击。如下图:
上面是多家音频应用的通知栏在小米手机的样式,而网友的需求是如何实现针对某一个应用通知栏的某一个按钮的点击,比如监听喜马拉雅APP,当接收到通知的时候,需要点击关闭按钮。这个需求该如何接住呢?
二、实现方案之无障碍服务
当需求清晰以后,我心里面想到的第一个方案就是无障碍服务。但是无障碍服务点击通知栏简单,点击通知栏的某一个控件需要打开通知栏,然后找到这个控件的id,然后调用点击方法。同时由于几年前有过写抢红包脚本的经验,提出了一些疑问:
- 用户使用的业务情景是什么?是否需要正规渠道上架?
- 无障碍服务权限相当敏感,是否接受交出权限的选择?
沟通结果是正规渠道上架和业务情景不用考虑,但是权限的敏感需要换一个思路。网友指出,NotificationListenerService
可以实现监听通知栏,能否在这个地方想点办法呢?而且他还提到一个业务情景:当收到通知的时候,不需要用户打开通知栏列表,不管用户在系统桌面,还是第三方应用页面,均需要实现点击具体按钮的操作。
虽然我此时对NotificationListenerService
不熟悉,但是一听到这个反常识的操作,我顿时觉得不现实,至少是需要一些黑科技才能在部分设备实现这个效果。因为操作UI需要在主线程,但是系统当前的主线程可能在其它进程,所以我觉得这个地方反常识了!
三、实现方案之通知监听服务
由于上面的沟通过程因为我不熟悉 NotificationListenerService
导致我battle
的时候都不敢大声说话,因此我决定去熟悉一下,然后我看到了黄老师的这篇 Android通知监听服务之NotificationListenerService使用篇
看到黄老师实现微信抢红包以后,我也心动了,既然黄老师可以抢红包,那么是不是我也可以抢他的红包?于是就开始了踩坑之旅。
3.1 通知栏的那些事
我们知道,通知栏的显示、刷新、关闭都是依赖于Notification
来实现,而通知栏的UI
要么是依托系统主题实现,要么是通过自定义RemoteViews
实现,而UI
的交互则是通过PendingIntent
包装的Intent
来实现具体的意图。
// 通知栏的`UI`依托系统主题实现
NotificationCompat.Builder(context, Notification.CHANNEL_ID)
.setStyle(androidx.media.app.NotificationCompat.MediaStyle()
// show only play/pause in compact view
.setShowActionsInCompactView(playPauseButtonPosition)
.setShowCancelButton(true)
.setCancelButtonIntent(mStopIntent)
.setMediaSession(mediaSession)
)
.setDeleteIntent(mStopIntent)
.setColorized(true)
.setSmallIcon(smallIcon)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setContentTitle(songInfo?.songName) //歌名
.setContentText(songInfo?.artist) //艺术家
.setLargeIcon(art)
/**
* 创建RemoteViews
*/
private fun createRemoteViews(isBigRemoteViews: Boolean): RemoteViews {
val remoteView: RemoteViews = if (isBigRemoteViews) {
RemoteViews(packageName, LAYOUT_NOTIFY_BIG_PLAY.getResLayout())
} else {
RemoteViews(packageName, LAYOUT_NOTIFY_PLAY.getResLayout())
}
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PLAY.getResId(), playIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PAUSE.getResId(), pauseIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_STOP.getResId(), stopIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_FAVORITE.getResId(), favoriteIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_LYRICS.getResId(), lyricsIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_DOWNLOAD.getResId(), downloadIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_NEXT.getResId(), nextIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PRE.getResId(), previousIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_CLOSE.getResId(), closeIntent)
remoteView.setOnClickPendingIntent(ID_IMG_NOTIFY_PLAY_OR_PAUSE.getResId(), playOrPauseIntent)
return remoteView
}
// 通过自定义`RemoteViews`实现
val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID)
notificationBuilder
.setOnlyAlertOnce(true)
.setSmallIcon(smallIcon)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(songInfo?.songName) //歌名
.setContentText(songInfo?.artist) //艺术家
1. StatusBarNotification
的逆向之旅
有了上面的了解,那么我们可以考虑通过Notification
来获取PendingIntent
,实现通知栏模拟点击的效果。
通过NotificationListenerService
的回调方法,我们可以获得StatusBarNotification
,源码如下:
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
}
接下来,我们需要从这个地方开始,抽丝剥茧般地一步一步找到我们想要的PendingIntent
。
先观察一下StatusBarNotification
的源码:
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
private final Notification notification;
public StatusBarNotification(String pkg, String opPkg, int id,
String tag, int uid, int initialPid, Notification notification, UserHandle user,
String overrideGr0upKey, long postTime) {
if (pkg == null) throw new NullPointerException();
if (notification == null) throw new NullPointerException();
this.pkg = pkg;
this.opPkg = opPkg;
this.id = id;
this.tag = tag;
this.uid = uid;
this.initialPid = initialPid;
this.notification = notification;
this.user = user;
this.postTime = postTime;
this.overrideGr0upKey = overrideGr0upKey;
this.key = key();
this.groupKey = groupKey();
}
/**
* The {@link android.app.Notification} supplied to
* {@link android.app.NotificationManager#notify(int, Notification)}.
*/
public Notification getNotification() {
return notification;
}
这里我们可以直接获取到Notification
这个对象,然后我们继续观察源码,
/**
* The view that will represent this notification in the notification list (which is pulled
* down from the status bar).
*
* As of N, this field may be null. The notification view is determined by the inputs
* to {@link Notification.Builder}; a custom RemoteViews can optionally be
* supplied with {@link Notification.Builder#setCustomContentView(RemoteViews)}.
*/
@Deprecated
public RemoteViews contentView;
虽然这个contentView
已经标记为不建议使用了,但是我们可以先尝试跑通流程。然后再将这个思路拓展到非自定义RemoteViews
的流程。
经过测试,这里我们已经可以获取到RemoteViews
了。按照惯例,这里我们需要继续观察RemoteViews
的源码,从设置点击事件开始:
public void setOnClickPendingIntent(@IdRes int viewId, PendingIntent pendingIntent) {
setOnClickResponse(viewId, RemoteResponse.fromPendingIntent(pendingIntent));
}
// 👇
public static class RemoteResponse {
private PendingIntent mPendingIntent;
public static RemoteResponse fromPendingIntent(@NonNull PendingIntent pendingIntent) {
RemoteResponse response = new RemoteResponse();
response.mPendingIntent = pendingIntent;
return response;
}
}
// 👆
public void setOnClickResponse(@IdRes int viewId, @NonNull RemoteResponse response) {
addAction(new SetOnClickResponse(viewId, response));
}
// 响应事件 👆
private class SetOnClickResponse extends Action {
SetOnClickResponse(@IdRes int id, RemoteResponse response) {
this.viewId = id;
this.mResponse = response;
}
SetOnClickResponse(Parcel parcel) {
viewId = parcel.readInt();
mResponse = new RemoteResponse();
mResponse.readFromParcel(parcel);
}
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(viewId);
mResponse.writeToParcel(dest, flags);
}
@Override
public void apply(View root, ViewGr0up rootParent, final InteractionHandler handler,
ColorResources colorResources) {
final View target = root.findViewById(viewId);
if (target == null) return;
if (mResponse.mPendingIntent != null) {
// If the view is an AdapterView, setting a PendingIntent on click doesn't make
// much sense, do they mean to set a PendingIntent template for the
// AdapterView's children?
if (hasFlags(FLAG_WIDGET_IS_COLLECTION_CHILD)) {
Log.w(LOG_TAG, "Cannot SetOnClickResponse for collection item "
+ "(id: " + viewId + ")");
ApplicationInfo appInfo = root.getContext().getApplicationInfo();
// We let this slide for HC and ICS so as to not break compatibility. It should
// have been disabled from the outset, but was left open by accident.
if (appInfo != null
&& appInfo.targetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN) {
return;
}
}
target.setTagInternal(R.id.pending_intent_tag, mResponse.mPendingIntent);
} else if (mResponse.mFillIntent != null) {
if (!hasFlags(FLAG_WIDGET_IS_COLLECTION_CHILD)) {
Log.e(LOG_TAG, "The method setOnClickFillInIntent is available "
+ "only from RemoteViewsFactory (ie. on collection items).");
return;
}
if (target == root) {
// Target is a root node of an AdapterView child. Set the response in the tag.
// Actual click handling is done by OnItemClickListener in
// SetPendingIntentTemplate, which uses this tag information.
target.setTagInternal(com.android.internal.R.id.fillInIntent, mResponse);
return;
}
} else {
// No intent to apply, clear the listener and any tags that were previously set.
target.setOnClickListener(null);
target.setTagInternal(R.id.pending_intent_tag, null);
target.setTagInternal(com.android.internal.R.id.fillInIntent, null);
return;
}
target.setOnClickListener(v -> mResponse.handleViewInteraction(v, handler));
}
@Override
public int getActionTag() {
return SET_ON_CLICK_RESPONSE_TAG;
}
final RemoteResponse mResponse;
}
private void addAction(Action a) {
if (hasMultipleLayouts()) {
throw new RuntimeException("RemoteViews specifying separate layouts for orientation"
+ " or size cannot be modified. Instead, fully configure each layouts"
+ " individually before constructing the combined layout.");
}
if (mActions == null) {
mActions = new ArrayList<>();
}
mActions.add(a);
}
上面代码有点多,我画个图方便大家理解:
至此,我们就知道了PendingIntent
的藏身之处了!
通过反射,正常情况下我们就能拿到所有属于SetOnClickResponse#PendingIntent
了,上代码:
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.***.******"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.first().let { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
}
}
}
}
}
2. 反射的拦路鬼——Android
平台限制对非SDK
接口的调用
不出意外的还是有了意外,明明反射的字段存在,就是反射获取不到。
就在一筹莫展之际,有朋友提出了一个思路——针对非SDK
接口的限制。然后经过查询,果然是反射失败的罪魁祸首!
既然确诊了病症,那么就可以开始开方抓药了!
根据轮子bypassHiddenApiRestriction
绕过 Android 9以上非SDK接口调用限制的方法,我们成功的获取到了PendingIntent
.
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.lzx.starrysky"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.forEach { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
}
}
}
}
}
这里的筛选结果有十几个点击事件的响应对象,我们需要做的就是一个一个的去尝试,找到那个目标对象的pendingIntent
,通过调用send
方法就可以实现模拟点击的效果了!
...
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
// 延迟实现点击功能
Handler(Looper.getMainLooper()).postDelayed({
target.send()
},500)
总结
综上,如果第三方应用的通知栏UI
是自定义View
的话,那么这里的方案是可以直接使用;如果第三方应用的通知栏UI
使用的是系统主题,那么按照这个思路应该也可以通过反射实现。
步骤如下:
- 接入第三方轮子
bypassHiddenApiRestriction
(PS:远程依赖的时候使用并未成功,我将项目clone下来打包为aar,导入项目后使用正常!),并初始化:
HiddenApiBypass.startBypass()
- 在
AndroidManifest
中注册NotificationListenerService
,然后启动服务
private fun startService(){
if (NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName)){
val intent = Intent(this,NotificationMonitorService::class.java)
startService(intent)
}else{
startActivity(Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"))
}
}
- 3.在
NotificationListenerService
监听通知栏
override fun onNotificationPosted(sbn: StatusBarNotification?) {
super.onNotificationPosted(sbn)
sbn?:return
if(sbn.packageName == "com.***.******"){
// 获取通知
val cls = sbn.notification.contentView.javaClass
// 点击事件容器
val field = cls.getDeclaredField("mActions")
field.isAccessible = true
// 点击事件容器对象
val result = field.get(sbn.notification.contentView)
// 强转
(result as? ArrayList<Any>?)?.let { list ->
// 筛选点击事件的实现类集合
// 此处需要判断具体的点击事件
list.filter { item -> item.javaClass.simpleName == "SetOnClickResponse" }.first().let { item ->
// 获取响应对象
val response = item.javaClass.getDeclaredField("mResponse")
response.isAccessible = true
// 强转
(response.get(item) as? RemoteViews.RemoteResponse)?.let { remoteResponse ->
// 获取PendingIntent
val intentField = remoteResponse.javaClass.getDeclaredField("mPendingIntent")
intentField.isAccessible = true
val target = intentField.get(remoteResponse) as PendingIntent
Log.e("NotificationMonitorService","最终目标:${Gson().toJson(target)}")
// 延迟实现点击功能
Handler(Looper.getMainLooper()).postDelayed({
target.send()
},500)
}
}
}
}
}
参考:
Android通知监听服务之NotificationListenerService使用篇
来源:juejin.cn/post/7190280650283778106