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去验证,反正笔者试了下网易云的更新弹窗能够自动点击关闭~
参考文献: