diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74bcd72b78..60769c4c72 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -159,6 +159,7 @@ dependencies { implementation(libs.kotlin.coroutines.jdk) implementation(libs.androidx.documentfile) implementation(libs.cxx) + implementation(libs.okhttp) } val adbExecutable: String = androidComponents.sdkComponents.adb.get().asFile.absolutePath diff --git a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt index b8ec6f45db..308524e9ec 100644 --- a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt +++ b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt @@ -129,6 +129,7 @@ class XposedInit : IXposedHookLoadPackage, IXposedHookZygoteInit { startHook { LiveQualityHook(lpparam.classLoader) } startHook { StoryPlayerAdHook(lpparam.classLoader) } startHook { LongPressSpeed(lpparam.classLoader) } + startHook { SkipVideoAd(lpparam.classLoader) } } lpparam.processName.endsWith(":web") -> { diff --git a/app/src/main/java/me/iacn/biliroaming/hook/skipVideoAd.kt b/app/src/main/java/me/iacn/biliroaming/hook/skipVideoAd.kt new file mode 100644 index 0000000000..acd7f17d54 --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/skipVideoAd.kt @@ -0,0 +1,132 @@ +package me.iacn.biliroaming.hook + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import me.iacn.biliroaming.BiliBiliPackage.Companion.instance +import me.iacn.biliroaming.utils.BilibiliSponsorBlock +import me.iacn.biliroaming.utils.Log +import me.iacn.biliroaming.utils.av2bv +import me.iacn.biliroaming.utils.callMethod +import me.iacn.biliroaming.utils.callMethodAs +import me.iacn.biliroaming.utils.hookAfterMethod +import me.iacn.biliroaming.utils.hookBeforeMethod +import me.iacn.biliroaming.utils.mossResponseHandlerReplaceProxy +import me.iacn.biliroaming.utils.sPrefs +import java.lang.ref.WeakReference + +class SkipVideoAd(classLoader: ClassLoader) : BaseHook(classLoader) { + + private var lastSeekTime = 0L + private var playerRef: WeakReference? = null + private val player get() = playerRef?.get() + private var duration: Int = -1 + private var segments : List? = null + private var bvid: String = "" + private var cid: String = "" + private var waitTime = 1000 + + override fun startHook() { + if (!sPrefs.getBoolean("skip_video_ad", false)) return + + Log.d("startHook: SkipVideoAd") + + instance.playerMossClass?.apply { + hookBeforeMethod("executePlayViewUnite", + instance.playViewUniteReqClass + ) { param -> + val req = param.args[0] + bvid = req.callMethodAs("getBvid") + val vod = req.callMethod("getVod")?:return@hookBeforeMethod + if (bvid.isEmpty()){ + val aid = vod.callMethodAs("getAid") + if (aid==-1L){ + return@hookBeforeMethod + } + bvid = av2bv(aid) + } + cid = vod.callMethodAs("getCid").toString() + } + + hookBeforeMethod("playViewUnite", + instance.playViewUniteReqClass, + instance.mossResponseHandlerClass + ){ param -> + param.args[1] = param.args[1].mossResponseHandlerReplaceProxy { reply -> + reply ?: return@mossResponseHandlerReplaceProxy null + val playArc = reply.callMethod("getPlayArc")?:return@mossResponseHandlerReplaceProxy null + cid = playArc.callMethodAs("getCid").toString() + val aid = playArc.callMethodAs("getAid")?:-1L + if (aid==-1L){ + return@mossResponseHandlerReplaceProxy null + } + bvid = av2bv(aid) + null + } + } + } + + instance.playerCoreServiceV2Class?.apply { + hookAfterMethod("G1", Int::class.java) { param -> + playerRef = WeakReference(param.thisObject) + val state = param.args[0] as Int + if (state in 3..5 && duration<=0) { + duration = (player?.callMethodAs("getDuration") ?: -1) + } + if(state == 2) { + duration = -1 + segments = null + CoroutineScope(Dispatchers.IO).launch{ + var retryCount = 0 + val maxRetries = 3 + while (retryCount < maxRetries) { + segments = BilibiliSponsorBlock(bvid, cid).getSegments() + if (segments.isNullOrEmpty()) { + retryCount++ + delay(1000) + } else { + break + } + } + if (segments == null){ + Log.toast("广告片段数据获取失败") + return@launch + } + + } + } + } + + hookAfterMethod("getCurrentPosition") { param -> + val now = System.currentTimeMillis() + if (now - lastSeekTime > waitTime) { + lastSeekTime = now + waitTime = if(seekTo(param.result as Int)) 3000 else 1000 + } + } + } + } + + private fun seekTo(position: Int?): Boolean { + if (position != null) { + if (position > duration) return false + } + + if (segments != null) { + for (segment in segments) { + val start = (segment.segment[0]*1000).toInt() + val end = (segment.segment[1]*1000).toInt() + if (position in start..? = withContext(Dispatchers.IO) { + try { + val prefix = bvid.trim().sha256().take(4) + val url = "$BASE_URL$prefix?category=sponsor" + + val request = Request.Builder() + .url(url) + .header("origin", "BiliRoaming") + .header("x-ext-version", "1.7.0") + .build() + + return@withContext httpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + Log.e("HTTP request failed with code: ${resp.code}") + return@use null + } + + val body = resp.body.string() + if (body.isEmpty()) { + Log.e("HTTP request failed with empty body") + return@use null + } + + parseSegments(body) + } + + } catch (e: Exception) { + Log.e(e) + null + } + } + + private fun parseSegments(json: String): List? { + try { + val jsonArray = JSONArray(json) + val segments = mutableListOf() + if (jsonArray.length() == 0) return null + + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + if (obj.optString("videoID") != bvid) continue + + val segmentsArray = obj.getJSONArray("segments") + for (j in 0 until segmentsArray.length()) { + val item = segmentsArray.getJSONObject(j) + val segmentArray = item.getJSONArray("segment") + segments.add( + Segment( + segment = floatArrayOf( + segmentArray.getDouble(0).toFloat(), + segmentArray.getDouble(1).toFloat() + ), + cid = item.optString("cid"), + UUID = item.optString("UUID"), + category = item.optString("category"), + actionType = item.optString("actionType"), + videoDuration = item.optInt("videoDuration") + ) + ) + } + break + } + return segments + .filter { it.cid == this.cid } + .sortedBy { it.segment[0] } + } catch (_: JSONException) { + return null + } + } + + private fun String.sha256(): String = sha256Digest + .digest(toByteArray()) + .joinToString("") { "%02x".format(it) } + + data class Segment( + val segment: FloatArray, + val cid: String, + val UUID: String, + val category: String, + val actionType: String, + val videoDuration: Int + ) +} diff --git a/app/src/main/java/me/iacn/biliroaming/utils/Utils.kt b/app/src/main/java/me/iacn/biliroaming/utils/Utils.kt index 00e8921a62..e1ea988603 100644 --- a/app/src/main/java/me/iacn/biliroaming/utils/Utils.kt +++ b/app/src/main/java/me/iacn/biliroaming/utils/Utils.kt @@ -65,6 +65,18 @@ fun bv2av(bv: String): Long { return r.and(2251799813685247L).xor(23442827791579L) } +fun av2bv(aid: Long): String { + val s = CharArray(12) { if (it < 3) "BV1"[it] else '0' } + var t = ((1L shl 51) or aid) xor 23442827791579L + var i = 11 + while (t > 0) { + s[i--] = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf"[(t % 58).toInt()] + t /= 58 + } + s[3] = s[9].also { s[9] = s[3] } + s[4] = s[7].also { s[7] = s[4] } + return String(s) +} fun getPackageVersion(packageName: String) = try { @Suppress("DEPRECATION") systemContext.packageManager.getPackageInfo(packageName, 0).run { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed298e9f1c..8168f86dbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -315,4 +315,6 @@ 净化竖屏模式广告/推广 有了这个,连第一关都看不到了😭 长按播放速度设置 + 跳过视频广告 + 一个简单的跳过视频广告功能,参考BilibiliSponsorBlock实现 diff --git a/app/src/main/res/xml/prefs_setting.xml b/app/src/main/res/xml/prefs_setting.xml index 9371aa1a8a..2224a8f48e 100644 --- a/app/src/main/res/xml/prefs_setting.xml +++ b/app/src/main/res/xml/prefs_setting.xml @@ -544,6 +544,12 @@ android:key="block_video_comment" android:title="@string/block_video_comment_title" /> + +