Android--AccessibilityService辅助功能基础使用(附微信抢红包教程)

辅助功能(AccessibilityService)是一个Android系统提供的一种服务,继承自Service类。AccessibilityService运行在后台,能够监听系统发出的一些事件(AccessibilityEvent),这些事件主要是UI界面一系列的状态变化,比如按钮点击、输入框内容变化、焦点变化等等,查找当前窗口的元素并能够模拟点击等事件。官方文档

这个系统功能主要为一些残障人士用户设计,他们由于各种原因比如视力、年龄、身体等因素导致使用Android设备困难。但是很多android开发者用这个功能来做一些不正常的操作,当然这种极客精神,只要不非法,我不认为是错误的。

开始使用

AccessibilityService使用非常非常简单。

1 首先新建一个类MyAccessibilityService并继承AccessibilityService

代码如下:

// 代码片段1
class MyAccessibilityService : AccessibilityService() {
    override fun onInterrupt() {
    }

    override fun onServiceConnected() {
        super.onServiceConnected()
//        val serviceInfo = AccessibilityServiceInfo()
//        serviceInfo.eventTypes = AccessibilityEvent.TYPES_ALL_MASK//typeNotificationStateChanged|typeWindowStateChanged|typeWindowContentChanged
//        serviceInfo.feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC//feedbackGeneric
//        serviceInfo.packageNames = arrayOf("com.tencent.mm")//com.tencent.mm
//        serviceInfo.notificationTimeout = 100
//        serviceInfo.flags = AccessibilityServiceInfo.DEFAULT
////        //android:canRetrieveWindowContent="true"
////        serviceInfo.canRetrieveWindowContent = true
//        setServiceInfo(serviceInfo)
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        dispatchEvent(event, rootInActiveWindow)
    }
}

onAccessibilityEvent(AccessibilityEvent event)onInterruput()这两个方法是抽象方法,必须重写。
常用API介绍:

  1. onServiceConnected():做一些初始化的操作
  2. onInterrupt ():AccessibilityService被中断时会调用,在整个生命周期里会被调用多次。
  3. onUnbind(intent: Intent):你可以做一些初始化的操作
  4. onServiceConnected:AccessibilityService将要关闭时会被调用,这个方法做一些释放资源的操作。
  5. onAccessibilityEvent(event: AccessibilityEvent?):核心API,AccessibilityEvent事件的回调函数,系统通过sendAccessibiliyEvent()方法发送AccessibilityEvent事件到这里
  6. getRootInActiveWindow():则会返回当前活动窗口的根结点,查找View的时候用到它
  7. findFoucs(int falg):查找拥有特定焦点类型的控件
  8. disableSelf():禁用当前服务
2 辅助类的声明与配置

AccessibilityService继承Service,因此也需要在AndroidManifest.xml中声明:

// 代码片段2
<service
    android:name=".access.MyAccessibilityService"
    android:enabled="true"
    android:exported="true"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService"/>
    </intent-filter>
    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility"/>
</service>

注意需要加上BIND_ACCESSIBILITY_SERVICE权限。代码片段2中的meta部分是AccessibilityService的配置信息,这是android 4.0后才支持的,代码如下:

// 代码片段3
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"

                       android:accessibilityEventTypes="typeAllMask"
                       android:accessibilityFeedbackType="feedbackGeneric"
                       android:accessibilityFlags="flagReportViewIds"
                       android:canRetrieveWindowContent="true"
                       android:notificationTimeout="100"
                       android:packageNames="com.tencent.mm"/>

设置配置信息还有第二种方法,就是在onServiceConnected()方法中使用代码设置,如代码片段1中的注释部分所示。这里的配置有很多属性,我们只研究其中的6个:

  1. android:packageNames:指定辅助服务监听哪些应用发出事件,多个应用包名之间用逗号分隔,如果不填,则监听手机上所有应用。例如我们现在要利用辅助点击做app的自动安装功能,取值com.android.packageinstaller。如果只关注微信发出的事件,那么取值com.tencent.mm。
  2. android:accessibilityEventTypes:辅助服务监听的事件类型,例如TYPE_VIEW_FOCUSED、TYPE_VIEW_CLICKED 、TYPE_WINDOW_STATE_CHANGED、TYPE_NOTIFICATION_STATE_CHANGED等等,如果监听全部事件,就取值typeAllMask
  3. android:accessibilityFlags:辅助服务额外的flag信息,例如FLAG_REPORT_VIEW_IDS可以使回调的事件带上view的ID。
  4. android:accessibilityFeedbackType:事件的反馈类型,例如通用反馈FEEDBACK_GENERIC、声音反馈FEEDBACK_AUDIBLE、语音反馈FEEDBACK_SPOKEN等。
  5. android:notificationTimeout:两个同样类型的监听事件发给辅助类的最小时间间隔
  6. android:canRetrieveWindowContent:是否可以获取窗口内容,一般设置为true

处理监听到的事件

前面就是使用辅助类的全部了,怎么样,是不是很简单?但是处理监听到的事件就有点麻烦了。我在github上写了一个微信抢红包的的开源项目,代码地址,我结合这个git库的代码解释下如何处理监听事件。

处理事件的入口是onAccessibilityEvent(event: AccessibilityEvent?)方法,我写了一个分发事件的类:DispatchEvent.kt,里面的方法dispatchEvent(event: AccessibilityEvent?, rootInActiveWindow: AccessibilityNodeInfo?)负责分发事件,代码如下:

// 代码片段4
fun dispatchEvent(event: AccessibilityEvent?, rootInActiveWindow: AccessibilityNodeInfo?) {
    val pkgName = event?.packageName.toString()
    val eventType = event?.getEventType()
    Log.i(TAG, "pkgName:${pkgName}     eventType:${eventType}      className:${event?.getClassName().toString()}      " +
            "event.text:${listToString(event?.text)} event?.getContentChangeTypes():${event?.getContentChangeTypes()}\n")
    when (eventType) {
        AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> com.example.zhouzhihui.accessibilitydemo.access.packet.handleNotification(event)//64     1-->click
        AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {//32 2048
            val className = event.getClassName().toString()
            if (className == "com.tencent.mm.ui.LauncherUI" || className == "com.tencent.mm.ui.mogic.WxViewPager" || className == "android.widget.EditText"/* || className == "android.widget.ListView"*/) {
                com.example.zhouzhihui.accessibilitydemo.access.packet.searchPacket(rootInActiveWindow)
            } else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI") {//com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyPrepareUI
                com.example.zhouzhihui.accessibilitydemo.access.packet.openPacket(rootInActiveWindow)
            } else if (className == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI") {
                com.example.zhouzhihui.accessibilitydemo.access.packet.closePacket(rootInActiveWindow)
            }
        }
    }
    if (event?.getContentChangeTypes() == AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT) {
        Withdraw().withDraw(event, rootInActiveWindow)//防消息撤回
    }

    rootInActiveWindow?.recycle()//避免重复创建实例通过recycle方法回收掉nodeInfo(我们自己手动去回收)
}

代码片段4事件被分发成四个分流:handleNotification(event: AccessibilityEvent?)searchPacket(rootInActiveWindow: AccessibilityNodeInfo?)openPacket(rootInActiveWindow: AccessibilityNodeInfo?)closePacket(rootInActiveWindow: AccessibilityNodeInfo?),这四个方法的处理逻辑在Packet.kt类中。

  1. handleNotification(event: AccessibilityEvent?)。当eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED == 64的时候执行这个事件流,这个事件表示监听到了通知栏事件,微信处在后台的时候来了聊天消息,就会出发这个事件,我们的方法检测通知内容是否包含为本"[微信红包]",如果包含就表示收到了红包消息,就执行它附带的PendingIntent,然后就会跳到相应的聊天页面。

  2. searchPacket(rootInActiveWindow: AccessibilityNodeInfo?)。当eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED == 32或者eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED == 2048的时候执行这个事件流。32表示窗口状态发生了变化,比如微信的主页"com.tencent.mm.ui.LauncherUI"从后台调到前台就会触发这个事件,并且它附带的className就是"com.tencent.mm.ui.LauncherUI";2048表示窗口的内容发生了变化,比如你在微信的第一个tab页面,这时候来了个聊天消息,就会触发这个事件,附带的className是android.widget.ListView,嗯,没错,微信竟然还是在用ListView这个过时的组件而不是RecyclerView。我们捕捉到这个事件后调用searchPacket()方法,顾名思义,这个方法要搜索红包并点击。我们传给它的参数通过API AccessibilityService.getRootInActiveWindow()获取的,我有点搞不懂这个API和AccessibilityEvent.getSource()有什么区别,前者是辅助服务调用的,应该是窗口的根节点,后者是监听到的某个事件获取的,应该是这个事件的源节点,我用Log显示大部分时候两者是一致的。searchPacket方法通过递归查找红包,当找到某个节点内容包含“领取红包”就终止递归,然后循环查找这个节点和它的父节点的第一个能够点击的节点,执行点击事件rootInActiveWindow.performAction(AccessibilityNodeInfo.ACTION_CLICK)就能自动点击红包。

  3. openPacket(rootInActiveWindow: AccessibilityNodeInfo?)。条件同上,当eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED == 32或者eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED == 2048的时候执行这个事件流。通过上面的searchPacket我们搜索到了红包并点击了,这时会出现红包领取页面,我们这里openPacket方法是要找到领取红包的节点并执行这个节点的点击事件进行领取。关键是如何找到这个节点,一种方法是通过ViewId,API AccessibilityNodeInfo.getViewIdResourceName()可以获取这个节点的id,但是你需要事先知道这个节点的id,而且辅助的配置标记必须是android:accessibilityFlags="flagReportViewIds"才能获取节点的id,可以使用Android Device Monitor或者Layout Inspector查看id,也可以直接把节点的id打印出来进行查看对比,但是微信的程序员经常改变id,我不认为这个方法是可靠的,我的方法是如果满足条件(rootInActiveWindow?.isClickable == true && rootInActiveWindow?.className?.contains("android.widget.Button") == true)就认为这个节点是领取红包的按钮,然后执行点击事件:rootInActiveWindow?.performAction(AccessibilityNodeInfo.ACTION_CLICK)

  4. closePacket(rootInActiveWindow: AccessibilityNodeInfo?)。条件同上,当eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED == 32或者eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED == 2048的时候执行这个事件流。这个方法是为了找到左上角的返回按钮,进行点击返回聊天页面。这个也不是通过id的方式,而是如果满足(rootInActiveWindow?.className == "android.widget.LinearLayout" && rootInActiveWindow?.isClickable && TextUtils.isEmpty(rootInActiveWindow?.text))就认为是左上角的返回节点。

下面贴出代码:

// 代码片段5
fun handleNotification(event: AccessibilityEvent?) {
    if (event == null) {
        return
    }
    val texts = event.text
    if (!texts.isEmpty()) {
        for (text in texts) {
            val content = text.toString()
            if (content.contains("[微信红包]")) {
                if (event.parcelableData != null && event.parcelableData is Notification) {
                    val notification = event.parcelableData as Notification
                    val pendingIntent = notification.contentIntent
                    try {
                        pendingIntent.send()
                    } catch (e: PendingIntent.CanceledException) {
                        e.printStackTrace()
                    }

                }
            }
        }
    }
}

fun searchPacket(rootInActiveWindow: AccessibilityNodeInfo?) {
    Log.i(TAG, "searchPacket node: ${rootInActiveWindow} childCount: ${rootInActiveWindow?.childCount}   idName: ${rootInActiveWindow?.getViewIdResourceName()}")
    if (rootInActiveWindow?.text.toString() == "领取红包") {
        if (rootInActiveWindow?.isClickable == true) {
            rootInActiveWindow.performAction(AccessibilityNodeInfo.ACTION_CLICK)
        } else {
            var parent: AccessibilityNodeInfo? = rootInActiveWindow?.getParent()
            while (parent != null) {
                if (parent.isClickable) {
                    parent.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                    break
                }
                parent = parent.parent
            }
        }
    } else {
        for (i in 0 until (rootInActiveWindow?.childCount ?: -1)) {
            searchPacket(rootInActiveWindow?.getChild(i))
        }
    }
}

fun openPacket(rootInActiveWindow: AccessibilityNodeInfo?) {
    Log.i(TAG, "openPacket node: ${rootInActiveWindow} childCount: ${rootInActiveWindow?.childCount}   idName: ${rootInActiveWindow?.getViewIdResourceName()}")
    if (rootInActiveWindow?.isClickable == true && rootInActiveWindow?.className?.contains("android.widget.Button") == true) {
        rootInActiveWindow?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
    }
//        node?.traversalAfter
    for (i in 0 until (rootInActiveWindow?.childCount ?: -1)) {
        openPacket(rootInActiveWindow?.getChild(i))
    }
}

fun closePacket(rootInActiveWindow: AccessibilityNodeInfo?) {
    Log.i(TAG, "closePacket node: ${rootInActiveWindow} childCount: ${rootInActiveWindow?.childCount}   idName: ${rootInActiveWindow?.getViewIdResourceName()}")
    if (rootInActiveWindow?.className == "android.widget.LinearLayout" && rootInActiveWindow?.isClickable && TextUtils.isEmpty(rootInActiveWindow?.text)) {
        //className: android.widget.LinearLayout; text: null; error: null; maxTextLength: -1; contentDescription: null; viewIdResName: com.tencent.mm:id/ho;
        rootInActiveWindow?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
        return
    }
    for (i in 0 until (rootInActiveWindow?.childCount ?: -1)) {
        closePacket(rootInActiveWindow?.getChild(i))
    }
}

此外,在MainActivity里面,还有判断服务是否开启的逻辑,如果没有开启,则可以点击跳转带开启页面:

// 代码片段6 MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        hello.also {
            val isOn = isAccessibilityServiceOn()
            it.text = if (isOn) "服务已经开启" else "点击开启服务"
            it.isEnabled = !isOn
            it.setOnClickListener {
                startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
            }
        }
    }
}
// 代码片段7 Tools.kt
val TAG = AccessibilityService::class.java.simpleName
fun listToString(list : List<Any>?): String {
    var result = StringBuilder("")
    list?.forEach {
        result.append("${it.toString()}\t")
    }
    return result.toString()
}

fun isPrePagePacket(prePageName: String): Boolean {
    return prePageName == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyReceiveUI" || prePageName == "com.tencent.mm.plugin.luckymoney.ui.LuckyMoneyDetailUI"
}

fun Context.isAccessibilityServiceOn(): Boolean {
    var service = "${packageName}/${MyAccessibilityService::class.java.canonicalName}"
    var enabled = Settings.Secure.getInt(applicationContext.contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED)
    var splitter = TextUtils.SimpleStringSplitter(':')
    if (enabled == 1) {
        var settingValue = Settings.Secure.getString(applicationContext.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
        if (settingValue != null) {
            splitter.setString(settingValue)
            while (splitter.hasNext()) {
                var accessibilityService = splitter.next()
                if (accessibilityService.equals(service, ignoreCase = true)) {
                    return true
                }
            }
        }
    }
    return false
}

自动领取红包的代码写完了,运行安装到手机上,还差最后一步了,就是在手机的“设置”里面把刚刚装上的应用的服务开启,我的小米5手机开启方法如图所示:
image.png

好了,本文是对AccessibilityService简单的应用,有更好的想法和项目请留言,我去star。

参考:
https://www.jianshu.com/p/4cd8c109cdfb
https://www.jianshu.com/p/959217070c87
https://www.cnblogs.com/happyhacking/p/6368888.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268