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。

參考:
http://www.lxweimin.com/p/4cd8c109cdfb
http://www.lxweimin.com/p/959217070c87
https://www.cnblogs.com/happyhacking/p/6368888.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,996評論 2 374