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+自带阅读器都有这个功用:

浅谈

假如确实有爱好想自己折腾,可以参阅下这两个开源库:

  • AdAway
  • NetBare-Android

第二种计划就十分简单了,完全可以自己写一个耍耍,根底知识就不展开讲了,不了解的读者可以移步至《杰哥带你玩转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去验证,反正笔者试了下网易云的更新弹窗可以自动点击关闭~

参阅文献

  • 《“李跳跳”为何成了大厂公敌?》
  • 《“腾讯是一个伟大的企业”,知名李跳跳软件完结了》
  • 《被控涉嫌“不正当竞争”,开屏广告越过 App“李跳跳”将无限期停更》