浅谈"李跳跳"停更 & 简单七步跳过Android开屏广告

17,249 阅读6分钟

0x1、瞎聊

好久没更文,诈尸水一篇,上周四看到很多群在转发 《大小姐李跳跳:无限期停止更新公告》简述下内容:

一个 没联网也没盈利应用开屏广告跳过APP 的开发者,收到了 "南山必胜客" 发的 律师函,说他的APP可用于屏蔽、过滤XX浏览器的广告服务,构成 不正当竞争,并最终导致 "用户福祉的减损"。

2333,原来 看广告用户福祉 ...

除李跳跳外,其它几款比较有名的同类型APP也不约而同也收到律师函,如:叮小跳、大圣净化、轻启动等。

有关注了李跳跳的公号的应该都知道,它被搞不是一天两天了,之前就经历过 酷安下架国产手机系统安装报毒

从酷安小编的一席话不难看出被搞的本质原因:

  • 1、断人财路,毕竟 开屏广告 已经成为 许多App的主要变现手段,据央视财经频道报道,某些手机App通过开屏弹窗广告获得的收益,占其总广告收入高达80%。
  • 2、影响力大,看下这篇文章《谈谈我的看法》,阅读量10w+,8.2w+点赞,用户量不得有个几十万?

所以,被搞是 情理之中,即便南山必胜客不站出来,也会有其它利益受损方站出来,只是迟早的问题~

还记得不久前的 多多提权坚强用户 事件吗?Google Play 以 "恶意软件" 为由将其下架,并向已下载该APP的用户发出警告,提醒卸载。反观国内,屁事没有,很多人甚至不知道这件事。

所以,这种大环境下的 为众人报薪者必冻毙于风雪。综上,李跳跳停更是 无奈之举,感谢开发者一直 用爱发电 默默更新🌼。

虽然停更,但是还是能搜索下载到APP的,鱼龙混杂,各位读者下载安装时 注意甄别!!!比如这种恶心盗版:

当然,也可以尝试其它平替,如果觉得代码闭源不放心,可在Github搜下关键字 "广告跳过"

也可以在了解完跳过原理后,自己动手写一个,不过还请切记 "闷声发大财"~🐶

0x2、Android广告跳过原理

Android中的广告跳过原理有两派:

  • 利用手机系统提供的 威屁恩 接口实现 本地代理,接管应用的网络请求,配合对应的拦截规则(DNS、主机域名) 来实现广告拦截。
  • 利用Android AccessibilityService无障碍服务 来识别广告跳过按钮,然后模拟点击,一般针对App开屏广告,国内绝大部分广告跳过APP都是这种。

0x3、简单七步实现开屏广告跳过

第一种方案自己实现的成本比较高,直接介绍下有哪些软件支持,按需安装即可:

  • AdGuard:支持APP中的广告拦截、自定义规则和过滤器,完整功能要钱,3台设备一年40+。
  • Adblock:支持浏览器浏览网页时的广告屏蔽。不过很多浏览器都内置了广告屏蔽功能,而且能直接订阅第三方规则,如Via、X浏览器等。甚至笔者用的IDM+自带浏览器都有这个功能:

如果确实有兴趣想自己折腾,可以参考下这两个开源库:

第二种方案就非常简单了,完全可以自己写一个耍耍,基础知识就不展开讲了,不了解的读者可以移步至《杰哥带你玩转Android自动化-AccessibilityService基础》大概了解下前置知识。

实现跳过开屏广告的核心点就三步:关注Event → 查找结点 → 点击结点,创建一个新的Android项目后,直接开整~

1、自定义AccessibilityService

class ADGunService : AccessibilityService() {
    companion object {
        var instance: ADGunService? = null  // 单例
        val isServiceEnable: Boolean get() = instance != null   // 判断无障碍服务是否可用
    }

    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        event?.let {
            // 在这里写跳过广告的逻辑
            log.d(TAG, "$it")
        }
    }

    override fun onInterrupt() {}

    override fun onServiceConnected() {
        super.onServiceConnected()
        instance = this
    }

    override fun onDestroy() {
        super.onDestroy()
        instance = null
    }
}

2、在res/xml目录下新建服务配置文件

ad_gun_service_config.xml

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
  android:description="@string/accessibility_description"
  android:accessibilityEventTypes="typeAllMask"
  android:canPerformGestures="true"
  android:accessibilityFeedbackType="feedbackSpoken"
  android:canRetrieveWindowContent="true"
  android:accessibilityFlags="flagRetrieveInteractiveWindows"
  android:notificationTimeout="1000"/>

3、AndroidManifest.xml中对服务进行声明

<service
    android:name=".ADGunService"
    android:exported="false"
    android:label="AD 滚犊子!!!"
    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/ad_gun_service_config" />
</service>

4、写个简单的页面xml

显示服务开启状态的文本和一个去开启的按钮(activity_main.xml)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/tv_service_status"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="跳过广告服务状态:" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/bt_open_service"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="去开启" />

</LinearLayout>

5、控件绑定并设置UI和点击事件

加个设置无障碍服务的状态UI的方法,一个点击跳转无障碍服务设置页(MainActivity.kt)

class MainActivity : AppCompatActivity() {
    private lateinit var mServiceStatusTv: TextView
    private lateinit var mToOpenBt: Button

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mServiceStatusTv = findViewById(R.id.tv_service_status)
        mToOpenBt = findViewById<Button>(R.id.bt_open_service).apply {
            setOnClickListener { jumpAccessibilityServiceSettings() }
        }
    }

    override fun onResume() {
        super.onResume()
        refreshServiceStatusUI()
    }

    /**
     * 刷新无障碍服务状态的UI
     * */
    private fun refreshServiceStatusUI() {
        if (ADGunService.isServiceEnable) {
            mServiceStatusTv.text = "跳过广告服务状态:已开启"
            mToOpenBt.visibility = View.GONE
        } else {
            mServiceStatusTv.text = "跳过广告服务状态:未开启"
            mToOpenBt.visibility = View.VISIBLE
        }
    }
}

运行后,点击去开启按钮,如下图依次开启无障碍服务

此时打开其它APP,可以看到Logcat输出的Event信息:

6、查找跳过广告结点

得益于市场监管总局修订发布的《互联网广告管理办法》

查找跳过广告结点变得容易多了,想当年,假按钮,极小点击区域等骗点击的伎俩层出不穷。这里只需要遍历页面结点,查找包含"跳过"的结点即可~

/**
 * 获得当前视图根节点
 * */
private fun getCurrentRootNode() = try {
    rootInActiveWindow
} catch (e: Exception) {
    e.message?.let { Log.e(TAG, it) }
    null
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    event?.let {
        // 如果查找包含跳过按钮的结点列表不为空,取第一个,然后输出
        getCurrentRootNode()?.findAccessibilityNodeInfosByText("跳过").takeUnless { it.isNullOrEmpty() }?.get(0)?.let {
            logD("检测到跳过广告结点:$it")
        }
    }
}

运行后随便打开一个有开屏广告的APP,可以看到控制台输出的结点信息:

7、点击跳过广告结点

《互联网广告管理办法》这份文件在,大部分APP应该不会知法犯法,结点一般是支持直接点击的:

所以直接performAction()触发结点点击:

运行看看跳过效果:

牛批,有些广告还没看清直接就跳过了,我们通过简单七步就快速实现了一个广告跳过APP。

当然,要投入真正使用还得完善一些细节,比如 前台服务保活引入线程池/协程解析结点避免堵塞主线程监听特定Event提高结点查找效率 本文只是抛砖引玉,对Android自动化感兴趣的童鞋,可以移步至《杰哥带你玩转Android自动化》自行学习~

*8、补充:自定义规则过滤

除了开屏广告外,还有一种很烦人的弹窗:

跳过广告类APP里的自定义规则过滤就是针对这种,这种规则只能靠人力来堆,众人拾柴火焰高,找到一个比较全的:LiTiaotiao-Custom-Rules,直接复制全部规则的json保存到res/raw文件夹下,截取其中一段:

{
  "-1606001344": "{"popup_rules":[{"id":"playing_tv_redeem_title","action":"playing_ic_close"}]}"
},

不难发现结构规则,id → 匹配结点的id或文本action → 点击结点的id或文本,key值是随机变化的字符串,直接使用Java自带抠脚Json来解析,先定义两个实体类存数据:

data class RuleEntity(
    val popupRules: ArrayList<RuleDetail>
)

data class RuleDetail(
    val id: String,
    val action: String
)

接着整个线程池用来读取解析Json文件,以及解析结点,耗时操作避免堵塞主线程~

var executor: ExecutorService = Executors.newFixedThreadPool(4) // 执行任务的线程池

接着编写解析json文件的方法,返回规则列表:

/**
 * 读取json文件生成规则实体列表
 * */
private fun readJsonToRuleList(): List<RuleEntity>? {
    try {
        val inputStream = resources.openRawResource(R.raw.custom_rules)
        val reader = BufferedReader(InputStreamReader(inputStream))
        val sb = StringBuilder()
        reader.use {
            var line: String? = it.readLine()
            while (line != null) {
                sb.append(line)
                line = it.readLine()
            }
        }
        val ruleEntityList = arrayListOf<RuleEntity>()
        val jsonArray = JSONArray(sb.toString())
        for (i in 0 until jsonArray.length()) {
            val jsonObject = jsonArray.getJSONObject(i)
            val keys = jsonObject.keys()
            while (keys.hasNext()) {
                val key = keys.next()
                val value = jsonObject.getString(key)
                val ruleEntityJson = JSONObject(value)
                val popupRules = ruleEntityJson.getJSONArray("popup_rules")
                val ruleEntity = RuleEntity(arrayListOf())
                for (j in 0 until popupRules.length()) {
                    val ruleObject = popupRules.getJSONObject(j)
                    val ruleDetail = RuleDetail(ruleObject.getString("id"), ruleObject.getString("action"))
                    ruleEntity.popupRules.add(ruleDetail)
                }
                ruleEntityList.add(ruleEntity)
            }
        }
        return ruleEntityList
    } catch (e: IOException) {
        e.printStackTrace()
    } catch (e: JSONException) {
        e.printStackTrace()
    }
    return null
}

接着在onServiceConnected()时调用此方法,即无障碍服务启动时读取初始化:

override fun onServiceConnected() {
    super.onServiceConnected()
    executor.execute {
        ruleList = readJsonToRuleList()
        ruleList?.forEach { logD("$it") }
        logD("自定义规则列表已加载...")
    }
    instance = this
}

运行后可以看到规则列表已加载:

接着编写匹配文字和id结点的方法:

/**
 * 递归遍历查找匹配文本或id结点
 * 结点id的构造规则:包名:id/具体id
 * */
private fun searchNode(filter: String): AccessibilityNodeInfo? {
    val rootNode = getCurrentRootNode()
    if (rootNode != null) {
        rootNode.findAccessibilityNodeInfosByText(filter).takeUnless { it.isNullOrEmpty() }?.let { return it[0] }
        if (!rootNode.packageName.isNullOrBlank()) {
            rootNode.findAccessibilityNodeInfosByViewId("${rootNode.packageName}:id/$filter")
                .takeUnless { it.isNullOrEmpty() }?.let { return it[0] }
        }
    }
    return null
}

接着在onAccessibilityEvent()遍历自定义规则列表,批量调用

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
    event?.let {
        executor.execute {
            searchNode("跳过")?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
            ruleList?.forEach {
                it.popupRules.forEach { rule ->
                    // 如果定位到匹配结点,查找要点击的结点并点击
                    if (searchNode(rule.id) != null) searchNode(rule.action)?.performAction(AccessibilityNodeInfo.ACTION_CLICK)
                }
            }
        }
    }
}

接着可以找 LiTiaotiao-Custom-Rules 提到APP去验证,反正笔者试了下网易云的更新弹窗能够自动点击关闭~

参考文献