【杰哥带你玩转Android自动化】AccessibilityS
0x1、引言
Hi,我是杰哥,在上一节《AccessibilityService实战-微信僵尸好友检测》中带大家利用所学的AccessibilityService基础知识,借鉴真实好友假转账的原理,实现了自己的专属微信僵尸好友检测工具。相信认真学完的读者对于自定义无障碍服务的开发流程都了然于胸,以后随手写个自动化小工具估摸着也是手到擒来了~
本节主要是拾遗,补充两点锦上添花的小细节:AccessibilityService实战的保活与防御。不哔哔,直接开始~
0x2、无障碍服务保活
应用保活,老生常谈的话题了,最早可以追溯到7年前的一个库 MarsDaemon,双进程守护,简单配置几行代码,即可实现进程常驻。
不过好景不长,Android 8.0后这个库就废掉了,后面陆续涌现了很多保活的 骚操作,如 1个像素的Activity、播放无声音频 等。
// 例:1像素的Activity
class OnePxActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
window.attributes = window.apply { setGravity(Gravity.START or Gravity.TOP) }
.attributes.apply {
width = 1
height = 1
x = 1
y = 1
}
}
}
当然这些骚操作,并不太通用靠谱,毕竟哪个厂商的底层不魔改一下,每家都有自己的一套管理系统。说句大实话:终极保活的技巧就是钞能力——花钱进厂商白名单。
没有钞能力也没关系,有一些通用可行的小技巧,可以提高你的APP的优先级,降低进程被杀的概率~
① 前台服务
把原本处于后台运行AccessibilityService设置为前台服务,需要在AndroidManifest.xml清单文件中声明下述权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
否则会报异常:
java.lang.SecurityException: Permission Denial:
startForeground from pid=2345, uid=10395 requires android.permission.FOREGROUND_SERVICE.
接着在 onCreate()
方法中创建Notification渠道,并开启前台服务,在 onDestory()
方法中停止前台服务,直接给出工具代码,读者按需修改即可:
class ClearCorpseAccessibilityService : AccessibilityService() {
...
override fun onCreate() {
super.onCreate()
// 创建Notification渠道,并开启前台服务
createForegroundNotification()?.let { startForeground(1, it) }
}
override fun onDestroy() {
// 停止前台服务
stopForeground(true)
super.onDestroy()
}
private fun createForegroundNotification(): Notification? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
// 创建通知渠道,一定要写在创建显示通知之前,创建通知渠道的代码只有在第一次执行才会创建
// 以后每次执行创建代码检测到该渠道已存在,因此不会重复创建
val channelId = "前台通知id名,任意"
notificationManager?.createNotificationChannel(
NotificationChannel(
channelId,
"前台通知名称,任意",
NotificationManager.IMPORTANCE_HIGH // 发送通知的等级,此处为高
).apply {
// 下述都是非必要的,看自己需求配置
enableLights(true) // 如果设备有指示灯,开启指示灯
lightColor = Color.GREEN // 设置指示灯颜色
enableVibration(true) // 开启震动
vibrationPattern = longArrayOf(100, 200, 300, 400) // 设置震动频率
setShowBadge(true) // 是否显示角标
setBypassDnd(true) // 是否绕过免打扰模式
lockscreenVisibility = Notification.VISIBILITY_PRIVATE // 是否在锁屏屏幕上显示此频道的通知
}
)
return NotificationCompat.Builder(this, channelId)
// 设置点击notification跳转,比如跳转到设置页
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, SettingActivity::class.java),
FLAG_IMMUTABLE
)
)
.setSmallIcon(R.drawable.ic_service_enable) // 设置小图标
.setContentTitle("通知标题")
.setContentText("通知内容")
.setTicker("通知提示语")
.build()
}
return null
}
...
}
运行后,在顶部通知栏可以看到前台服务的Notification:
② 取消电池优化限制
Android 6.0后为了省电,添加了休眠模式,系统待机一段时间后会杀死后台正常运行的进程,但系统会有一个 后台运行白名单。
早期的原生系统中,依次点击:设置 → 电池 → 电池优化 → 未优化应用,可以看到这个白名单。
而在后续的系统中(如我的Android 10),得去 应用和通知 找:找到自己的应用 → 点击电池 → 后台限制:
接着是:判断APP是否受电池优化限制 和 申请取消电池优化限制 的工具代码:
// 需在AndroidManifest.xml中添加下述权限
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
// 判断APP是否被限制
@RequiresApi(api = Build.VERSION_CODES.M)
private fun isIgnoringBatteryOptimizations() =
(getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(packageName)
// 申请取消限制
@RequiresApi(api = Build.VERSION_CODES.M)
fun requestIgnoreBatteryOptimizations() {
try {
startActivity(Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
})
} catch (e: Exception) {
e.printStackTrace()
}
}
申请时会有这样的弹窗 (不同手机系统各有差异):
③ 引导用户开启自启动
先是判断应用 是否开启自启动权限,很遗憾,笔者并没有找到 通用且公开的API,只找到有人通过 反射方式获得的,测试了一下,并不靠谱。
所以一个折中的方案:存一个自启动引导页是否打开的标记,弹之前判断下弹过没,没弹过就弹,弹过就不弹,如果弹了就修改下标记。
接着是跳转到设置页,因为厂商对系统的不同定制,导致 开启自启动 (有些都不叫这个名字) 的设置入口就五花八门,所以需要开发者根据不同品牌机型自行适配。
先判断是哪家的手机,然后跳转对应的设置页,笔者根据网上的几篇文章,简单地整理了一下 (因笔者测试机有限,未能全部测试覆盖,不对的欢迎评论区提出):
object RomUtil {
// 系统名
const val ROM_MIUI = "MIUI" // 小米
const val ROM_EMUI = "EMUI" // 华为
const val ROM_OPPO = "OPPO" // OPPO
const val ROM_VIVO = "VIVO" // VIVO
const val ROM_SMARTISAN = "SMARTISAN" // 锤子
const val ROM_FLYME = "FLYME" // 魅族
const val ROM_QIKU = "QIKU" // 360
// 对应系统有的属性
private const val KEY_VERSION_MIUI = "ro.miui.ui.version.name"
private const val KEY_VERSION_EMUI = "ro.build.version.emui"
private const val KEY_VERSION_OPPO = "ro.build.version.opporom"
private const val KEY_VERSION_SMARTISAN = "ro.smartisan.version"
private const val KEY_VERSION_VIVO = "ro.vivo.os.version"
// getprop命令去系统build.prop查找是否有对应属性来判断
private fun getProp(name: String): String? {
val line: String?
var input: BufferedReader? = null
try {
val process = Runtime.getRuntime().exec("getprop $name")
input = BufferedReader(InputStreamReader(process.inputStream), 1024)
line = input.readLine()
input.close()
} catch (ex: IOException) {
Log.e(TAG, "Unable to read prop $name", ex)
return null
} finally {
if (input != null) {
try {
input.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
}
return line
}
// 判断系统的方法
private fun check(rom: String): Boolean {
val tempRom: String?
if (!getProp(KEY_VERSION_MIUI).isNullOrBlank()) {
tempRom = ROM_MIUI
} else if (!getProp(KEY_VERSION_EMUI).isNullOrBlank()) {
tempRom = ROM_EMUI
} else if (!getProp(KEY_VERSION_OPPO).isNullOrBlank()) {
tempRom = ROM_OPPO
} else if (!getProp(KEY_VERSION_VIVO).isNullOrBlank()) {
tempRom = ROM_VIVO
} else if (!getProp(KEY_VERSION_SMARTISAN).isNullOrBlank()) {
tempRom = ROM_SMARTISAN
} else {
val version = Build.DISPLAY
tempRom = if (version.uppercase().contains(ROM_FLYME)) {
ROM_FLYME
} else {
Build.MANUFACTURER.uppercase()
}
}
return rom == tempRom
}
fun isXiaomi() = check(ROM_MIUI)
fun isHuawei() = check(ROM_EMUI)
fun isVivo() = check(ROM_VIVO)
fun isOppo() = check(ROM_OPPO)
fun isFlyme() = check(ROM_FLYME)
fun is360() = check(ROM_QIKU) || check("360")
fun isSmartisan() = check(ROM_SMARTISAN)
// 打开自启动设置页
fun openStart(context: Context) {
if (Build.VERSION.SDK_INT < 23) return
var intent = Intent()
var componentName: ComponentName? = null
when {
isXiaomi() -> {
componentName = ComponentName(
"com.miui.securitycenter",
"com.miui.permcenter.autostart.AutoStartManagementActivity"
)
}
isHuawei() -> {
componentName = ComponentName(
"com.huawei.systemmanager",
"com.huawei.systemmanager.startupmgr.ui.StartupNormalAppListActivity"
)
}
isOppo() -> {
componentName = if (Build.VERSION.SDK_INT >= 26) {
ComponentName(
"com.coloros.safecenter",
"com.coloros.safecenter.startupapp.StartupAppListActivity"
)
} else {
ComponentName(
"com.color.safecenter",
"com.color.safecenter.permission.startup.StartupAppListActivity"
)
}
}
isVivo() -> {
componentName = if (Build.VERSION.SDK_INT >= 26) {
ComponentName(
"com.vivo.permissionmanager",
"com.vivo.permissionmanager.activity.PurviewTabActivity"
)
} else {
ComponentName(
"com.iqoo.secure",
"com.iqoo.secure.ui.phoneoptimize.SoftwareManagerActivity"
)
}
}
isFlyme() -> {
componentName = ComponentName.unflattenFromString(
"com.meizu.safe/.permission.PermissionMainActivity"
)
}
else -> {
if (Build.VERSION.SDK_INT >= 9) {
intent.action = "android.settings.APPLICATION_DETAILS_SETTINGS";
intent.data = Uri.fromParts("package", context.packageName, null);
} else if (Build.VERSION.SDK_INT <= 8) {
intent.action = Intent.ACTION_VIEW
intent.setClassName(
"com.android.settings",
"com.android.settings.InstalledAppDetails"
);
intent.putExtra(
"com.android.settings.ApplicationPkgName",
context.packageName
)
}
intent = Intent(Settings.ACTION_SETTINGS)
}
}
componentName?.let { intent.setComponent(it) }
try {
context.startActivity(intent)
} catch (e: Exception) {
// 抛出异常的话直接打开设置页
context.startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
}
④ 引导用户在多任务列表窗口加锁
如题,引导用户对 多任务列表的APP窗口加锁,这样点击将清理加速时不会导致应用被杀,如:
另外,还有一个骚操作:在多任务列表把App窗口给隐藏了,避免用户手多划掉,工具代码如下:
fun Context.hideAppWindow(isHide: Boolean) {
try {
(getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager)
.appTasks[0].setExcludeFromRecents(isHide)
} catch (e: Exception) {
//...
}
}
⑤ 引导用户打开APP后台高耗电开关
部分厂商的手机有这个(如Vivo),设置方式:设置 → 电池 → 后台高耗电 → 找到自己的APP开启
0x3、无障碍服务防御
在一开始学习AccessibilityService的时候就提到过,这个服务设计的初衷是:为了帮助残障人士可以更好的使用App。
image.png而在国内一些开发者利用它 能监控与操作其它APP的特性 + 系统远超人类的反应速度,在某些竞争类场景开发出了 作弊外挂,如抢单、秒杀等,对原本公平的竞争环境产生不公。
作为一名普通的Android开发者,还是要居安思危,指不定哪天自己开发的APP也会惨招毒手,提前了解一些AccessibilityService的防御措施,不至于真发生时不知所措~
① 检测用户是否安装外挂软件
建立外挂软件黑名单,PackageManager遍历手机已安装的APP,判断是有有黑名单里的包名和应用名,有给个提示,然后退出APP。
但,这需要权限,而且涉及到了隐私,所以,可以尝试换个思路 → 检测监控包名的AccessibilityService
可以通过 AccessibilityManagerService
获取所有已安装及已启动的AccessibilityService应用,而它是com.android.server.accessibility包下的类,无法直接使用。但可以通过 AccessibilityManager
来间接操作(Binder)。提供了两个获取 List<AccessibilityServiceInfo>
的方法:
- getInstalledAccessibilityServiceList() → 获得所有已安装的AccessibilityService;
- getEnabledAccessibilityServiceList() → 获得所有已启动的AccessibilityService;
以第一个获取方法为例,写出遍历的工具代码:
// 取得正在监控目标包名的AccessibilityService
fun getInstalledAccessibilityServiceList(targetPackage: String): List<AccessibilityServiceInfo> {
val serviceList = arrayListOf<AccessibilityServiceInfo>()
val manager =
applicationContext.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
?: return serviceList
val infoList = manager.installedAccessibilityServiceList
if (infoList.isNullOrEmpty()) return serviceList
infoList.forEach {
if (it.packageNames == null) serviceList.add(it) else {
it.packageNames.forEach { pkgName ->
if (targetPackage == pkgName) serviceList.add(
it
)
}
}
}
return serviceList
}
简单调用下 (检测监听微信的无障碍服务有哪些):
getInstalledAccessibilityServiceList("com.tencent.mm").forEach { info ->
logD(
" \n【监听的包名 (null代表所有)】${info.packageNames?.toList()}\n【监听的服务】${info.id}\n【设置页】${info.settingsActivityName}\n【服务描述信息】${
info.description.replace("\n", "").replace(" ", "")
}"
)
}
运行后控制台输出信息如下 (注:info.packageNames为null表示监控所有包名):
可以看到手机安装的所有监听微信的无障碍服务App信息都被打印出来了,接着就是检查这里面有没有外挂黑名单里的包名了。
至于检测时机,可以定时或者在特定时间节点进行,尽量别只在App启动时,毕竟用户可以先启动App然后再打开外挂。另外,检测时也可以顺带把觉得可以的App信息也上报到后台,用于完善黑名单。
当然,这种 检测到就不给用的策略 有些过于粗暴,有时还可能造成误伤,毕竟应用包名只要不上架市场,随便起啊,你封一次我改一次,所有还得从App自身触发去防御~
② 重写TextView的findViewsWithText()屏蔽文案检查
我们知道AccessibilityServices中定位节点的两种常规方式,一个是id,一个是根据text文本,后者 findAccessibilityNodeInfosByText()
最终调用的实际是View的 findViewsWithText()
。只需对这个方法进行重写即可屏蔽文案检查,代码示例如下:
class DefensiveTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
AppCompatTextView(context, attrs) {
override fun findViewsWithText(
outViews: ArrayList<View>?,
searched: CharSequence?,
flags: Int
) {
outViews?.remove(this)
}
}
③ 屏蔽点击事件
上面是屏蔽找的,接着是屏蔽点击时间的,因为AccessibilityServices执行点击最终会调用View的OnClickListener回调onClick()。所以,一种最直接的方法就是自定义View,然后 用onTouch()替换onClick()。
除此之外还有另外一种方法 → 重写performAccessibilityAction()返回true,以此忽略掉AccessibilityService传递过来的事件。实现方式的话,除了自定义View重写外,还可以调用 setAccessibilityDelegate()
对控件进行设置,直接给出设置的扩展代码,用时直接调就好:
// 控件是否屏蔽无障碍相关
fun View.disableAccessibility(disable: Boolean = true) {
if (!disable) {
this.accessibilityDelegate = null
} else {
this.accessibilityDelegate = object : View.AccessibilityDelegate() {
override fun performAccessibilityAction(
host: View?,
action: Int,
args: Bundle?
): Boolean {
// performAction方法触发的行为,拦截View响应无障碍服务模拟事件的API
return true
}
override fun sendAccessibilityEvent(host: View?, eventType: Int) {
// 篡改或屏蔽View发送的无障碍事件
}
override fun onInitializeAccessibilityEvent(
host: View?,
event: AccessibilityEvent?
) {
// 阻止View生成AccessibilityNodeInfo, 从而防止无障碍抓取到内容
}
override fun onInitializeAccessibilityNodeInfo(
host: View?,
info: AccessibilityNodeInfo?
) {
// 阻止View发送出去的AccessibilityEvent
}
override fun dispatchPopulateAccessibilityEvent(
host: View?,
event: AccessibilityEvent?
): Boolean {
// 阻止 AccessibilityEvent 向子 View 传递
return false
}
override fun onRequestSendAccessibilityEvent(
host: ViewGroup?,
child: View?,
event: AccessibilityEvent?
): Boolean {
// 阻止子View请求发送无障碍事件消息
return false
}
}
}
}
// 调用处:
button.disableAccessibility()
主要是重写setAccessibilityDelegate(),其它方案可按需增删~
对了,泼个冷水哈,上述两种屏蔽方式,都可以通过上一节教的 手势模拟点击 来破解~
④ 主动发送Event干扰
我们都知道AccessibilityServices的玩法其实就是:监听目标APP发出的AccessibilityEvent来执行相应操作。
而在APP里,其实可以调用View的 sendAccessibilityEvent()
来主动发送Event,所以一种防御的思路就是闲来无事发几个Event,尝试干扰外挂程序的正常逻辑。代码示例如:
textview.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED)
button.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOWS_CHANGED)
不过,这个操作其实有些鸡肋,毕竟收到Event后都是检测页面是否有特定因素,然后再执行下一步的。
道高一尺,魔高一丈,上面提到的防御技巧都是有办法绕过的,比如你屏蔽了文案检查,那我就OCR文字识别,甚至根据图片匹配。个人感觉还得是 风控,采集用户操作记录,检测到反人类的异常行为时告警,如每次点击都是点一个坐标,如页面操作时间超短等等。
0x4、小结
本节对 AccessibilityService保活和防御 相关进行了学习,相信大家学完也会有所裨益。关于AccessibilityService的知识点,就差一篇 源码解读 了,但不影响我们学习开发自动化脚本,所以将会在本专栏末尾进行讲解。而下节会讲一下使用 AccessibilityService 的最佳拍档 —— Android悬浮框,它两的关系可谓是:吃面不吃蒜,香味少一半。敬请期待~
参考文献:
作者:coder_pig
链接:https://juejin.cn/post/7171753659477262349