通知栏的那些奇技淫巧

一、问题的由来


前几天,一个网友在微信群提了一个问题:

通知栏监听模拟点击如何实现?

我以为业务情景是在自己应用内,防止脚本模拟点击而引申出来的一个需求,心里还在想,是否可以使用自定义View——onTouchEvent的参数MotionEventgetPressure来判断是否是模拟点击。后来经过沟通得知,业务需求是如何监听第三方应用的通知栏,实现具体按钮的点击。如下图:

通知栏.jpg

上面是多家音频应用的通知栏在小米手机的样式,而网友的需求是如何实现针对某一个应用通知栏的某一个按钮的点击,比如监听喜马拉雅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 overrideGroupKey, 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.overrideGroupKey = overrideGroupKey;
    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, ViewGroup 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);
    }
    

上面代码有点多,我画个图方便大家理解:

未命名文件.jpg

至此,我们就知道了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接口的调用

不出意外的还是有了意外,明明反射的字段存在,就是反射获取不到。

反射失败.png 就在一筹莫展之际,有朋友提出了一个思路——针对非SDK接口的限制。然后经过查询,果然是反射失败的罪魁祸首!

WechatIMG889.png 既然确诊了病症,那么就可以开始开方抓药了! 根据轮子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)}")
                }

            }
        }

    }

}

WechatIMG892.jpeg 这里的筛选结果有十几个点击事件的响应对象,我们需要做的就是一个一个的去尝试,找到那个目标对象的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使用的是系统主题,那么按照这个思路应该也可以通过反射实现。 步骤如下:

    1. 接入第三方轮子bypassHiddenApiRestriction(PS:远程依赖的时候使用并未成功,我将项目clone下来打包为aar,导入项目后使用正常!),并初始化:
HiddenApiBypass.startBypass()

    1. 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)
                }

            }
        }

    }

}

作者:苏灿烤鱼
链接:https://juejin.cn/post/7190280650283778106

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
在这里插入图片描述
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

全套视频资料:

一、面试合集在这里插入图片描述
二、源码解析合集
在这里插入图片描述

三、开源框架合集
在这里插入图片描述
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值