輔助功能(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介紹:
-
onServiceConnected()
:做一些初始化的操作 -
onInterrupt ()
:AccessibilityService被中斷時會調用,在整個生命周期里會被調用多次。 -
onUnbind(intent: Intent)
:你可以做一些初始化的操作 -
onServiceConnected
:AccessibilityService將要關閉時會被調用,這個方法做一些釋放資源的操作。 -
onAccessibilityEvent(event: AccessibilityEvent?)
:核心API,AccessibilityEvent事件的回調函數,系統通過sendAccessibiliyEvent()方法發送AccessibilityEvent事件到這里 -
getRootInActiveWindow()
:則會返回當前活動窗口的根結點,查找View的時候用到它 -
findFoucs(int falg)
:查找擁有特定焦點類型的控件 -
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個:
-
android:packageNames
:指定輔助服務監聽哪些應用發出事件,多個應用包名之間用逗號分隔,如果不填,則監聽手機上所有應用。例如我們現在要利用輔助點擊做app的自動安裝功能,取值com.android.packageinstaller。如果只關注微信發出的事件,那么取值com.tencent.mm。 -
android:accessibilityEventTypes
:輔助服務監聽的事件類型,例如TYPE_VIEW_FOCUSED、TYPE_VIEW_CLICKED 、TYPE_WINDOW_STATE_CHANGED、TYPE_NOTIFICATION_STATE_CHANGED等等,如果監聽全部事件,就取值typeAllMask -
android:accessibilityFlags
:輔助服務額外的flag信息,例如FLAG_REPORT_VIEW_IDS可以使回調的事件帶上view的ID。 -
android:accessibilityFeedbackType
:事件的反饋類型,例如通用反饋FEEDBACK_GENERIC、聲音反饋FEEDBACK_AUDIBLE、語音反饋FEEDBACK_SPOKEN等。 -
android:notificationTimeout
:兩個同樣類型的監聽事件發給輔助類的最小時間間隔 -
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
類中。
handleNotification(event: AccessibilityEvent?)
。當eventType == AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED == 64的時候執行這個事件流,這個事件表示監聽到了通知欄事件,微信處在后臺的時候來了聊天消息,就會出發這個事件,我們的方法檢測通知內容是否包含為本"[微信紅包]",如果包含就表示收到了紅包消息,就執行它附帶的PendingIntent,然后就會跳到相應的聊天頁面。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()方法,顧名思義,這個方法要搜索紅包并點擊。我們傳給它的參數通過APIAccessibilityService.getRootInActiveWindow()
獲取的,我有點搞不懂這個API和AccessibilityEvent.getSource()
有什么區別,前者是輔助服務調用的,應該是窗口的根節點,后者是監聽到的某個事件獲取的,應該是這個事件的源節點,我用Log顯示大部分時候兩者是一致的。searchPacket方法通過遞歸查找紅包,當找到某個節點內容包含“領取紅包”就終止遞歸,然后循環查找這個節點和它的父節點的第一個能夠點擊的節點,執行點擊事件rootInActiveWindow.performAction(AccessibilityNodeInfo.ACTION_CLICK)
就能自動點擊紅包。openPacket(rootInActiveWindow: AccessibilityNodeInfo?)
。條件同上,當eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED == 32或者eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED == 2048的時候執行這個事件流。通過上面的searchPacket我們搜索到了紅包并點擊了,這時會出現紅包領取頁面,我們這里openPacket方法是要找到領取紅包的節點并執行這個節點的點擊事件進行領取。關鍵是如何找到這個節點,一種方法是通過ViewId,APIAccessibilityNodeInfo.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)
。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手機開啟方法如圖所示:好了,本文是對AccessibilityService簡單的應用,有更好的想法和項目請留言,我去star。
參考:
http://www.lxweimin.com/p/4cd8c109cdfb
http://www.lxweimin.com/p/959217070c87
https://www.cnblogs.com/happyhacking/p/6368888.html