From 00ea006b8c2e395ba56e8e5b90c467542849be46 Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Wed, 30 Nov 2022 15:07:54 +0100 Subject: [PATCH] feat(twitch): `block-embedded-ads` patch support (#227) --- app/build.gradle.kts | 2 + .../twitch/adblock/IAdblockService.kt | 18 ++++ .../twitch/adblock/PurpleAdblockService.kt | 68 +++++++++++++++ .../revanced/twitch/adblock/TTVLolService.kt | 52 +++++++++++ .../revanced/twitch/api/PurpleAdblockApi.java | 12 +++ .../revanced/twitch/api/RequestInterceptor.kt | 86 +++++++++++++++++++ .../revanced/twitch/api/RetrofitClient.java | 25 ++++++ .../twitch/patches/EmbeddedAdsPatch.java | 9 ++ .../twitch/settings/SettingsEnum.java | 1 + .../app/revanced/twitch/utils/LogHelper.java | 2 +- .../revanced/twitch/utils/ReVancedUtils.java | 16 ++++ 11 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/revanced/twitch/adblock/IAdblockService.kt create mode 100644 app/src/main/java/app/revanced/twitch/adblock/PurpleAdblockService.kt create mode 100644 app/src/main/java/app/revanced/twitch/adblock/TTVLolService.kt create mode 100644 app/src/main/java/app/revanced/twitch/api/PurpleAdblockApi.java create mode 100644 app/src/main/java/app/revanced/twitch/api/RequestInterceptor.kt create mode 100644 app/src/main/java/app/revanced/twitch/api/RetrofitClient.java create mode 100644 app/src/main/java/app/revanced/twitch/patches/EmbeddedAdsPatch.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8cc3527..12d7dcdc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,4 +46,6 @@ dependencies { compileOnly(project(mapOf("path" to ":dummy"))) compileOnly("androidx.annotation:annotation:1.5.0") compileOnly("androidx.appcompat:appcompat:1.5.1") + compileOnly("com.squareup.okhttp3:okhttp:4.10.0") + compileOnly("com.squareup.retrofit2:retrofit:2.9.0") } diff --git a/app/src/main/java/app/revanced/twitch/adblock/IAdblockService.kt b/app/src/main/java/app/revanced/twitch/adblock/IAdblockService.kt new file mode 100644 index 00000000..73c199a5 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/adblock/IAdblockService.kt @@ -0,0 +1,18 @@ +package app.revanced.twitch.adblock + +import okhttp3.Request + +interface IAdblockService { + fun friendlyName(): String + fun maxAttempts(): Int + fun isAvailable(): Boolean + fun rewriteHlsRequest(originalRequest: Request): Request? + + companion object { + fun Request.isVod() = url.pathSegments.contains("vod") + fun Request.channelName() = + url.pathSegments + .firstOrNull { it.endsWith(".m3u8") } + .run { this?.replace(".m3u8", "") } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/adblock/PurpleAdblockService.kt b/app/src/main/java/app/revanced/twitch/adblock/PurpleAdblockService.kt new file mode 100644 index 00000000..3c5e5abf --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/adblock/PurpleAdblockService.kt @@ -0,0 +1,68 @@ +package app.revanced.twitch.adblock + +import app.revanced.twitch.adblock.IAdblockService.Companion.channelName +import app.revanced.twitch.api.RetrofitClient +import app.revanced.twitch.utils.LogHelper +import app.revanced.twitch.utils.ReVancedUtils +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.ResponseBody + +class PurpleAdblockService : IAdblockService { + private val tunnels = mutableMapOf( + /* tunnel url */ /* alive */ + "https://eu1.jupter.ga" to false, + "https://eu2.jupter.ga" to false + ) + + override fun friendlyName(): String = ReVancedUtils.getString("revanced_proxy_purpleadblock") + + override fun maxAttempts(): Int = 3 + + override fun isAvailable(): Boolean { + for(tunnel in tunnels.keys) { + var success = true + try { + val response = RetrofitClient.getInstance().purpleAdblockApi.ping(tunnel).execute() + if (!response.isSuccessful) { + LogHelper.error("PurpleAdBlock tunnel $tunnel returned an error: HTTP code %d", response.code()) + LogHelper.debug(response.message()) + LogHelper.debug((response.errorBody() as ResponseBody).string()) + success = false + } + } catch (ex: Exception) { + LogHelper.printException("PurpleAdBlock tunnel $tunnel is unavailable", ex) + success = false + } + + // Cache availability data + tunnels[tunnel] = success + + if(success) + return true + } + + return false + } + + override fun rewriteHlsRequest(originalRequest: Request): Request? { + val server = tunnels.filter { it.value }.map { it.key }.firstOrNull() + server ?: run { + LogHelper.error("No tunnels are available") + return null + } + + // Compose new URL + val url = "$server/channel/${originalRequest.channelName()}".toHttpUrlOrNull() + if (url == null) { + LogHelper.error("Failed to parse rewritten URL") + return null + } + + // Overwrite old request + return Request.Builder() + .get() + .url(url) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/adblock/TTVLolService.kt b/app/src/main/java/app/revanced/twitch/adblock/TTVLolService.kt new file mode 100644 index 00000000..b3cf2a73 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/adblock/TTVLolService.kt @@ -0,0 +1,52 @@ +package app.revanced.twitch.adblock + +import app.revanced.twitch.adblock.IAdblockService.Companion.channelName +import app.revanced.twitch.adblock.IAdblockService.Companion.isVod +import app.revanced.twitch.utils.LogHelper +import app.revanced.twitch.utils.ReVancedUtils +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import java.util.Random + +class TTVLolService : IAdblockService { + + override fun friendlyName(): String = ReVancedUtils.getString("revanced_proxy_ttv_lol") + + // TTV.lol is sometimes unstable + override fun maxAttempts(): Int = 4 + + override fun isAvailable(): Boolean = true + + override fun rewriteHlsRequest(originalRequest: Request): Request? { + // Compose new URL + val url = "https://api.ttv.lol/${if (originalRequest.isVod()) "vod" else "playlist"}/${originalRequest.channelName()}.m3u8${nextQuery()}".toHttpUrlOrNull() + if (url == null) { + LogHelper.error("Failed to parse rewritten URL") + return null + } + + // Overwrite old request + return Request.Builder() + .get() + .url(url) + .addHeader("X-Donate-To", "https://ttv.lol/donate") + .build() + } + + private fun nextQuery(): String { + return SAMPLE_QUERY.replace("", generateSessionId()) + } + + private fun generateSessionId() = + (1..32) + .map { "abcdef0123456789"[randomSource.nextInt(16)] } + .joinToString("") + + private val randomSource = Random() + + companion object { + + private const val SAMPLE_QUERY = + "%3Fallow_source%3Dtrue%26fast_bread%3Dtrue%26allow_audio_only%3Dtrue%26p%3D0%26play_session_id%3D%26player_backend%3Dmediaplayer%26warp%3Dfalse%26force_preroll%3Dfalse%26mobile_cellular%3Dfalse" + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/api/PurpleAdblockApi.java b/app/src/main/java/app/revanced/twitch/api/PurpleAdblockApi.java new file mode 100644 index 00000000..31a70729 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/api/PurpleAdblockApi.java @@ -0,0 +1,12 @@ +package app.revanced.twitch.api; + +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Url; + +/* only used for service pings */ +public interface PurpleAdblockApi { + @GET /* root */ + Call ping(@Url String baseUrl); +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/api/RequestInterceptor.kt b/app/src/main/java/app/revanced/twitch/api/RequestInterceptor.kt new file mode 100644 index 00000000..257e7839 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/api/RequestInterceptor.kt @@ -0,0 +1,86 @@ +package app.revanced.twitch.api + +import app.revanced.twitch.adblock.IAdblockService +import app.revanced.twitch.adblock.IAdblockService.Companion.channelName +import app.revanced.twitch.adblock.IAdblockService.Companion.isVod +import app.revanced.twitch.adblock.PurpleAdblockService +import app.revanced.twitch.adblock.TTVLolService +import app.revanced.twitch.settings.SettingsEnum +import app.revanced.twitch.utils.LogHelper +import app.revanced.twitch.utils.ReVancedUtils +import okhttp3.* + +class RequestInterceptor : Interceptor { + private var activeService: IAdblockService? = null + + private fun updateActiveService() { + val current = SettingsEnum.BLOCK_EMBEDDED_ADS.string + activeService = if(current == ReVancedUtils.getString("key_revanced_proxy_ttv_lol") && activeService !is TTVLolService) + TTVLolService() + else if(current == ReVancedUtils.getString("key_revanced_proxy_purpleadblock") && activeService !is PurpleAdblockService) + PurpleAdblockService() + else if(current == ReVancedUtils.getString("key_revanced_proxy_disabled")) + null + else + activeService + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + LogHelper.debug("Intercepted request to URL: %s", originalRequest.url.toString()) + + // Skip if not HLS manifest request + if (!originalRequest.url.host.contains("usher.ttvnw.net")) { + return chain.proceed(originalRequest) + } + + LogHelper.debug("Found HLS manifest request. Is VOD? %s; Channel: %s", + if (originalRequest.isVod()) "yes" else "no", originalRequest.channelName()) + + // None of the services support VODs currently + if(originalRequest.isVod()) + return chain.proceed(originalRequest) + + updateActiveService() + + activeService?.let { + val available = it.isAvailable() + val rewritten = it.rewriteHlsRequest(originalRequest) + + if (!available || rewritten == null) { + ReVancedUtils.toast( + String.format(ReVancedUtils.getString("revanced_embedded_ads_service_unavailable"), it.friendlyName()), + true + ) + return chain.proceed(originalRequest) + } + + LogHelper.debug("Rewritten HLS stream URL: %s", rewritten.url.toString()) + + val maxAttempts = it.maxAttempts() + for(i in 1..maxAttempts) { + // Execute rewritten request and close body to allow multiple proceed() calls + val response = chain.proceed(rewritten).apply { close() } + if(!response.isSuccessful) { + LogHelper.error("Request failed (attempt %d/%d): HTTP error %d (%s)", + i, maxAttempts, response.code, response.message) + Thread.sleep(50) + } + else { + // Accept response from ad blocker + LogHelper.debug("Ad-blocker used") + return chain.proceed(rewritten) + } + } + + // maxAttempts exceeded; giving up on using the ad blocker + ReVancedUtils.toast( + String.format(ReVancedUtils.getString("revanced_embedded_ads_service_failed"), it.friendlyName()), + true + ) + } + + // Adblock disabled + return chain.proceed(originalRequest) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/api/RetrofitClient.java b/app/src/main/java/app/revanced/twitch/api/RetrofitClient.java new file mode 100644 index 00000000..9d5014c5 --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/api/RetrofitClient.java @@ -0,0 +1,25 @@ +package app.revanced.twitch.api; + +import retrofit2.Retrofit; + +public class RetrofitClient { + + private static RetrofitClient instance = null; + private final PurpleAdblockApi purpleAdblockApi; + + private RetrofitClient() { + Retrofit retrofit = new Retrofit.Builder().baseUrl("http://localhost" /* dummy */).build(); + purpleAdblockApi = retrofit.create(PurpleAdblockApi.class); + } + + public static synchronized RetrofitClient getInstance() { + if (instance == null) { + instance = new RetrofitClient(); + } + return instance; + } + + public PurpleAdblockApi getPurpleAdblockApi() { + return purpleAdblockApi; + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/patches/EmbeddedAdsPatch.java b/app/src/main/java/app/revanced/twitch/patches/EmbeddedAdsPatch.java new file mode 100644 index 00000000..3f86fb9a --- /dev/null +++ b/app/src/main/java/app/revanced/twitch/patches/EmbeddedAdsPatch.java @@ -0,0 +1,9 @@ +package app.revanced.twitch.patches; + +import app.revanced.twitch.api.RequestInterceptor; + +public class EmbeddedAdsPatch { + public static RequestInterceptor createRequestInterceptor() { + return new RequestInterceptor(); + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java b/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java index 548366d2..fdb48ba9 100644 --- a/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java +++ b/app/src/main/java/app/revanced/twitch/settings/SettingsEnum.java @@ -10,6 +10,7 @@ public enum SettingsEnum { /* Ads */ BLOCK_VIDEO_ADS("revanced_block_video_ads", true, ReturnType.BOOLEAN), BLOCK_AUDIO_ADS("revanced_block_audio_ads", true, ReturnType.BOOLEAN), + BLOCK_EMBEDDED_ADS("revanced_block_embedded_ads", "ttv-lol", ReturnType.STRING), /* Chat */ SHOW_DELETED_MESSAGES("revanced_show_deleted_messages", "cross-out", ReturnType.STRING), diff --git a/app/src/main/java/app/revanced/twitch/utils/LogHelper.java b/app/src/main/java/app/revanced/twitch/utils/LogHelper.java index f8d8add6..f2752f7f 100644 --- a/app/src/main/java/app/revanced/twitch/utils/LogHelper.java +++ b/app/src/main/java/app/revanced/twitch/utils/LogHelper.java @@ -43,7 +43,7 @@ public class LogHelper { private static void showDebugToast(String msg) { if(SettingsEnum.DEBUG_MODE.getBoolean()) { - ReVancedUtils.ifContextAttached((c) -> Toast.makeText(c, msg, Toast.LENGTH_SHORT).show()); + ReVancedUtils.toast(msg, false); } } } diff --git a/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java b/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java index d915bff4..9e9ec956 100644 --- a/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java +++ b/app/src/main/java/app/revanced/twitch/utils/ReVancedUtils.java @@ -2,6 +2,9 @@ package app.revanced.twitch.utils; import android.annotation.SuppressLint; import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; public class ReVancedUtils { @SuppressLint("StaticFieldLeak") @@ -53,6 +56,10 @@ public class ReVancedUtils { void run(Context ctx); } + public static void runOnMainThread(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + /** * Get resource id safely * @return May return 0 if resource not found or context not attached @@ -84,4 +91,13 @@ public class ReVancedUtils { public static String getString(String name) { return ifContextAttached((c) -> c.getString(getStringId(name)), ""); } + + public static void toast(String message) { + toast(message, true); + } + public static void toast(String message, boolean longLength) { + ifContextAttached((c) -> { + runOnMainThread(() -> Toast.makeText(c, message, longLength ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show()); + }); + } }