一、问题的由来


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

告诉栏监听模仿点击怎么完成?

我认为事务情形是在自己运用内,防止脚本模仿点击而引申出来的一个需求,心里还在想,是否能够运用自定义View——onTouchEvent的参数MotionEventgetPressure来判别是否是模仿点击。后来经过沟通得知,事务需求是怎么监听第三方运用的告诉栏,完成具体按钮的点击。如下图:

通知栏的那些奇技淫巧

上面是多家音频运用的告诉栏在小米手机的样式,而网友的需求是怎么完成针对某一个运用告诉栏的某一个按钮的点击,比方监听喜马拉雅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);
    }

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

通知栏的那些奇技淫巧

至此,咱们就知道了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运用的是体系主题,那么依照这个思路应该也能够经过反射完成。 步骤如下:

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

参考:

Android告诉监听服务之NotificationListenerService运用篇

另一种绕过Android 9以上非SDK接口调用约束的办法