美文网首页
【杰哥带你玩转Android自动化】AccessibilityS

【杰哥带你玩转Android自动化】AccessibilityS

作者: _Jun | 来源:发表于2022-12-09 21:10 被阅读0次

    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

    相关文章

      网友评论

          本文标题:【杰哥带你玩转Android自动化】AccessibilityS

          本文链接:https://www.haomeiwen.com/subject/blftqdtx.html