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

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

kotlin 复制代码
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 复制代码
<?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中对服务进行声明

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 复制代码
<?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)

kotlin 复制代码
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、查找跳过广告结点

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

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

kotlin 复制代码
/**
 * 获得当前视图根节点
 * */
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文件夹下,截取其中一段:

json 复制代码
{
  "-1606001344": "{"popup_rules":[{"id":"playing_tv_redeem_title","action":"playing_ic_close"}]}"
},

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

kotlin 复制代码
data class RuleEntity(
    val popupRules: ArrayList<RuleDetail>
)

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

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

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

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

kotlin 复制代码
/**
 * 读取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()时调用此方法,即无障碍服务启动时读取初始化:

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

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

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

kotlin 复制代码
/**
 * 递归遍历查找匹配文本或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()遍历自定义规则列表,批量调用

kotlin 复制代码
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去验证,反正笔者试了下网易云的更新弹窗能够自动点击关闭~

参考文献

相关推荐
腾讯TNTWeb前端团队6 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰9 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy10 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom11 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom11 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom11 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom11 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom11 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试