本文为稀土技能社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
0x1、引言
Hi,我是杰哥,上节咱们学习了AccessibilityService无障碍的基础知识,并写了一个简略的微信主动登录的小案例。信任咱们都意犹未尽,所以本节组织一波实战 —— 微信僵尸老友检测。
啥是 僵尸老友?
在微信里,对方把你删去/拉黑了,并不会从你的老友列表消失,只需你给他/她发音讯,看到赤色感叹号才知道。而他/她假如把 加老友验证选项封闭,你发音讯不会有赤色感叹号,而对方却能看到你发的音讯:
他/她顺手点这个把你加回来,你这边是不会有任何提示的,所以被删这件事你可能永远都不知道~
对于我这种有强迫症的人来说,已然对方删了我,那我也要删了他/她。直接粗暴地给每个老友 群发音讯看会不会呈现赤色感叹号 的计划显然不太行,浪费自己时刻不说,还打扰了别人,万一发给了一些不得不加,但平常无天可聊的人,就为难了。
顺手搜了一下,看到一个 拉群 的计划:拉群时被删的老友会提示不是老友联系:
但每次只能检测50人 (如同是这个值),超过50需求对方同意才干够入群,就是会 收到约请通知。而少于50的话,只需不往群里发音讯,被拉的人是不知道群存在的。当然,要是被别人知道了的话,就是社死了~
又顺手搜了一下:主动整理微信僵尸老友
九块九解君愁?也有些免费帮你整理的公号 (哪有那么多天上掉馅饼的好事),看了下演示视频,需求扫码登录,猜想用的是PC端协议。登录后,他们能够用你的账号随意发音讯,这就存在 风险 了,万一他们:以患病、出车祸、急用钱等各种理由向你的朋友 群发欺诈信息,又或许 群发色情信息 给你造成不良影响,导致封号呢?
所以,但凡触及到 要你登录 的,都请不要尝试,以免造成不必要的丢失。那,有没有 免费安全又好用 的东西呢?还真有,网上许多文章都提到了它李跳跳的 实在老友
,界面长这样:
用法简略:
翻开无障碍权限,点击开端检测,晾一边等它主动检测完,最终会输出正常/反常老友到列表。点击能够仿制微信号,翻开微信自行搜索,按需删去联系反常的老友即可。
笔者简略体验了一下,很赞,虽然没开源,但APP没恳求任何权限(不联网),所以你不需求担心隐私泄露啥的。假如懒得折腾,完全能够放心使用,当然,建议到官方公号「大小姐李跳跳」下载。毕竟破解APP后加点广告、引流信息等恶意内容很常见,比方我就见过越过广告的APP反而被加入了开屏广告,23333~
em… 如同扯得有点远了,本文的意图不是教会咱们使用这款软件,而是 借(chao)鉴(xi) 它, 使用上节所学的AccessibilityService基础,自己完成一个检测微信僵尸老友的东西!本节某些东西代码get√了,也能够为你开发其它无障碍服务脚本供给一些助力哦~ 话不多说,赶紧开端!!!
0x2、怎么判别被删去/拉黑?—— 假转账法
上面说了 群发音讯 和 拉群 验证老友联系都不太靠谱,所以这儿选用实在老友用的—— 假转账法,无感,不打扰对方,也不会产生实在的转账行为。它的断定流程如下:
- 进入老友转账页,呢称后边呈现实在姓名,阐明是 正常老友联系;
- 呢称后没有实在姓名,进行 “假转账” 进一步承认联系,可能会呈现四种状况:
- ① 提示:你不是收款方老友,对方增加你为老友后才干建议转账 → 阐明被删去了;
- ② 提示:请承认你和他(她)的老友联系是否正常 → 阐明被拉黑了;
- ③ 提示:对方微信号已被限制登录,为保障你的资金安全,暂时无法完结交易 → 对方账号反常;
- ④ 弹出:输入付出暗码 界面,阐明是正常老友联系
核心难点解决了,记者就是编写脚原本完成主动化了~
0x3、实战环节
① 界面设计
设置页 直接复用上节的熊猫头,增加一个 去整理的Button,点击跳转到 整理僵尸老友页,根本UI款式如下:
一个从头检测的Button + 一个显现成果的RecyclerView,十分简练(lou)~
② 跳转微信
跳转外部APP的方法有两种:Intent指定启动APP包名和Activity名
或 URL Scheme恳求
,直接给出东西代码:
/**
* 跳转其它APP
* @param packageName 跳转APP包名
* @param activityName 跳转APP的Activity名
* @param errorTips 跳转页面不存在时的提示
* */
fun Context.startApp(packageName: String, activityName: String, errorTips: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW).apply {
component = ComponentName(packageName, activityName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
})
} catch (e: ActivityNotFoundException) {
shortToast(errorTips)
} catch (e: Exception) {
e.message?.let { logD(it) }
}
}
/**
* 跳转其它APP
* @param urlScheme URL Scheme恳求字符串
* @param errorTips 跳转页面不存在时的提示
* */
fun Context.startApp(urlScheme: String, errorTips: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(urlScheme)))
} catch (e: ActivityNotFoundException) {
shortToast(errorTips)
} catch (e: Exception) {
e.message?.let { logD(it) }
}
}
读者可能对这儿的捕获 ActivityNotFoundException 感到古怪,为啥不经过 getPackageManager().getInstalledPackages(0)
读已装置应用列表,然后再遍历判别?
答:由于这样不只需求权限,还触及到了隐私,为了简化处理,直接捕获这个反常,然后给出未装置的提示。由于假如设备装置了,一般只需你不写错包名啥的,是不会触发这个反常的!调用示例如下:
startApp("com.tencent.mm", "com.tencent.mm.ui.LauncherUI", "未装置微信")
startApp("weixin://", "未装置微信")
另外,URL Scheme对于一些内嵌浏览器页面的APP跳转有奇效,比方之前某东双11活动页的跳转的scheme如下:
"openApp.jdMobile://virtual?params={"category":"jump","action":"to","des":"m","sourceValue":"JSHOP_SOURCE_VALUE","sourceType":"JSHOP_SOURCE_TYPE","url":"https://u.jd.com/kIrrQ3H","M_sourceFrom":"mxz","msf_type":"auto"}'})"
履行后会跳转到下述页面:(活动已过期,正常状况下你是进不了这个页面的~)
③ 搞清Event的触发链条
按照上节所说,能够先把无障碍配置文件里的 android:accessibilityEventTypes
设置为 typeAllMask
,监听一切类型的Event。在 onAccessibilityEvent()
里把日志打印出来,然后挑选自己重视的Event类型,最终再把 android:accessibilityFeedbackType
设置为这些类型。
点击从头检测,跳转微信,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.LauncherUI
- 阐明:进入微信主页
此刻点击底部的 通讯录,输出日志如下:
当:EventType → TYPE_VIEW_CLICKED,Text → 通讯录 时阐明点击了通讯录。接着随意 点击一个联系人,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.plugin.profile.ui.ContactInfoUI
- 阐明:进入联系人信息页
接着 点击发音讯,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.chatting.ChattingUI
- 阐明:进入谈天页
接着 点击加号更多按钮,输出日志如下:
当:EventType → TYPE_VIEW_CLICKED,Text → 更多功用按钮,已折叠 阐明:点击了更多按钮,接着 点击转账按钮,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → om.tencent.mm.plugin.remittance.ui.RemittanceUI
- 阐明:进入转账页,输入0.01,接着 点击转账按钮,输出日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → com.tencent.mm.ui.widget.dialog.f
- 阐明:呈现反常弹窗,老友联系不正常,比方这儿的Text就显现:你不是收款方老友,对方增加你为老友后才干建议转账, 我知道了。
接着再试试正常转账,需求输入付出暗码的状况,输入日志如下:
- 当:EventType → TYPE_WINDOW_STATE_CHANGED,ClassName → android.widget.LinearLayout – 阐明:呈现输入付出暗码的页面
由于LinearLayout不是特别的Activity或许类,所以等下还得特别处理一下。当然,这只是大约的Event触发流程,实践开发过程可能呈现某些Event不触发的景象,见机行事咯。接着就是在恰当的Event,获取相应节点,履行对应的交互,如点击、滑动等。不过再次之前,还得先改动下咱们的 无障碍配置文件~。
④ 修改无障碍配置文件
笔者忽然有点猎奇 实在老友 的配置,那就开扒,定位到它的配置文件:
咦,跟我的配置不一样,没设置 android:packageNames,上节说过不设置这个属性的话,是监听一切App的,检测僵尸老友,不是只应该监听 com.tencent.mm 微信的吗?还有监听的事情类型只监听 typeWindowsChanged,关于这种类型,官方文档中这样介绍到:
API 21新增,体系窗口事情改动会触发,难不成这中event类型更高效?写一个顶几个?所以我Copy了它的配置,并加上 android:packageNames=”com.tencent.mm”,运转后却发现没有日志输出。
接着把它删掉再试,此刻有日志信息输出:
但packageName和className都是null,这样能区别哪个APP?哪个页面?真是老友是咋做的?
简略脱下壳导出dex,丢电脑里用jadx反编译成java,直接定位到它的无障碍服务类 → MyAccessibilityService
,搜 setServiceInfo()
,我感觉它是不是在代码里又进行了动态配置,成果没找着,接着搜 onAccessibilityEvent()
:
往线程池里丢了线程实例t,跟下t的代码完成:
榜首段不难看出大约得逻辑:
getRootInActiveWindow() 取得节点树,然后判别packageName是否为com.tencent.mm,是履行微信相关校验逻辑
第二段略微难猜一点,应该是用来 判别用户是否退出微信,只在 onServiceConnected() 调用一次,断点了一下for循环:
这儿拿到了Launcher(桌面启动器) 和 设置的包名信息,此后的com.android.incallui一般是拨号软件的包名,再加上实在老友的包名。假如包名和这四个匹配,阐明用户退出微信页面,中断使命履行。
另外,在阅读源码时还发现了作者不同版本的兼容方法,需求经过id定位节点的,把每个版本节点对应id存一个数组中,遍历查找:
虽然没细看完整代码,不过大约能猜到作者的意图,这样处理的好处,不必区别Event类型,依据页面特征点进行匹配,当时处于哪一步,履行对应的处理逻辑,实属牛啤!
但咱们这儿不这样做,毕竟练手,2333,仍是特意区别event来游玩,后边再改善亦可,给出咱们的无障碍服务配置如下:
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/accessibility_desc"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewClicked"
android:accessibilityFeedbackType="feedbackSpoken"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"
android:notificationTimeout="100"
android:canPerformGestures="true"
android:packageNames="com.tencent.mm"
android:settingsActivity="cn.coderpig.clearcorpse.SettingActivity" />
另外,实在老友配置文件中的 android:accessibilityFlags=”flagRetrieveInteractiveWindows” 这个是用来搭配 TYPE_WINDOWS_CHANGE 事情类型使用的:
⑤ 点击通讯录Tab
跳转微信后,定位到通讯录节点,触发点击,运转打印节点树的python脚本,输出成果如下:
能够看到id未f2s得节点有多个,故这儿经过文本匹配,而它的clickable为false,阐明是不行点击的,得调用 parent()
取得他的父节点才干点击。
这种状况很常见,获取到的节点不支持点击,有时得连续调用好几个 parent()
才干拿到可点击的节点,跟连体蜈蚣一样。所以这儿封装下点击的方法,递归获取能点击的父节点,具体代码如下:
// 点击
fun AccessibilityNodeInfo?.click() {
if (this == null) return
if (this.isClickable) {
this.performAction(AccessibilityNodeInfo.ACTION_CLICK)
return
} else {
this.parent.click()
}
}
// 长按
fun AccessibilityNodeInfo?.longClick() {
if (this == null) return
if (this.isClickable) {
this.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK)
return
} else {
this.parent.longClick()
}
}
补全下点击代码:
class ClearCorpseAccessibilityService : AccessibilityService() {
companion object {
const val LAUNCHER_UI = "com.tencent.mm.ui.LauncherUI" // 主页
}
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (event.eventType == TYPE_WINDOW_STATE_CHANGED) {
when (event.className.toString()) {
LAUNCHER_UI -> {
event.source?.let { source ->
source.getNodeByText("通讯录").click()
}
}
}
}
}
override fun onInterrupt() { }
}
能够,运转后主动点击通讯录了。
关于结点查找的两个方法: findAccessibilityNodeInfosByViewId()
和 findAccessibilityNodeInfosByText()
的回来类型都是 List<AccessibilityNodeInfo>,而咱们大部分时分只需榜首个结点,每次得写一堆判空然后取榜首个的重复代码显得不太漂亮,同样封装下,顺带加上轮询,由于有时页面可能还没load完,此刻拿不到节点,过一瞬间就能拿到了,封装后的代码如下:
/**
* 依据id查找单个节点
* @param id 控件id
* @return 对应id的节点
* */
fun AccessibilityNodeInfo.getNodeById(id: String): AccessibilityNodeInfo? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByViewId(id).let {
if (!it.isNullOrEmpty()) return it[0]
}
sleep(100)
count++
}
return null
}
/**
* 依据id查找多个节点
* @param id 控件id
* @return 对应id的节点列表
* */
fun AccessibilityNodeInfo.getNodesById(id: String): List<AccessibilityNodeInfo>? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByViewId(id).let {
if (!it.isNullOrEmpty()) return it
}
sleep(100)
count++
}
return null
}
/**
* 依据文本查找单个节点
* @param text 匹配文本
* @param allMatch 是否全匹配,默许false,contains()方法的匹配
* @return 匹配文本的节点
* */
fun AccessibilityNodeInfo.getNodeByText(
text: String,
allMatch: Boolean = false
): AccessibilityNodeInfo? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByText(text).let {
if (!it.isNullOrEmpty()) {
if (allMatch) {
it.forEach { node -> if (node.text == text) return node }
} else {
return it[0]
}
}
sleep(100)
count++
}
}
return null
}
/**
* 依据文本查找多个节点
* @param text 匹配文本
* @param allMatch 是否全匹配,默许false,contains()方法的匹配
* @return 匹配文本的节点列表
* */
fun AccessibilityNodeInfo.getNodesByText(
text: String,
allMatch: Boolean = false
): List<AccessibilityNodeInfo>? {
var count = 0
while (count < 10) {
findAccessibilityNodeInfosByText(text).let {
if (!it.isNullOrEmpty()) {
return if (allMatch) {
val tempList = arrayListOf<AccessibilityNodeInfo>()
it.forEach { node -> if (node.text == text) tempList.add(node) }
if (tempList.isEmpty()) null else tempList
} else {
it
}
}
sleep(100)
count++
}
}
return null
}
/**
* 获取结点的文本
* */
fun AccessibilityNodeInfo?.text(): String {
return this?.text?.toString() ?: ""
}
封装好的代码等下直接调,乐滋滋~
⑥ 老友列表点击
来到老友列表,仍是运转打印节点树的python脚本:
直接就定位到了列表节点,但我忽然有点厌烦这种获取方法了,每次找节点都得运转一次脚本,得想办法简化下~
忽然心生一计,我直接写个递归遍历结点的方法,把要用到的信息打印出来不就好了?说干就干:
/**
* 遍历打印结点
* */
fun AccessibilityNodeInfo?.fullPrintNode(
tag: String,
spaceCount: Int = 0
) {
if (this == null) return
val spaceSb = StringBuilder().apply { repeat(spaceCount) { append(" ") } }
logD("$tag: $spaceSb$text | $viewIdResourceName | $className | Clickable: $isClickable")
if (childCount == 0) return
for (i in 0 until childCount) getChild(i).fullPrintNode(tag, spaceCount + 1)
}
// 调用下
source.fullPrintNode("主页")
运转后,输出日志信息如下:
舒服了啊,列表项的id也get√了,持续完善下代码:
const val CONTACT_LIST_ID = "js"
const val CONTACT_ITEM_ID = "hg4"
override fun onAccessibilityEvent(event: AccessibilityEvent) {
when (event.eventType) {
TYPE_WINDOW_STATE_CHANGED -> {
when (event.className.toString()) {
LAUNCHER_UI -> {
event.source?.let { source -> source.getNodeByText("通讯录").click() }
}
}
}
TYPE_VIEW_CLICKED -> {
if (event.text[0] == "通讯录") {
// 这儿不能用event的getSource(),只能获取到发生改动的节点
// 需求调用getRootInActiveWindow()取得一切结点
rootInActiveWindow?.let { source ->
val contactList = source.getNodeById(wxNodeId(CONTACT_LIST_ID))
if (contactList != null) {
contactList.getNodeById(wxNodeId(CONTACT_ITEM_ID)).click()
} else {
logD("未能获取老友列表")
}
}
}
}
else -> logD("$event")
}
}
运转看下效果:
杠杠滴!有了上面的东西代码,后续的开发也变得简略了许多~
⑦ 联系人信息页点击发音讯
来到联系人信息页,获取联系人微信号,然后点击发音讯,代码如下:
运转后正常点击,控制台看到联系人微信号也打印出来了~
⑧ 谈天页点击加号+转账
来到谈天页,点击更多按钮,底部弹出窗口点击转账。但这儿有些古怪,并没有走到上面的ChattingUI,所以这儿换成监听点击了发音讯,然后再履行这些操作。
理论上是这样,但实践上并没有点击转账,打断点发现,点击确实实是clickable的父节点。应该是微信做了什么防护,阻拦了节点的点击行为。这种状况得变通下了,用手势的方法来完成模仿点击,同样给出直接就能用的东西代码:
/**
* 使用手势模仿点击
* @param node: 需求点击的节点
* */
fun AccessibilityService.gestureClick(node: AccessibilityNodeInfo?) {
if (node == null) return
val tempRect = Rect()
node.getBoundsInScreen(tempRect)
val x = ((tempRect.left + tempRect.right) / 2).toFloat()
val y = ((tempRect.top + tempRect.bottom) / 2).toFloat()
dispatchGesture(
GestureDescription.Builder().apply {
addStroke(GestureDescription.StrokeDescription(Path().apply { moveTo(x, y) }, 0L, 200L))
}.build(),
object : AccessibilityService.GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
super.onCompleted(gestureDescription)
logD("手势点击完结: 【$x - $y】")
}
},
null
)
}
修改下调用处:
运转后看看效果:
能够,手势模仿点击正常~
⑨ 转账页处理逻辑
来到转账页,判别昵称后边是否有实在姓名,是阐明老友联系正常。没有的话,转账0.01,呈现反常状况弹窗(被删、拉黑、对方账号反常),呈现输入暗码的弹窗阐明联系正常。逻辑十分清楚,就直接给出代码吧:
运转下看效果,先是反常老友:
紧接着是正常老友:
日志输出如下:
能够,到此整条检测链条的根本流程就完成啦~
0x4、小结
不知不觉又到文尾,限于篇幅,并没有完成完整功用,现在还差:遍历一切老友履行上述逻辑和检测成果保存了,当然可能还有一些bug,后续会完善下更新到Github上:ClearCorpse,感兴趣的能够先Star,也能够自己续着写,师傅领进门,修行靠本身,多练多总结才是真,感谢,咱们下节再会~