From f53e77dc538b3b3a6307bad57b52e44cb91bbbe8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 22 Jul 2026 21:05:35 +0200 Subject: [PATCH 1/3] feat: deliver signed SABR compatibility profiles --- app/build.gradle | 21 +- app/src/main/java/org/schabi/newpipe/App.java | 5 +- .../player/datasource/QuickJsSabrRuntime.kt | 113 ----- .../datasource/QuickJsSabrSessionPolicy.kt | 415 ------------------ .../player/datasource/SabrPolicyRuntime.java | 387 +++------------- .../datasource/SabrPolicyUpdateWorker.kt | 89 ++-- .../player/datasource/SabrProfileCache.java | 176 ++++++++ .../datasource/SabrProfileRegistry.java | 171 ++++++++ .../datasource/SabrProfileRestorer.java | 69 +++ app/src/main/res/values/settings_keys.xml | 2 + 10 files changed, 566 insertions(+), 882 deletions(-) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/QuickJsSabrRuntime.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/QuickJsSabrSessionPolicy.kt create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileCache.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRegistry.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRestorer.java diff --git a/app/build.gradle b/app/build.gradle index e1538318b..a1b872c63 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,12 @@ def keyPath = System.getenv("KEY_PATH") def keyStorePassword = System.getenv("KEY_STORE_PASSWORD") def signingKeyAlias = System.getenv("KEY_ALIAS") def signingKeyPassword = System.getenv("KEY_PASSWORD") -def sabrPolicyPublicKey = "" -def sabrPolicyUrl = "" +def pipePipeVersionName = "5.2.4" +def sabrProfilePublicKeys = System.getenv("SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS") ?: "" +def sabrProfileChannel = pipePipeVersionName.contains("-beta") ? "beta" : "stable" +def sabrProfileUrl = System.getenv(sabrProfileChannel == "beta" + ? "SABR_COMPATIBILITY_PROFILE_BETA_URL" + : "SABR_COMPATIBILITY_PROFILE_STABLE_URL") ?: "" android { compileSdk 37 @@ -22,15 +26,17 @@ android { //noinspection OldTargetApi,ExpiredTargetSdkVersion targetSdk 36 versionCode 1105 - versionName "5.2.4" + versionName pipePipeVersionName multiDexEnabled true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true - buildConfigField "String", "SABR_POLICY_PUBLIC_KEY_BASE64", - "\"${sabrPolicyPublicKey.replace('\\', '\\\\').replace('"', '\\"')}\"" - buildConfigField "String", "SABR_POLICY_URL", - "\"${sabrPolicyUrl.replace('\\', '\\\\').replace('"', '\\"')}\"" + buildConfigField "String", "SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS", + "\"${sabrProfilePublicKeys.replace('\\', '\\\\').replace('"', '\\"')}\"" + buildConfigField "String", "SABR_COMPATIBILITY_PROFILE_URL", + "\"${sabrProfileUrl.replace('\\', '\\\\').replace('"', '\\"')}\"" + buildConfigField "String", "SABR_COMPATIBILITY_PROFILE_CHANNEL", + "\"${sabrProfileChannel}\"" javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] @@ -183,7 +189,6 @@ dependencies { /** NewPipe libraries **/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' implementation group: 'com.github.InfinityLoop1308.PipePipeExtractor', name: 'extractor' - implementation 'io.github.dokar3:quickjs-kt:1.0.5' /** Kotlin **/ implementation "org.jetbrains.kotlin:kotlin-stdlib" diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 57871bfdc..48d74f3be 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -142,10 +142,11 @@ public void onChanged(Integer connectionState) { }); try { SabrPolicyRuntime.initialize(this, - BuildConfig.SABR_POLICY_PUBLIC_KEY_BASE64, 0); + BuildConfig.SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS, + BuildConfig.SABR_COMPATIBILITY_PROFILE_CHANNEL, 0); SabrPolicyUpdateWorker.initialize(this); } catch (final IllegalArgumentException error) { - Log.e(TAG, "Could not initialize SABR cloud policy; using builtin", error); + Log.e(TAG, "Could not initialize SABR compatibility profiles; using builtin", error); } final AndroidWebViewAvailabilityChecker webViewAvailabilityChecker = new AndroidWebViewAvailabilityChecker(this); diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/QuickJsSabrRuntime.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/QuickJsSabrRuntime.kt deleted file mode 100644 index 462cde416..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/QuickJsSabrRuntime.kt +++ /dev/null @@ -1,113 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import com.dokar.quickjs.QuickJs -import com.dokar.quickjs.binding.function -import com.grack.nanojson.JsonObject -import com.grack.nanojson.JsonParser -import com.grack.nanojson.JsonWriter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicy -import java.util.WeakHashMap - -internal object QuickJsSabrRuntime { - private const val MAX_RESULT_CHARS = 512 * 1024 - private const val MEMORY_LIMIT_BYTES = 32L * 1024 * 1024 - private const val MAX_STACK_BYTES = 512L * 1024 - - private val quickJs = QuickJs.create(Dispatchers.Unconfined) - private val compiledPolicies = WeakHashMap() - private var inputJson = "{}" - private var methodName = "describe" - private var activeSessionId = 0 - private var nextSessionId = 1 - private val invokeBytecode: ByteArray - private val closeBytecode: ByteArray - - init { - quickJs.memoryLimit = MEMORY_LIMIT_BYTES - quickJs.maxStackSize = MAX_STACK_BYTES - quickJs.function("__hostInput") { inputJson } - quickJs.function("__hostMethod") { methodName } - quickJs.function("__hostSession") { activeSessionId } - runBlocking { - quickJs.evaluate(RUNTIME_PRELUDE + "var __sabrPolicies=Object.create(null);") - } - invokeBytecode = quickJs.compile( - "(function(){var p=__sabrPolicies[__hostSession()];" + - "if(!p)throw Error('closed SABR policy');var m=__hostMethod(),f=p[m];" + - "if(typeof f!=='function')throw Error('missing method '+m);" + - "return JSON.stringify(f.call(p,JSON.parse(__hostInput())))})()", - ) - closeBytecode = quickJs.compile("delete __sabrPolicies[__hostSession()]") - } - - @Synchronized - fun createSession(script: SabrScriptPolicy): Int { - val sessionId = nextSessionId++ - if (sessionId <= 0) throw SabrProtocolException("SABR QuickJS session limit reached") - activeSessionId = sessionId - try { - val bootstrap = compiledPolicies[script] ?: quickJs.compile( - "(function(id){\n" + script.source + - "\n;if(typeof createSabrPolicy!=='function')" + - "throw Error('missing createSabrPolicy');" + - "var p=createSabrPolicy(sabr);" + - "if(!p)throw Error('createSabrPolicy returned no policy');" + - "__sabrPolicies[id]=p;})(__hostSession())", - ).also { compiledPolicies[script] = it } - runBlocking { quickJs.evaluate(bootstrap) } - return sessionId - } catch (error: Exception) { - throw SabrProtocolException("Could not initialize SABR QuickJS policy", error) - } - } - - @Synchronized - fun invoke(sessionId: Int, method: String, input: JsonObject): JsonObject { - activeSessionId = sessionId - methodName = method - inputJson = JsonWriter.string(input) - return try { - val result = runBlocking { quickJs.evaluate(invokeBytecode) } - if (result.length > MAX_RESULT_CHARS) { - throw SabrProtocolException("SABR QuickJS result exceeded limit") - } - JsonParser.`object`().from(result) - } catch (error: SabrProtocolException) { - throw error - } catch (error: Exception) { - throw SabrProtocolException("SABR QuickJS method failed: $method", error) - } - } - - @Synchronized - fun closeSession(sessionId: Int) { - activeSessionId = sessionId - runBlocking { quickJs.evaluate(closeBytecode) } - quickJs.gc() - } - - private const val RUNTIME_PRELUDE = - "var sabr=(function(){'use strict';" + - "var A='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';" + - "function b64d(s){var o=[],v=0,n=0,i,c;for(i=0;i=8){n-=8;o.push((v>>n)&255)}}return o}" + - "function b64e(a){var o='',i,v;for(i=0;i>18)&63]+A[(v>>12)&63]+(i+1>6)&63]:'=')" + - "+(i+2=a.length)throw Error('truncated varint');" + - "b=a[p.i++];v+=(b&127)*m;m*=128}while(b&128);return v}" + - "function dec(a){var p={i:0},o=[],k,n,w,l,s;while(p.i127){o.push((v%128)|128);v=Math.floor(v/128)}o.push(v)}" + - "function enc(fs){var o=[],i,f,b;for(i=0;i.evaluateDemandRoute(event) - val output = invokeObject("demandRoute", demandInput(event)) - return try { - SabrSessionPolicy.DemandRoute.valueOf( - output.getString("route") - ?: throw SabrProtocolException("QuickJS demand policy returned no route"), - ) - } catch (error: IllegalArgumentException) { - throw SabrProtocolException("QuickJS demand policy returned unknown route", error) - } - } - - @Synchronized - override fun evaluateDemandResponse( - event: SabrSessionPolicy.DemandResponseEvent, - ): SabrSessionPolicy.DemandResponseDecision { - if (!scriptedDemand) return super.evaluateDemandResponse(event) - val input = demandInput(event) - input["segmentCount"] = event.segmentCount - input["targetTrackSegmentCount"] = event.targetTrackSegmentCount - input["returnedSegmentsTruncated"] = event.areReturnedSegmentsTruncated() - val returned = JsonArray() - for (segment in event.returnedSegments) { - returned.add(JsonObject().apply { - this["itag"] = segment.itag - this["sequenceNumber"] = segment.sequenceNumber - this["startMs"] = segment.startMs - this["durationMs"] = segment.durationMs - }) - } - input["returnedSegments"] = returned - val output = invokeObject("demandResponse", input) - val outcome = try { - SabrSessionPolicy.DemandOutcome.valueOf( - output.getString("outcome") - ?: throw SabrProtocolException("QuickJS demand policy returned no outcome"), - ) - } catch (error: IllegalArgumentException) { - throw SabrProtocolException("QuickJS demand policy returned unknown outcome", error) - } - val retryDelayMs = output.getInt("retryDelayMs", 0) - if (retryDelayMs !in 0..SabrSessionPolicy.MAX_DEMAND_RETRY_DELAY_MS || - outcome != SabrSessionPolicy.DemandOutcome.CONTINUE && retryDelayMs != 0 - ) { - throw SabrProtocolException("QuickJS demand policy returned invalid retry delay") - } - return SabrSessionPolicy.DemandResponseDecision( - outcome, - retryDelayMs, - ) - } - - @Synchronized - override fun evaluate( - state: SabrSessionPolicy.State, - event: SabrSessionPolicy.Event, - ): SabrSessionPolicy.Result { - ensureOpen() - if (event is SabrSessionPolicy.RequestEvent) { - val input = stateJson(state) - input["playerTimeMs"] = event.playerTimeMs - input["bufferedEdgeMs"] = event.bufferedEdgeMs - input["poTokenBytes"] = event.poTokenBytes - input["bufferedRangeCount"] = event.bufferedRangeCount - input["fallbackBody"] = Base64.encodeToString(event.proposedBody, Base64.NO_WRAP) - val output = invokeObject( - if (state.requestNumber == 0) "initialRequest" else "followUpRequest", - input, - ) - val body = output.getString("body") - ?: throw SabrProtocolException("QuickJS policy returned no request body") - val bytes = try { - Base64.decode(body, Base64.DEFAULT) - } catch (error: IllegalArgumentException) { - throw SabrProtocolException("Invalid QuickJS request body", error) - } - return SabrSessionPolicy.Result.request( - state, - if (state.requestNumber == 0) { - SabrSessionPolicy.ActionType.SEND_INITIAL_REQUEST - } else { - SabrSessionPolicy.ActionType.SEND_FOLLOW_UP_REQUEST - }, - bytes, - ) - } - return control(state, event as SabrSessionPolicy.ControlResponseEvent) - } - - private fun control( - state: SabrSessionPolicy.State, - event: SabrSessionPolicy.ControlResponseEvent, - ): SabrSessionPolicy.Result { - val input = stateJson(state) - input["segmentCount"] = event.segmentCount - input["honorBackoff"] = event.shouldHonorBackoff() - input["mode"] = event.mode.name - val parts = JsonArray() - for (part in event.response.parts) { - if (part.type == mediaProtocol.mediaPartType) continue - val item = JsonObject() - item["type"] = part.type - item["data"] = Base64.encodeToString(part.data, Base64.NO_WRAP) - parts.add(item) - } - input["parts"] = parts - val builtin = JsonObject() - builtin["error"] = event.response.sabrErrorDetails != null - builtin["reload"] = event.response.isReloadRequested - builtin["protection"] = event.response.isProtectionBoundaryNoMediaResponse - builtin["redirectUrl"] = event.response.redirectUrl - builtin["backoffMs"] = maxOf(0, event.response.backoffTimeMs) - input["builtin"] = builtin - - val output = invokeObject("response", input) - val outputActions = output.getArray("actions") - if (outputActions == null || outputActions.isEmpty()) { - throw SabrProtocolException("QuickJS policy returned no actions") - } - val actions = ArrayList(outputActions.size) - for (index in outputActions.indices) { - try { - actions.add( - SabrSessionPolicy.Action( - SabrSessionPolicy.ActionType.valueOf(outputActions.getString(index)), - ), - ) - } catch (error: RuntimeException) { - throw SabrProtocolException("QuickJS policy returned unknown action", error) - } - } - val next = output.getObject("state") - val nextState = if (next == null) state else SabrSessionPolicy.State( - state.requestNumber, - next.getInt("redirectCount", state.redirectCount), - next.getInt("poTokenRefreshes", state.poTokenRefreshes), - state.reloads, - ) - return SabrSessionPolicy.Result.control( - nextState, - actions, - SabrSessionPolicy.ControlDecision( - output.getInt("backoffMs", 0), - output.getString("redirectUrl"), - output.getString("errorDetails"), - ), - if (output.has("statePatch")) { - parseStatePatch(output.getObject("statePatch"), event) - } else { - null - }, - ) - } - - private fun parseStatePatch( - value: JsonObject?, - event: SabrSessionPolicy.ControlResponseEvent, - ): SabrResponseStatePatch? { - if (value == null) return null - val builder = SabrResponseStatePatch.builder() - val next = value.getObject("nextRequest") - if (next != null) { - builder.setNextRequestPolicy( - SabrNextRequestPolicy.normalized( - next.getInt("targetAudioReadaheadMs", -1), - next.getInt("targetVideoReadaheadMs", -1), - next.getInt("maxTimeSinceLastRequestMs", -1), - next.getInt("backoffTimeMs", -1), - next.getInt("minAudioReadaheadMs", -1), - next.getInt("minVideoReadaheadMs", -1), - decodeOptional(next.getString("playbackCookie")), - next.getString("videoId"), - ), - ) - } - val live = value.getArray("live") - if (live != null) { - for (index in live.indices) { - val item = live.getObject(index) - builder.addLiveMetadata( - SabrLiveMetadata.normalized( - item.getString("broadcastId"), - item.getLong("headSequenceNumber", -1), - item.getLong("headTimeMs", -1), - item.getLong("wallTimeMs", -1), - item.getString("videoId"), - item.getBoolean("postLiveDvr", false), - item.getLong("headm", -1), - item.getLong("minSeekableTimeTicks", -1), - item.getInt("minSeekableTimescale", -1), - item.getLong("maxSeekableTimeTicks", -1), - item.getInt("maxSeekableTimescale", -1), - ), - ) - } - } - val formats = value.getArray("formats") - if (formats != null) { - for (index in formats.indices) { - val item = formats.getObject(index) - builder.addFormatMetadata( - SabrFormatInitializationMetadata.normalized( - item.getString("videoId"), - item.getInt("itag", -1), - item.getLong("lastModified", -1), - item.getString("xtags"), - item.getLong("endTimeMs", -1), - item.getLong("endSegmentNumber", -1), - item.getString("mimeType"), - item.getLong("initRangeStart", -1), - item.getLong("initRangeEnd", -1), - item.getLong("indexRangeStart", -1), - item.getLong("indexRangeEnd", -1), - item.getLong("field8", -1), - item.getLong("durationUnits", -1), - item.getLong("durationTimescale", -1), - ), - ) - } - } - val contexts = value.getArray("contexts") - if (contexts != null) { - for (index in contexts.indices) { - val item = contexts.getObject(index) - builder.addContextUpdate( - SabrContextUpdate.normalized( - item.getInt("type", -1), - item.getInt("scope", -1), - decodeRequired(item.getString("value"), "context value"), - item.getBoolean("sendByDefault", false), - item.getInt("writePolicy", -1), - ), - ) - } - } - val contextPolicy = value.getObject("contextPolicy") - if (contextPolicy != null) { - builder.setContextSendingPolicy( - SabrContextSendingPolicy.normalized( - intList(contextPolicy.getArray("start")), - intList(contextPolicy.getArray("stop")), - intList(contextPolicy.getArray("discard")), - ), - ) - } - for (header in event.response.mediaHeaders) builder.addMediaHeader(header) - return builder.build() - } - - private fun intList(values: JsonArray?): List { - if (values == null) return emptyList() - return values.indices.map { values.getInt(it) } - } - - private fun decodeOptional(value: String?): ByteArray? = - value?.let { decodeRequired(it, "optional bytes") } - - private fun decodeRequired(value: String?, name: String): ByteArray { - if (value == null) throw SabrProtocolException("QuickJS policy returned no $name") - return try { - Base64.decode(value, Base64.DEFAULT) - } catch (error: IllegalArgumentException) { - throw SabrProtocolException("QuickJS policy returned invalid $name", error) - } - } - - @Synchronized - private fun invokeObject(method: String, input: JsonObject): JsonObject { - ensureOpen() - return QuickJsSabrRuntime.invoke(sessionId, method, input) - } - - private fun stateJson(state: SabrSessionPolicy.State) = JsonObject().apply { - this["requestNumber"] = state.requestNumber - this["redirectCount"] = state.redirectCount - this["poTokenRefreshes"] = state.poTokenRefreshes - this["reloads"] = state.reloads - } - - private fun demandInput(event: SabrSessionPolicy.DemandEvent) = JsonObject().apply { - this["targetItag"] = event.targetItag - this["targetSequenceNumber"] = event.targetSequenceNumber - this["targetStartMs"] = event.targetStartMs - this["bufferedEdgeMs"] = event.bufferedEdgeMs - this["createdAtMs"] = event.state.createdAtMs - this["nowMs"] = event.state.nowMs - this["elapsedMs"] = event.state.elapsedMs - this["responsesWithoutDemandedSegment"] = - event.state.responsesWithoutDemandedSegment - this["recoveryCount"] = event.state.recoveryCount - } - - private fun ensureOpen() { - if (closed) throw SabrProtocolException("SABR QuickJS policy is closed") - } - - @Synchronized - override fun close() { - if (!closed) { - closed = true - closeCreatedSession() - } - } - - private fun closeCreatedSession() { - if (sessionId >= 0) { - QuickJsSabrRuntime.closeSession(sessionId) - sessionId = -1 - } - } - - private inner class ScriptMediaProtocol( - private val headerType: Int, - private val mediaType: Int, - private val endType: Int, - private val builtinHeaderDecoder: Boolean, - ) : SabrMediaProtocol { - init { - if (headerType < 0 || mediaType < 0 || endType < 0 || - headerType == mediaType || headerType == endType || mediaType == endType - ) { - throw IllegalArgumentException("Invalid QuickJS media protocol types") - } - } - - override fun getHeaderPartType() = headerType - override fun getMediaPartType() = mediaType - override fun getEndPartType() = endType - - override fun decodeHeader(payload: ByteArray): SabrMediaHeader { - if (builtinHeaderDecoder) return SabrMediaProtocol.builtin().decodeHeader(payload) - val input = JsonObject() - input["data"] = Base64.encodeToString(payload, Base64.NO_WRAP) - val value = invokeObject("mediaHeader", input) - val headerId = value.getInt("headerId", -1) - val itag = value.getInt("itag", -1) - if (headerId !in 0..255 || itag <= 0) { - throw SabrProtocolException("QuickJS policy returned invalid media header identity") - } - return SabrMediaHeader.normalized( - headerId, - value.getString("videoId"), - itag, - value.getLong("lastModified", -1), - value.getString("xtags"), - value.getLong("startRange", -1), - value.getInt("compressionAlgorithm", -1), - value.getBoolean("initSegment", false), - value.getInt("sequenceNumber", -1), - value.getLong("bitrateBps", -1), - value.getLong("startMs", -1), - value.getLong("durationMs", -1), - value.getLong("contentLength", -1), - value.getLong("timeRangeStartTicks", -1), - value.getLong("timeRangeDurationTicks", -1), - value.getInt("timeRangeTimescale", -1), - value.getLong("sequenceLastModified", -1), - ) - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntime.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntime.java index ae8884725..9d817a1ba 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntime.java +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntime.java @@ -1,48 +1,33 @@ package org.schabi.newpipe.player.datasource; import android.content.Context; -import android.util.AtomicFile; import android.util.Base64; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.extractor.services.youtube.sabr.BuiltinSabrSessionPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaHeader; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaProtocol; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicyDocument; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicyManager; +import org.schabi.newpipe.extractor.services.youtube.sabr.CompatibilitySabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.FallbackSabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfile; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicy; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyHost; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyTranscript; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; -import java.security.PublicKey; +import java.util.LinkedHashMap; +import java.util.Map; -/** Single construction boundary for the bundled SABR policy set and its per-session transcripts. */ +/** Client-owned delivery boundary for signed declarative SABR compatibility profiles. */ public final class SabrPolicyRuntime { - public enum BenchmarkPolicyMode { AUTO, BUILTIN, CLOUD } + public enum BenchmarkPolicyMode { AUTO, BUILTIN, PROFILE } + private static final String TAG = "SabrProfileRuntime"; private static final int SESSION_TRANSCRIPT_CAPACITY = 512; - private static final int CACHE_MAGIC = 0x53504348; - private static final int CACHE_VERSION = 1; - private static final int MAX_PAYLOAD_BYTES = 512 * 1024; - private static final int MAX_SIGNATURE_BYTES = 1024; - private static final String CACHE_FILE = "sabr-cloud-policy.bin"; - private static final String REVISION_FILE = "sabr-cloud-policy.rev"; - @NonNull private static final SabrSessionPolicy FALLBACK = new BuiltinSabrSessionPolicy(); - @Nullable private static volatile SabrScriptPolicyManager cloudPolicies; - @Nullable private static volatile AtomicFile policyCache; - @Nullable private static volatile AtomicFile revisionCache; + @NonNull private static final SabrSessionPolicy BUILTIN = new BuiltinSabrSessionPolicy(); + @Nullable private static volatile SabrProfileRegistry registry; @NonNull private static volatile BenchmarkPolicyMode benchmarkPolicyMode = BenchmarkPolicyMode.AUTO; @@ -51,327 +36,95 @@ private SabrPolicyRuntime() { @NonNull public static SabrSessionPolicyHost createSessionHost() { - final BenchmarkPolicyMode benchmarkMode = benchmarkPolicyMode; - if (benchmarkMode == BenchmarkPolicyMode.BUILTIN) { - return createHost(FALLBACK); - } - final SabrScriptPolicyManager manager = cloudPolicies; - SabrSessionPolicy policy = FALLBACK; - final SabrScriptPolicy script = manager == null - ? null : manager.current(System.currentTimeMillis()); - if (script != null) { - try { - policy = new FailoverPolicy(script, createScriptPolicy(script)); - } catch (final SabrProtocolException ignored) { - policy = FALLBACK; + final BenchmarkPolicyMode mode = benchmarkPolicyMode; + if (mode == BenchmarkPolicyMode.BUILTIN) { + return createHost(BUILTIN); + } + final SabrProfileRegistry localRegistry = registry; + final SabrCompatibilityProfile profile = localRegistry == null + ? null : localRegistry.current(System.currentTimeMillis()); + if (profile == null) { + if (mode == BenchmarkPolicyMode.PROFILE) { + throw new IllegalStateException("No active SABR compatibility profile"); } + return createHost(BUILTIN); } - if (benchmarkMode == BenchmarkPolicyMode.CLOUD && policy == FALLBACK) { - throw new IllegalStateException("No active SABR cloud policy for benchmark"); - } - return createHost(policy); + Log.i(TAG, "Using SABR compatibility profile revision=" + profile.getRevision()); + final SabrSessionPolicy primary = new CompatibilitySabrSessionPolicy(profile); + return createHost(new FallbackSabrSessionPolicy(primary, BUILTIN, + failure -> disable(localRegistry, profile, failure))); } public static void setBenchmarkPolicyMode(@NonNull final BenchmarkPolicyMode mode) { if (!BuildConfig.DEBUG) { - throw new IllegalStateException("SABR benchmark policy override requires debug build"); + throw new IllegalStateException("SABR benchmark override requires a debug build"); } benchmarkPolicyMode = mode; } - @NonNull - private static SabrSessionPolicyHost createHost(@NonNull final SabrSessionPolicy policy) { - return new SabrSessionPolicyHost(policy, - new SabrSessionPolicyTranscript(SESSION_TRANSCRIPT_CAPACITY)); - } - - /** Configures cloud policy verification and restores the last verified cached envelope. */ - public static synchronized void initialize(@NonNull final Context context, - @NonNull final PublicKey publicKey, - final long minimumRevision) { - final SabrScriptPolicyManager manager = new SabrScriptPolicyManager(publicKey, - Math.max(minimumRevision, readHighestRevision(context))); - initialize(context, manager); - } - - private static synchronized void initialize(@NonNull final Context context, - @NonNull final SabrScriptPolicyManager manager) { - final AtomicFile cache = new AtomicFile(new File( - context.getApplicationContext().getFilesDir(), CACHE_FILE)); - final AtomicFile revisions = new AtomicFile(new File( - context.getApplicationContext().getFilesDir(), REVISION_FILE)); - try { - final Envelope envelope = decodeEnvelope(cache.readFully()); - final SabrScriptPolicy verified = manager.verify( - envelope.payload, envelope.signature, System.currentTimeMillis()); - createScriptPolicy(verified).close(); - manager.activate(verified); - } catch (final IOException | IllegalArgumentException | SabrProtocolException ignored) { - // Missing, expired, or invalid cache must never prevent startup or builtin playback. - } - cloudPolicies = manager; - policyCache = cache; - revisionCache = revisions; - } - - /** Initializes from a deployment-provided raw 32-byte Ed25519 key. Empty means builtin only. */ public static synchronized void initialize(@NonNull final Context context, - @Nullable final String publicKeyBase64, + @Nullable final String encodedPublicKeys, + @NonNull final String channel, final long minimumRevision) { - if (publicKeyBase64 == null || publicKeyBase64.isEmpty()) { - cloudPolicies = null; - policyCache = null; - revisionCache = null; - return; - } - try { - final byte[] key = Base64.decode(publicKeyBase64, Base64.DEFAULT); - final SabrScriptPolicyManager manager = new SabrScriptPolicyManager(key, - Math.max(minimumRevision, readHighestRevision(context))); - initialize(context, manager); - } catch (final IllegalArgumentException error) { - throw new IllegalArgumentException("Invalid SABR cloud policy public key", error); - } + registry = null; + final Map keys = parsePublicKeys(encodedPublicKeys); + registry = keys.isEmpty() ? null + : SabrProfileRegistry.restore(context, keys, channel, minimumRevision); } - /** Verifies, activates, and atomically persists a downloaded policy envelope. */ - public static synchronized void install(@NonNull final byte[] payload, - @NonNull final byte[] signature, - final long nowMs) throws IOException { - final SabrScriptPolicyManager manager = cloudPolicies; - final AtomicFile cache = policyCache; - final AtomicFile revisions = revisionCache; - if (manager == null || cache == null || revisions == null) { - throw new IllegalStateException("SABR cloud policy runtime is not initialized"); - } - final SabrScriptPolicy verified = manager.verify(payload, signature, nowMs); - try { - createScriptPolicy(verified).close(); - } catch (final SabrProtocolException error) { - throw new IOException("Invalid SABR JavaScript policy", error); + /** Verifies, atomically persists, then activates a downloaded profile. */ + public static synchronized void installDocument(@NonNull final byte[] document, + final long nowMs) throws IOException { + final SabrProfileRegistry current = registry; + if (current == null) { + throw new IllegalStateException("SABR profile runtime is not initialized"); } - writeRevision(revisions, verified.getRevision()); - try { - writeEnvelope(cache, encodeEnvelope(payload, signature)); - } catch (final IOException error) { - cache.delete(); - throw error; - } - manager.activate(verified); + current.install(document, nowMs); } - /** Installs a signed, human-readable JSON policy document received from remote delivery. */ - public static void installDocument(@NonNull final byte[] encoded, final long nowMs) - throws IOException { - final SabrScriptPolicyDocument.Parsed document; - try { - document = SabrScriptPolicyDocument.decode(encoded); - } catch (final IllegalArgumentException error) { - throw new IOException("Invalid SABR cloud policy document", error); - } - install(document.getPayload(), document.getSignature(), nowMs); + public static synchronized long currentRevision() { + final SabrProfileRegistry current = registry; + return current == null ? -1 : current.currentRevision(System.currentTimeMillis()); } @NonNull - private static SabrSessionPolicy createScriptPolicy(@NonNull final SabrScriptPolicy script) - throws SabrProtocolException { - return new QuickJsSabrSessionPolicy(script); - } - - private static synchronized void disable(@NonNull final SabrScriptPolicy script) { - final SabrScriptPolicyManager manager = cloudPolicies; - if (manager != null) manager.deactivate(script); - final AtomicFile cache = policyCache; - if (cache != null) cache.delete(); - } - - private static final class FailoverPolicy implements SabrSessionPolicy { - @NonNull private final SabrScriptPolicy script; - @NonNull private final SabrSessionPolicy primary; - @NonNull private final SabrMediaProtocol mediaProtocol; - private boolean failed; - - private FailoverPolicy(@NonNull final SabrScriptPolicy script, - @NonNull final SabrSessionPolicy primary) { - this.script = script; - this.primary = primary; - final SabrMediaProtocol primaryMedia = primary.getMediaProtocol(); - mediaProtocol = new SabrMediaProtocol() { - @Override public int getHeaderPartType() { - return currentMediaProtocol(primaryMedia).getHeaderPartType(); - } - @Override public int getMediaPartType() { - return currentMediaProtocol(primaryMedia).getMediaPartType(); - } - @Override public int getEndPartType() { - return currentMediaProtocol(primaryMedia).getEndPartType(); - } - @NonNull @Override public SabrMediaHeader decodeHeader(@NonNull final byte[] payload) - throws SabrProtocolException { - if (failed) return SabrMediaProtocol.builtin().decodeHeader(payload); - try { - return primaryMedia.decodeHeader(payload); - } catch (final RuntimeException | SabrProtocolException error) { - fail(); - return SabrMediaProtocol.builtin().decodeHeader(payload); - } - } - }; - } - - @NonNull - @Override - public SabrMediaProtocol getMediaProtocol() { - return mediaProtocol; - } - - @NonNull - @Override - public Result evaluate(@NonNull final State state, @NonNull final Event event) - throws SabrProtocolException { - if (failed) return FALLBACK.evaluate(state, event); - try { - return primary.evaluate(state, event); - } catch (final RuntimeException | SabrProtocolException error) { - fail(); - return FALLBACK.evaluate(state, event); + static Map parsePublicKeys(@Nullable final String encoded) { + final Map keys = new LinkedHashMap<>(); + if (encoded == null || encoded.trim().isEmpty()) { + return keys; + } + for (final String entry : encoded.split(",", -1)) { + final int separator = entry.indexOf('='); + if (separator <= 0 || separator == entry.length() - 1) { + throw new IllegalArgumentException("Invalid SABR profile public-key entry"); } - } - - @NonNull - @Override - public DemandRoute evaluateDemandRoute(@NonNull final DemandRouteEvent event) - throws SabrProtocolException { - if (failed) return FALLBACK.evaluateDemandRoute(event); - try { - return primary.evaluateDemandRoute(event); - } catch (final RuntimeException | SabrProtocolException error) { - fail(); - return FALLBACK.evaluateDemandRoute(event); - } - } - - @NonNull - @Override - public DemandResponseDecision evaluateDemandResponse( - @NonNull final DemandResponseEvent event) throws SabrProtocolException { - if (failed) return FALLBACK.evaluateDemandResponse(event); + final String keyId = entry.substring(0, separator); + final byte[] key; try { - return primary.evaluateDemandResponse(event); - } catch (final RuntimeException | SabrProtocolException error) { - fail(); - return FALLBACK.evaluateDemandResponse(event); + key = Base64.decode(entry.substring(separator + 1), Base64.DEFAULT); + } catch (final IllegalArgumentException failure) { + throw new IllegalArgumentException("Invalid SABR profile public key", failure); } - } - - @NonNull - private SabrMediaProtocol currentMediaProtocol(@NonNull final SabrMediaProtocol primaryMedia) { - return failed ? SabrMediaProtocol.builtin() : primaryMedia; - } - - private void fail() { - if (!failed) { - failed = true; - disable(script); - try { - primary.close(); - } catch (final RuntimeException ignored) { - } + if (keys.put(keyId, key) != null) { + throw new IllegalArgumentException("Duplicate SABR profile public key"); } } - - @Override - public void close() { - primary.close(); - } - } - - @NonNull - static byte[] encodeEnvelope(@NonNull final byte[] payload, - @NonNull final byte[] signature) { - validateLengths(payload.length, signature.length); - try { - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - final DataOutputStream output = new DataOutputStream(bytes); - output.writeInt(CACHE_MAGIC); - output.writeByte(CACHE_VERSION); - output.writeInt(payload.length); - output.writeInt(signature.length); - output.write(payload); - output.write(signature); - output.flush(); - return bytes.toByteArray(); - } catch (final IOException impossible) { - throw new IllegalStateException(impossible); - } + return keys; } @NonNull - static Envelope decodeEnvelope(@NonNull final byte[] encoded) throws IOException { - final DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); - if (input.readInt() != CACHE_MAGIC || input.readUnsignedByte() != CACHE_VERSION) { - throw new IOException("Unsupported SABR policy cache"); - } - final int payloadLength = input.readInt(); - final int signatureLength = input.readInt(); - validateLengths(payloadLength, signatureLength); - final byte[] payload = new byte[payloadLength]; - final byte[] signature = new byte[signatureLength]; - input.readFully(payload); - input.readFully(signature); - if (input.available() != 0) throw new IOException("Trailing SABR policy cache bytes"); - return new Envelope(payload, signature); - } - - private static void validateLengths(final int payloadLength, final int signatureLength) { - if (payloadLength <= 0 || payloadLength > MAX_PAYLOAD_BYTES - || signatureLength <= 0 || signatureLength > MAX_SIGNATURE_BYTES) { - throw new IllegalArgumentException("Invalid SABR policy cache size"); - } - } - - private static void writeEnvelope(@NonNull final AtomicFile cache, - @NonNull final byte[] encoded) throws IOException { - FileOutputStream output = null; - try { - output = cache.startWrite(); - output.write(encoded); - output.flush(); - cache.finishWrite(output); - } catch (final IOException error) { - if (output != null) cache.failWrite(output); - throw error; - } - } - - private static long readHighestRevision(@NonNull final Context context) { - final AtomicFile file = new AtomicFile(new File( - context.getApplicationContext().getFilesDir(), REVISION_FILE)); - try { - final DataInputStream input = new DataInputStream( - new ByteArrayInputStream(file.readFully())); - final long revision = input.readLong(); - return input.available() == 0 && revision >= 0 ? revision : 0; - } catch (final IOException ignored) { - return 0; - } - } - - private static void writeRevision(@NonNull final AtomicFile file, final long revision) - throws IOException { - final ByteArrayOutputStream bytes = new ByteArrayOutputStream(Long.BYTES); - final DataOutputStream output = new DataOutputStream(bytes); - output.writeLong(revision); - output.flush(); - writeEnvelope(file, bytes.toByteArray()); + private static SabrSessionPolicyHost createHost(@NonNull final SabrSessionPolicy policy) { + return new SabrSessionPolicyHost(policy, + new SabrSessionPolicyTranscript(SESSION_TRANSCRIPT_CAPACITY)); } - static final class Envelope { - @NonNull final byte[] payload; - @NonNull final byte[] signature; - private Envelope(@NonNull final byte[] payload, @NonNull final byte[] signature) { - this.payload = payload; - this.signature = signature; + private static synchronized void disable(@NonNull final SabrProfileRegistry expectedRegistry, + @NonNull final SabrCompatibilityProfile profile, + @NonNull final Throwable failure) { + if (registry != expectedRegistry || !expectedRegistry.disable(profile)) { + return; } + Log.w(TAG, "Disabled SABR profile revision=" + profile.getRevision() + + " failure=" + failure.getClass().getSimpleName()); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyUpdateWorker.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyUpdateWorker.kt index 1c64d1df9..b36b14106 100644 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyUpdateWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrPolicyUpdateWorker.kt @@ -13,6 +13,10 @@ import androidx.work.Worker import androidx.work.WorkerParameters import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileDocument +import okhttp3.Request +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.io.ByteArrayOutputStream import java.io.IOException import java.util.concurrent.TimeUnit @@ -21,44 +25,75 @@ class SabrPolicyUpdateWorker( params: WorkerParameters, ) : Worker(context, params) { override fun doWork(): Result { - val url = BuildConfig.SABR_POLICY_URL - if (url.isEmpty() || BuildConfig.SABR_POLICY_PUBLIC_KEY_BASE64.isEmpty()) { + val url = BuildConfig.SABR_COMPATIBILITY_PROFILE_URL + if (url.isEmpty() || BuildConfig.SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS.isEmpty()) { return Result.success() } return try { - val response = DownloaderImpl.getInstance().get(url) - when (response.responseCode()) { - 200 -> { - val body = response.rawResponseBody() - ?: throw IOException("SABR policy response had no body") - SabrPolicyRuntime.installDocument(body, System.currentTimeMillis()) - Result.success() - } - 204, 304 -> Result.success() - in 500..599 -> Result.retry() - else -> { - Log.w(TAG, "SABR policy update failed with HTTP ${response.responseCode()}") - Result.failure() + val parsedUrl = url.toHttpUrlOrNull() + ?.takeIf { it.isHttps } + ?: throw IllegalArgumentException("SABR profile URL must use HTTPS") + val client = DownloaderImpl.getInstance().client.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + val request = Request.Builder() + .url(parsedUrl) + .header("Accept", "application/json") + .build() + client.newCall(request).execute().use { response -> + when (response.code) { + 200 -> { + SabrPolicyRuntime.installDocument(readBounded(response), + System.currentTimeMillis()) + Result.success() + } + 204, 304 -> Result.success() + 408, 425, 429, in 500..599 -> Result.retry() + else -> { + Log.w(TAG, "SABR profile update failed with HTTP ${response.code}") + Result.success() + } } } } catch (error: IOException) { - Log.w(TAG, "Could not update SABR policy", error) + Log.w(TAG, "Could not update SABR profile", error) Result.retry() - } catch (error: RuntimeException) { - Log.e(TAG, "Rejected SABR policy update", error) - Result.failure() + } catch (error: IllegalArgumentException) { + Log.w(TAG, "Rejected SABR compatibility profile", error) + Result.success() } } companion object { private const val TAG = "SabrPolicyUpdate" - private const val IMMEDIATE_WORK = "sabr-policy-update-now" - private const val PERIODIC_WORK = "sabr-policy-update-periodic" + private const val BUFFER_BYTES = 8192 + + private fun readBounded(response: okhttp3.Response): ByteArray { + val body = response.body + val bytes = ByteArrayOutputStream() + val buffer = ByteArray(BUFFER_BYTES) + body.byteStream().use { input -> + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (bytes.size() + read > + SabrCompatibilityProfileDocument.MAX_DOCUMENT_BYTES + ) { + throw IllegalArgumentException( + "SABR profile response exceeds size limit", + ) + } + bytes.write(buffer, 0, read) + } + } + return bytes.toByteArray() + } @JvmStatic fun initialize(context: Context) { - if (BuildConfig.SABR_POLICY_URL.isEmpty() || - BuildConfig.SABR_POLICY_PUBLIC_KEY_BASE64.isEmpty() + if (BuildConfig.SABR_COMPATIBILITY_PROFILE_URL.isEmpty() || + BuildConfig.SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS.isEmpty() ) return val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -71,13 +106,13 @@ class SabrPolicyUpdateWorker( .build() val workManager = WorkManager.getInstance(context) workManager.enqueueUniqueWork( - IMMEDIATE_WORK, - ExistingWorkPolicy.KEEP, + "sabr-profile-update-now", + ExistingWorkPolicy.REPLACE, immediate, ) workManager.enqueueUniquePeriodicWork( - PERIODIC_WORK, - ExistingPeriodicWorkPolicy.KEEP, + "sabr-profile-update-periodic", + ExistingPeriodicWorkPolicy.UPDATE, periodic, ) } diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileCache.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileCache.java new file mode 100644 index 000000000..6b4045887 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileCache.java @@ -0,0 +1,176 @@ +package org.schabi.newpipe.player.datasource; + +import android.content.Context; +import android.util.AtomicFile; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileDocument; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; + +/** Atomic two-generation profile cache plus an independent monotonic revision floor. */ +final class SabrProfileCache { + private static final int STATE_MAGIC = 0x53435043; + private static final int FLOOR_MAGIC = 0x53435052; + private static final int STATE_VERSION = 2; + private static final int FLOOR_VERSION = 1; + private static final int MAX_DOCUMENT_BYTES = + SabrCompatibilityProfileDocument.MAX_DOCUMENT_BYTES; + private static final int MAX_STATE_BYTES = 2 * MAX_DOCUMENT_BYTES + 32; + + @NonNull private final AtomicFile stateFile; + @NonNull private final AtomicFile floorFile; + + SabrProfileCache(@NonNull final Context context, @NonNull final String channel) { + if (!channel.matches("[a-z0-9-]{1,24}")) { + throw new IllegalArgumentException("Invalid SABR profile channel"); + } + final File directory = context.getApplicationContext().getFilesDir(); + stateFile = new AtomicFile(new File(directory, + "sabr-compatibility-" + channel + ".bin")); + floorFile = new AtomicFile(new File(directory, + "sabr-compatibility-" + channel + ".rev")); + } + + @NonNull + State readState() throws IOException { + if (stateFile.getBaseFile().length() > MAX_STATE_BYTES) { + throw new IOException("SABR profile cache exceeds size limit"); + } + final DataInputStream input = new DataInputStream( + new ByteArrayInputStream(stateFile.readFully())); + if (input.readInt() != STATE_MAGIC) { + throw new IOException("Unsupported SABR profile cache"); + } + final int version = input.readUnsignedByte(); + if (version != 1 && version != STATE_VERSION) { + throw new IOException("Unsupported SABR profile cache"); + } + final byte[] active = readDocument(input); + final byte[] previous = readDocument(input); + final long fallbackFromRevision = version == STATE_VERSION ? input.readLong() : 0; + if (input.available() != 0) { + throw new IOException("Invalid SABR profile cache state"); + } + return new State(active, previous, fallbackFromRevision); + } + + long readRevisionFloor() { + try { + final DataInputStream input = new DataInputStream( + new ByteArrayInputStream(floorFile.readFully())); + if (input.readInt() != FLOOR_MAGIC || input.readUnsignedByte() != FLOOR_VERSION) { + return 0; + } + final long revision = input.readLong(); + return revision >= 0 && input.available() == 0 ? revision : 0; + } catch (final IOException ignored) { + return 0; + } + } + + void writeState(@NonNull final State state) throws IOException { + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(STATE_MAGIC); + output.writeByte(STATE_VERSION); + writeDocument(output, state.active); + writeDocument(output, state.previous); + output.writeLong(state.fallbackFromRevision); + output.flush(); + write(stateFile, bytes.toByteArray()); + } + + void writeRevisionFloor(final long revision) throws IOException { + if (revision < 0) { + throw new IllegalArgumentException("Negative SABR profile revision floor"); + } + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + final DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(FLOOR_MAGIC); + output.writeByte(FLOOR_VERSION); + output.writeLong(revision); + output.flush(); + write(floorFile, bytes.toByteArray()); + } + + void deleteState() { + stateFile.delete(); + } + + private static void writeDocument(@NonNull final DataOutputStream output, + @Nullable final byte[] document) throws IOException { + if (document == null) { + output.writeInt(-1); + return; + } + validateDocumentLength(document.length); + output.writeInt(document.length); + output.write(document); + } + + @Nullable + private static byte[] readDocument(@NonNull final DataInputStream input) throws IOException { + final int length = input.readInt(); + if (length == -1) { + return null; + } + validateDocumentLength(length); + final byte[] document = new byte[length]; + input.readFully(document); + return document; + } + + private static void validateDocumentLength(final int length) { + if (length <= 0 || length > MAX_DOCUMENT_BYTES) { + throw new IllegalArgumentException("Invalid cached SABR profile size"); + } + } + + private static void write(@NonNull final AtomicFile file, @NonNull final byte[] value) + throws IOException { + FileOutputStream output = null; + try { + output = file.startWrite(); + output.write(value); + output.flush(); + file.finishWrite(output); + } catch (final IOException failure) { + if (output != null) { + file.failWrite(output); + } + throw failure; + } + } + + static final class State { + @Nullable final byte[] active; + @Nullable final byte[] previous; + final long fallbackFromRevision; + + State(@Nullable final byte[] active, + @Nullable final byte[] previous, + final long fallbackFromRevision) { + if (fallbackFromRevision < 0 || active == null + && (previous != null || fallbackFromRevision != 0) + || fallbackFromRevision != 0 && previous != null) { + throw new IllegalArgumentException("Invalid SABR profile cache generations"); + } + this.active = active == null ? null : active.clone(); + this.previous = previous == null ? null : previous.clone(); + this.fallbackFromRevision = fallbackFromRevision; + } + + boolean isGenerationLayoutValid(final long revisionFloor) { + return fallbackFromRevision == 0 || fallbackFromRevision == revisionFloor; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRegistry.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRegistry.java new file mode 100644 index 000000000..611f01886 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRegistry.java @@ -0,0 +1,171 @@ +package org.schabi.newpipe.player.datasource; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfile; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileManager; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; + +/** Owns verified profile generations and their crash-safe local cache. */ +final class SabrProfileRegistry { + @NonNull private final SabrCompatibilityProfileManager manager; + @NonNull private final SabrProfileCache cache; + @Nullable private SabrCompatibilityProfile activeProfile; + @Nullable private SabrCompatibilityProfile previousProfile; + @Nullable private byte[] activeDocument; + @Nullable private byte[] previousDocument; + private long fallbackFromRevision; + + private SabrProfileRegistry(@NonNull final SabrCompatibilityProfileManager manager, + @NonNull final SabrProfileCache cache, + @Nullable final SabrProfileRestorer.Restored active, + @Nullable final SabrProfileRestorer.Restored previous, + final long fallbackFromRevision) { + this.manager = manager; + this.cache = cache; + activeProfile = active == null ? null : active.profile; + activeDocument = active == null ? null : active.document; + previousProfile = previous == null ? null : previous.profile; + previousDocument = previous == null ? null : previous.document; + this.fallbackFromRevision = fallbackFromRevision; + } + + @NonNull + static SabrProfileRegistry restore(@NonNull final Context context, + @NonNull final Map keys, + @NonNull final String channel, + final long minimumRevision) { + final SabrProfileCache cache = new SabrProfileCache(context, channel); + final long floor = Math.max(minimumRevision, cache.readRevisionFloor()); + final SabrCompatibilityProfileManager manager = + new SabrCompatibilityProfileManager(keys, floor); + SabrProfileCache.State cached; + try { + cached = cache.readState(); + } catch (final IOException | IllegalArgumentException ignored) { + cached = new SabrProfileCache.State(null, null, 0); + } + if (!cached.isGenerationLayoutValid(floor)) { + cached = new SabrProfileCache.State(null, null, 0); + } + final long nowMs = System.currentTimeMillis(); + final boolean persistedFallback = cached.fallbackFromRevision != 0; + final SabrProfileRestorer.Restored active = SabrProfileRestorer.restoreActive( + manager, cached.active, nowMs, persistedFallback); + final SabrProfileRestorer.Restored previous = active == null || persistedFallback ? null + : SabrProfileRestorer.restorePrevious(manager, cached.previous, nowMs); + final SabrCompatibilityProfile current = manager.current(nowMs); + final SabrProfileRestorer.Restored selected = + SabrProfileRestorer.matching(current, active, previous); + final SabrProfileRestorer.Restored fallback = selected == active ? previous : active; + final SabrProfileRegistry registry = new SabrProfileRegistry( + manager, cache, selected, fallback, + selected == null ? 0 : cached.fallbackFromRevision); + registry.compactCache(floor); + return registry; + } + + @Nullable + synchronized SabrCompatibilityProfile current(final long nowMs) { + return manager.current(nowMs); + } + + synchronized long currentRevision(final long nowMs) { + final SabrCompatibilityProfile current = manager.current(nowMs); + return current == null ? -1 : current.getRevision(); + } + + synchronized void install(@NonNull final byte[] document, final long nowMs) + throws IOException { + final SabrCompatibilityProfile verified = manager.verifyDocument(document, nowMs); + final SabrCompatibilityProfile current = manager.current(nowMs); + if (current != null && current.getRevision() == verified.getRevision() + && !Arrays.equals(current.serialize(), verified.serialize())) { + throw new IllegalArgumentException( + "Conflicting SABR compatibility profile revision"); + } + final boolean replacesSameRevision = current != null + && current.getRevision() == verified.getRevision(); + final byte[] oldActive = documentFor(current); + final SabrProfileCache.State oldState = + new SabrProfileCache.State( + activeDocument, previousDocument, fallbackFromRevision); + cache.writeState(new SabrProfileCache.State( + document, replacesSameRevision ? previousDocument : oldActive, 0)); + try { + cache.writeRevisionFloor(verified.getRevision()); + } catch (final IOException failure) { + restoreState(oldState); + throw failure; + } + manager.activate(verified); + previousProfile = replacesSameRevision ? previousProfile : current; + previousDocument = replacesSameRevision ? previousDocument : oldActive; + activeProfile = verified; + activeDocument = document.clone(); + fallbackFromRevision = 0; + } + + synchronized boolean disable(@NonNull final SabrCompatibilityProfile expected) { + if (!manager.deactivate(expected)) { + return false; + } + final SabrCompatibilityProfile fallback = manager.current(System.currentTimeMillis()); + final byte[] fallbackDocument = documentFor(fallback); + try { + if (fallbackDocument == null) { + cache.deleteState(); + } else { + cache.writeState(new SabrProfileCache.State(fallbackDocument, null, + manager.getHighestRevision())); + } + } catch (final IOException failure) { + cache.deleteState(); + } + activeProfile = fallback; + activeDocument = fallbackDocument; + previousProfile = null; + previousDocument = null; + fallbackFromRevision = fallback == null ? 0 : manager.getHighestRevision(); + return true; + } + + @Nullable + private byte[] documentFor(@Nullable final SabrCompatibilityProfile profile) { + if (profile == null) { + return null; + } + if (profile == activeProfile) { + return activeDocument == null ? null : activeDocument.clone(); + } + if (profile == previousProfile) { + return previousDocument == null ? null : previousDocument.clone(); + } + return null; + } + + private void compactCache(final long revisionFloor) { + try { + cache.writeState(new SabrProfileCache.State( + activeDocument, previousDocument, fallbackFromRevision)); + cache.writeRevisionFloor(Math.max(revisionFloor, manager.getHighestRevision())); + } catch (final IOException ignored) { + // A cache failure never prevents bundled SABR playback. + } + } + + private void restoreState(@NonNull final SabrProfileCache.State state) { + try { + cache.writeState(state); + } catch (final IOException ignored) { + cache.deleteState(); + } + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRestorer.java b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRestorer.java new file mode 100644 index 000000000..e77beeefb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/SabrProfileRestorer.java @@ -0,0 +1,69 @@ +package org.schabi.newpipe.player.datasource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfile; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileManager; + +/** Restores signed cache generations through their distinct trust paths. */ +final class SabrProfileRestorer { + private SabrProfileRestorer() { + } + + @Nullable + static Restored restoreActive(@NonNull final SabrCompatibilityProfileManager manager, + @Nullable final byte[] document, + final long nowMs, + final boolean persistedFallback) { + if (document == null) { + return null; + } + try { + final SabrCompatibilityProfile profile = persistedFallback + ? manager.restoreFallbackDocument(document, nowMs) + : manager.restoreDocument(document, nowMs); + return new Restored(profile, document); + } catch (final IllegalArgumentException ignored) { + return null; + } + } + + @Nullable + static Restored restorePrevious(@NonNull final SabrCompatibilityProfileManager manager, + @Nullable final byte[] document, + final long nowMs) { + if (document == null) { + return null; + } + try { + return new Restored(manager.restorePreviousDocument(document, nowMs), document); + } catch (final IllegalArgumentException ignored) { + return null; + } + } + + @Nullable + static Restored matching(@Nullable final SabrCompatibilityProfile profile, + @Nullable final Restored first, + @Nullable final Restored second) { + if (profile == null) { + return null; + } + if (first != null && first.profile == profile) { + return first; + } + return second != null && second.profile == profile ? second : null; + } + + static final class Restored { + @NonNull final SabrCompatibilityProfile profile; + @NonNull final byte[] document; + + private Restored(@NonNull final SabrCompatibilityProfile profile, + @NonNull final byte[] document) { + this.profile = profile; + this.document = document.clone(); + } + } +} diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 35cb70911..b1b019c62 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -1773,12 +1773,14 @@ youtube_player_client MWEB (SABR) + WEB (SABR) WEB Safari (HLS) Android VR (DASH) TV Downgraded (DASH) mweb + web web_safari android_vr tv_downgraded From 88e31f7f2a8e2e788a7fba2ad64b2bbadcbb5155 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 22 Jul 2026 21:05:43 +0200 Subject: [PATCH 2/3] test: cover SABR compatibility profile delivery --- .../assets/equivalent-builtin-sabr-policy.js | 159 -------- .../newpipe/player/SabrPlaybackSmokeTest.java | 329 +++++++++++++++- .../YoutubeClickBenchmarkSetupTest.java | 14 +- .../player/YoutubePlaybackBenchmarkTest.java | 4 +- .../QuickJsSabrSessionPolicyTest.kt | 185 --------- .../datasource/SabrPolicyRuntimeTest.java | 370 +++++++++++------- .../SabrProfileFallbackHarnessTest.java | 84 ++++ .../SabrProfileProcessRestartTest.java | 85 ++++ .../datasource/SabrProfileTestDocuments.java | 174 ++++++++ 9 files changed, 913 insertions(+), 491 deletions(-) delete mode 100644 app/src/androidTest/assets/equivalent-builtin-sabr-policy.js delete mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/QuickJsSabrSessionPolicyTest.kt create mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileFallbackHarnessTest.java create mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileProcessRestartTest.java create mode 100644 app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileTestDocuments.java diff --git a/app/src/androidTest/assets/equivalent-builtin-sabr-policy.js b/app/src/androidTest/assets/equivalent-builtin-sabr-policy.js deleted file mode 100644 index c2703e902..000000000 --- a/app/src/androidTest/assets/equivalent-builtin-sabr-policy.js +++ /dev/null @@ -1,159 +0,0 @@ -function createSabrPolicy(sabr) { - function text(bytes) { - var result = '', index = 0, first, second, third, code; - while (index < bytes.length) { - first = bytes[index++]; - if (first < 128) { - result += String.fromCharCode(first); - } else if (first < 224) { - second = bytes[index++]; - result += String.fromCharCode(((first & 31) << 6) | (second & 63)); - } else if (first < 240) { - second = bytes[index++]; - third = bytes[index++]; - result += String.fromCharCode(((first & 15) << 12) - | ((second & 63) << 6) | (third & 63)); - } else { - second = bytes[index++]; - third = bytes[index++]; - code = ((first & 7) << 18) | ((second & 63) << 12) - | ((third & 63) << 6) | (bytes[index++] & 63); - code -= 65536; - result += String.fromCharCode(55296 + (code >> 10), 56320 + (code & 1023)); - } - } - return result; - } - - function mediaHeader(event) { - var fields = sabr.proto.decode(sabr.base64.decode(event.data)); - var result = {}; - var timeRange = null; - var index, field, nested, nestedIndex, nestedField; - for (index = 0; index < fields.length; index++) { - field = fields[index]; - if (field.n === 1) result.headerId = field.v; - else if (field.n === 2) result.videoId = text(field.b); - else if (field.n === 3) result.itag = field.v; - else if (field.n === 4) result.lastModified = field.v; - else if (field.n === 5) result.xtags = text(field.b); - else if (field.n === 6) result.startRange = field.v; - else if (field.n === 7) result.compressionAlgorithm = field.v; - else if (field.n === 8) result.initSegment = field.v !== 0; - else if (field.n === 9) result.sequenceNumber = field.v; - else if (field.n === 10) result.bitrateBps = field.v; - else if (field.n === 11) result.startMs = field.v; - else if (field.n === 12) result.durationMs = field.v; - else if (field.n === 13) { - nested = sabr.proto.decode(field.b); - for (nestedIndex = 0; nestedIndex < nested.length; nestedIndex++) { - nestedField = nested[nestedIndex]; - if (nestedField.n === 1 && result.itag === undefined) result.itag = nestedField.v; - else if (nestedField.n === 2 && result.lastModified === undefined) { - result.lastModified = nestedField.v; - } else if (nestedField.n === 3 && result.xtags === undefined) { - result.xtags = text(nestedField.b); - } - } - } else if (field.n === 14) result.contentLength = field.v; - else if (field.n === 15) { - timeRange = {}; - nested = sabr.proto.decode(field.b); - for (nestedIndex = 0; nestedIndex < nested.length; nestedIndex++) { - nestedField = nested[nestedIndex]; - if (nestedField.n === 1) timeRange.start = nestedField.v; - else if (nestedField.n === 2) timeRange.duration = nestedField.v; - else if (nestedField.n === 3) timeRange.timescale = nestedField.v; - } - } else if (field.n === 16) result.sequenceLastModified = field.v; - } - if (timeRange) { - result.timeRangeStartTicks = timeRange.start; - result.timeRangeDurationTicks = timeRange.duration; - result.timeRangeTimescale = timeRange.timescale; - if (timeRange.timescale > 0) { - if (result.startMs === undefined && timeRange.start >= 0) { - result.startMs = Math.floor(timeRange.start * 1000 / timeRange.timescale); - } - if (result.durationMs === undefined && timeRange.duration >= 0) { - result.durationMs = Math.floor(timeRange.duration * 1000 / timeRange.timescale); - } - } - } - return result; - } - - function response(event) { - var actions = ['APPLY_BUILTIN_RESPONSE_STATE']; - var state = { - redirectCount: event.redirectCount, - poTokenRefreshes: event.poTokenRefreshes - }; - var redirect = event.builtin.redirectUrl; - var backoff = Math.max(0, event.builtin.backoffMs || 0); - if (redirect) { - actions.push('APPLY_REDIRECT'); - state.redirectCount++; - } - if (event.builtin.error) { - actions.push('FAIL_SABR_ERROR'); - return {actions: actions, state: state, redirectUrl: redirect, - errorDetails: 'SABR error'}; - } - if (event.builtin.reload) { - actions.push('TRY_RELOAD'); - return {actions: actions, state: state, redirectUrl: redirect}; - } - if (event.builtin.protection) { - actions.push(event.mode === 'FETCH_SEGMENT' ? 'REQUIRE_PO_TOKEN' : 'REFRESH_PO_TOKEN'); - } - if (event.mode === 'PUMP' && event.segmentCount > 0) { - state.redirectCount = 0; - state.poTokenRefreshes = 0; - actions.push('RESET_RECOVERY_BUDGETS'); - } - if (backoff > 0) { - actions.push(event.honorBackoff ? 'SLEEP_BACKOFF' : 'DEFER_BACKOFF'); - } else if (!event.honorBackoff) { - actions.push('CLEAR_DEMAND_BACKOFF'); - } - actions.push(event.mode === 'FETCH_SEGMENT' && event.builtin.protection - ? 'RETRY' : 'CONTINUE'); - return {actions: actions, state: state, backoffMs: backoff, redirectUrl: redirect}; - } - - function demandRoute(event) { - var recover = event.responsesWithoutDemandedSegment > event.recoveryCount; - if (event.targetStartMs < event.bufferedEdgeMs) { - return {route: recover ? 'RECOVER_REWIND' : 'REWIND'}; - } - if (event.targetStartMs > event.bufferedEdgeMs + 30000) { - return {route: recover ? 'RECOVER_FORWARD' : 'FORWARD'}; - } - return {route: recover ? 'RECOVER_MISSING' : 'STREAM'}; - } - - function demandResponse(event) { - if (event.responsesWithoutDemandedSegment >= 3) { - return {outcome: 'FAIL_REPEATED_TARGET_OMISSION', retryDelayMs: 0}; - } - if (event.elapsedMs >= 15000) { - return {outcome: event.targetTrackSegmentCount > 0 - ? 'FAIL_REPEATED_TARGET_OMISSION' : 'FAIL_NO_TARGET_MEDIA', retryDelayMs: 0}; - } - return {outcome: 'CONTINUE', retryDelayMs: 0}; - } - - return { - describe: function () { - return {demand: true, media: {headerType: 20, mediaType: 21, endType: 22, - headerDecoder: 'builtin'}}; - }, - initialRequest: function (event) { return {body: event.fallbackBody}; }, - followUpRequest: function (event) { return {body: event.fallbackBody}; }, - response: response, - demandRoute: demandRoute, - demandResponse: demandResponse, - mediaHeader: mediaHeader - }; -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java index 3fe28d5f9..be39420e1 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/SabrPlaybackSmokeTest.java @@ -11,7 +11,10 @@ import android.app.NotificationManager; import android.content.Context; import android.graphics.SurfaceTexture; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.SystemClock; import android.service.notification.StatusBarNotification; @@ -32,6 +35,7 @@ import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; import org.junit.Test; import org.junit.runner.RunWith; import org.schabi.newpipe.App; @@ -46,10 +50,18 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.services.youtube.sabr.ProfiledSabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileClient; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProfileRecovery; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProfileRequestField; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProfileResponseMapping; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProfileRule; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRequestDumper; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrResponseDecoder; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyHost; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyTranscript; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat; import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo; @@ -59,6 +71,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.datasource.SabrDashMediaSource; +import org.schabi.newpipe.player.datasource.SabrProfileTestDocuments; +import org.schabi.newpipe.player.datasource.SabrPolicyRuntime; import org.schabi.newpipe.player.datasource.SabrSegmentDataSource; import org.schabi.newpipe.player.helper.LegacySubtitleRenderersFactory; import org.schabi.newpipe.player.helper.LoadController; @@ -79,6 +93,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -92,6 +107,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.zip.GZIPOutputStream; @@ -125,12 +141,66 @@ public final class SabrPlaybackSmokeTest { private static final long DEFAULT_POST_REWIND_PLAYBACK_MS = 30_000; private static final long PREPARE_TIMEOUT_SECONDS = 150; private static final long PLAYBACK_TIMEOUT_SECONDS = 75; + private static final String ONLINE_PROFILE_CHANNEL = "online-smoke"; + private static final String ONLINE_FALLBACK_CHANNEL = "online-fallback"; + private static final String PARTIAL_PROFILE_CHANNEL = "partial-profile"; + private static final long ONLINE_PROFILE_REVISION = 1; @Test public void extractorToMedia3PlaysAndSeeks() throws Exception { runSmokeCase(SmokeCase.playback()); } + @Test + public void signedCompatibilityProfilePlaysAndSeeks() throws Exception { + final Context context = InstrumentationRegistry.getInstrumentation() + .getTargetContext().getApplicationContext(); + final Ed25519PrivateKeyParameters key = + new Ed25519PrivateKeyParameters(new SecureRandom()); + final String publicKey = "online=" + java.util.Base64.getEncoder().encodeToString( + key.generatePublicKey().getEncoded()); + final long now = System.currentTimeMillis(); + clearProfileChannel(context, ONLINE_PROFILE_CHANNEL); + SabrPolicyRuntime.initialize(context, publicKey, ONLINE_PROFILE_CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + ONLINE_PROFILE_REVISION, now, "online", key, true), now); + SabrPolicyRuntime.setBenchmarkPolicyMode(SabrPolicyRuntime.BenchmarkPolicyMode.PROFILE); + try { + assertEquals(ONLINE_PROFILE_REVISION, SabrPolicyRuntime.currentRevision()); + runSmokeCase(SmokeCase.playback()); + assertEquals("The signed profile was disabled and playback fell back to builtin", + ONLINE_PROFILE_REVISION, SabrPolicyRuntime.currentRevision()); + } finally { + SabrPolicyRuntime.setBenchmarkPolicyMode(SabrPolicyRuntime.BenchmarkPolicyMode.AUTO); + SabrPolicyRuntime.initialize(context, "", ONLINE_PROFILE_CHANNEL, 0); + clearProfileChannel(context, ONLINE_PROFILE_CHANNEL); + } + } + + @Test + public void signedBrokenProfileFallsBackToBuiltin() throws Exception { + final Context context = InstrumentationRegistry.getInstrumentation() + .getTargetContext().getApplicationContext(); + final Ed25519PrivateKeyParameters key = + new Ed25519PrivateKeyParameters(new SecureRandom()); + final String publicKey = "online=" + java.util.Base64.getEncoder().encodeToString( + key.generatePublicKey().getEncoded()); + final long now = System.currentTimeMillis(); + clearProfileChannel(context, ONLINE_FALLBACK_CHANNEL); + SabrPolicyRuntime.initialize(context, publicKey, ONLINE_FALLBACK_CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signedWithFailingMediaMapping( + ONLINE_PROFILE_REVISION, now, "online", key), now); + try { + assertEquals(ONLINE_PROFILE_REVISION, SabrPolicyRuntime.currentRevision()); + runSmokeCase(SmokeCase.playback()); + assertEquals("The broken signed profile remained active", + -1, SabrPolicyRuntime.currentRevision()); + } finally { + SabrPolicyRuntime.initialize(context, "", ONLINE_FALLBACK_CHANNEL, 0); + clearProfileChannel(context, ONLINE_FALLBACK_CHANNEL); + } + } + @Test public void recoversMissingInitializationFromPump() throws Exception { runSmokeCase(SmokeCase.missingInitialization()); @@ -161,6 +231,84 @@ public void seekIntoSponsorBlockSkipsToDuration() throws Exception { runSmokeCase(SmokeCase.sponsorBlockSeek()); } + @Test + public void compatibilityProfileReplaysShiftedUmpSchema() throws Exception { + final SabrSessionPolicyHost host = shiftedProfileHost(); + try (SabrSmokeHarness harness = SabrSmokeHarness.create(host)) { + harness.downloader.enqueue(new UmpFixture() + .profiledSegment(2, SMOKE_VIDEO_ITAG, 1, 0, 5_000) + .bytes()); + + harness.openMediaSegment( + SabrSegmentRequest.media(harness.videoFormat, 1), 5_000); + + assertEquals(1, harness.downloader.requestBodies.size()); + final byte[] request = harness.downloader.requestBodies.get(0); + assertTrue("Profile request did not use shifted field 101", request.length > 1 + && (request[0] & 0xff) == 0xaa && (request[1] & 0xff) == 0x06); + assertTrue("Profile policy transcript did not observe the shifted request", + host.snapshotTranscript().stream() + .anyMatch(entry -> entry.contains("event=request"))); + } + } + + @Test + public void compatibilityProfileReplaysSabrDownloadPump() throws Exception { + final SabrSessionPolicyHost host = shiftedProfileHost(); + try (SabrSmokeHarness harness = SabrSmokeHarness.create(host)) { + harness.downloader.enqueue(new UmpFixture() + .profiledSegment(2, SMOKE_VIDEO_ITAG, 1, 0, 5_000) + .bytes()); + harness.downloader.enqueue(new UmpFixture() + .profiledSegment(3, SMOKE_VIDEO_ITAG, 2, 5_000, 5_000) + .bytes()); + + assertEquals(1, harness.holder.session.pumpOnceStreaming( + new Localization("en", "US"))); + assertEquals(1, harness.holder.session.pumpOnceStreaming( + new Localization("en", "US"))); + + assertNotNull(harness.holder.session.getCachedSegment( + SabrSegmentRequest.media(harness.videoFormat, 1))); + assertNotNull(harness.holder.session.getCachedSegment( + SabrSegmentRequest.media(harness.videoFormat, 2))); + assertEquals(2, harness.downloader.requestBodies.size()); + final byte[] following = harness.downloader.requestBodies.get(1); + assertTrue("Download pump did not use shifted following field 201", + following.length > 1 && (following[0] & 0xff) == 0xca + && (following[1] & 0xff) == 0x0c); + } + } + + @Test + public void webOnlyProfileLeavesMwebReplayOnBuiltinPolicy() throws Exception { + final Context context = InstrumentationRegistry.getInstrumentation() + .getTargetContext().getApplicationContext(); + final Ed25519PrivateKeyParameters key = + new Ed25519PrivateKeyParameters(new SecureRandom()); + final String publicKey = "partial=" + java.util.Base64.getEncoder().encodeToString( + key.generatePublicKey().getEncoded()); + final long now = System.currentTimeMillis(); + clearProfileChannel(context, PARTIAL_PROFILE_CHANNEL); + SabrPolicyRuntime.initialize(context, publicKey, PARTIAL_PROFILE_CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + ONLINE_PROFILE_REVISION, now, "partial", key, false), now); + try (SabrSmokeHarness harness = SabrSmokeHarness.create( + SabrPolicyRuntime.createSessionHost())) { + harness.downloader.enqueue(new UmpFixture() + .segment(1, SMOKE_VIDEO_ITAG, 1, 0, 5_000) + .bytes()); + + harness.openMediaSegment( + SabrSegmentRequest.media(harness.videoFormat, 1), 5_000); + + assertEquals(ONLINE_PROFILE_REVISION, SabrPolicyRuntime.currentRevision()); + } finally { + SabrPolicyRuntime.initialize(context, "", PARTIAL_PROFILE_CHANNEL, 0); + clearProfileChannel(context, PARTIAL_PROFILE_CHANNEL); + } + } + @Test public void demandRepositionsAfterNonTargetMediaBatch() throws Exception { try (SabrSmokeHarness harness = SabrSmokeHarness.create()) { @@ -1597,11 +1745,16 @@ private static void runSmokeCase(final SmokeCase smokeCase) throws Exception { final CountDownLatch ready = new CountDownLatch(1); final CountDownLatch firstVideoFrame = new CountDownLatch(1); final CountDownLatch audioStarted = new CountDownLatch(1); + final CountDownLatch videoDecoderStarted = new CountDownLatch(1); + final CountDownLatch audioDecoderStarted = new CountDownLatch(1); final CountDownLatch ended = new CountDownLatch(1); final AtomicReference seekProcessed = new AtomicReference<>(new CountDownLatch(1)); final AtomicReference playerError = new AtomicReference<>(); final AtomicReference seekPositionReported = new AtomicReference<>(); + final AtomicReference videoDecoderName = new AtomicReference<>(); + final AtomicReference audioDecoderName = new AtomicReference<>(); + final AtomicInteger droppedVideoFrames = new AtomicInteger(); final AtomicBoolean endedEarly = new AtomicBoolean(); final AtomicReference playerRef = new AtomicReference<>(); final AtomicReference textureRef = new AtomicReference<>(); @@ -1647,6 +1800,31 @@ public void onPositionDiscontinuity(final Player.PositionInfo oldPosition, } }); player.addAnalyticsListener(new AnalyticsListener() { + @Override + public void onVideoDecoderInitialized(final EventTime eventTime, + final String decoderName, + final long initializedTimestampMs, + final long initializationDurationMs) { + videoDecoderName.set(decoderName); + videoDecoderStarted.countDown(); + } + + @Override + public void onAudioDecoderInitialized(final EventTime eventTime, + final String decoderName, + final long initializedTimestampMs, + final long initializationDurationMs) { + audioDecoderName.set(decoderName); + audioDecoderStarted.countDown(); + } + + @Override + public void onDroppedVideoFrames(final EventTime eventTime, + final int droppedFrames, + final long elapsedMs) { + droppedVideoFrames.addAndGet(droppedFrames); + } + @Override public void onRenderedFirstFrame(final EventTime eventTime, final Object output, @@ -1683,6 +1861,21 @@ public void onAudioPositionAdvancing(final EventTime eventTime, assertTrue("Audio output did not start", audioStarted.await(PLAYBACK_TIMEOUT_SECONDS, TimeUnit.SECONDS)); assertNull("Player failed while starting audio", playerError.get()); + assertTrue("Media3 did not report the video decoder", + videoDecoderStarted.await(5, TimeUnit.SECONDS)); + assertTrue("Media3 did not report the audio decoder", + audioDecoderStarted.await(5, TimeUnit.SECONDS)); + final boolean hardwareVideoDecoder = isHardwareDecoder(videoDecoderName.get()); + System.out.println("SABR_DECODERS client=" + client + + " video=" + videoDecoderName.get() + + " hardwareVideo=" + hardwareVideoDecoder + + " audio=" + audioDecoderName.get() + + " droppedVideoFrames=" + droppedVideoFrames.get()); + if (Boolean.parseBoolean(arguments.getString( + "requireHardwareVideoDecoder", "false"))) { + assertTrue("Expected a hardware video decoder, got " + videoDecoderName.get(), + hardwareVideoDecoder); + } if (injectedHolder != null) { verifyInitializationRecovery(injectedHolder); } @@ -1696,7 +1889,8 @@ public void onAudioPositionAdvancing(final EventTime eventTime, "linearPlaybackMs", String.valueOf(DEFAULT_LINEAR_PLAYBACK_MS))); final long initialPositionMs = positionOf(playerRef.get()); waitForPosition(playerRef.get(), initialPositionMs + linearPlaybackMs, - PLAYBACK_TIMEOUT_SECONDS); + Math.max(PLAYBACK_TIMEOUT_SECONDS, + TimeUnit.MILLISECONDS.toSeconds(linearPlaybackMs) + 30)); assertNull("Player failed during linear playback", playerError.get()); final long postSeekPlaybackMs = Long.parseLong(arguments.getString( @@ -1758,17 +1952,39 @@ public void onAudioPositionAdvancing(final EventTime eventTime, } assertTrue("Content ended before playback and seek checks completed", !endedEarly.get() || durationMs < 8_000); + final SabrSessionStore.Holder diagnosticHolder = getHolder(info.getId()); + System.out.println("SABR_SESSION client=" + client + + " audioItag=" + diagnosticHolder.audioFormat.getItag() + + " videoItag=" + diagnosticHolder.videoFormat.getItag() + + " requestNumber=" + diagnosticHolder.session.getRequestNumber() + + " playerPositionMs=" + positionOf(playerRef.get()) + + " bufferedEdgeMs=" + diagnosticHolder.session.getStreamState() + .getMinBufferedEndMs() + + " cachedBytes=" + diagnosticHolder.session.getCachedBytes() + + " peakCachedBytes=" + diagnosticHolder.session.getPeakCachedBytes() + + " policyEvents=" + diagnosticHolder.session + .getSessionPolicyTranscript().size()); final String maxCachedBytesArgument = arguments.getString("maxCachedBytes"); if (maxCachedBytesArgument != null) { final long maximum = Long.parseLong(maxCachedBytesArgument); - final SabrSessionStore.Holder holder = getHolder(info.getId()); + final SabrSessionStore.Holder holder = diagnosticHolder; + final long retained = holder.session.getCachedBytes(); final long observed = holder.session.getPeakCachedBytes(); + final long maximumResponse = holder.session.getMaxResponseBytes(); + final long transientMaximum = maximum + maximumResponse; System.out.println("SABR_MEMORY height=" + holder.videoFormat.getHeight() + " itag=" + holder.videoFormat.getItag() + + " retainedCachedBytes=" + retained + " peakCachedBytes=" + observed - + " maxCachedBytes=" + maximum); + + " maxCachedBytes=" + maximum + + " maxResponseBytes=" + maximumResponse + + " maxSegmentBytes=" + holder.session.getMaxSegmentBytes() + + " transientMaxCachedBytes=" + transientMaximum); + assertTrue("SABR retained cache exceeded bound: observed=" + retained + + " maximum=" + maximum, retained <= maximum); assertTrue("SABR cache exceeded bound: observed=" + observed - + " maximum=" + maximum, observed <= maximum); + + " transientMaximum=" + transientMaximum, + observed <= transientMaximum); } } finally { InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { @@ -1804,6 +2020,11 @@ private static String readTextFile(final File file) throws IOException { } } + private static void clearProfileChannel(final Context context, final String channel) { + context.getFileStreamPath("sabr-compatibility-" + channel + ".bin").delete(); + context.getFileStreamPath("sabr-compatibility-" + channel + ".rev").delete(); + } + private static boolean isSabr(final VideoStream stream) { return stream.getDeliveryMethod() == DeliveryMethod.SABR; } @@ -2115,6 +2336,22 @@ private static long seekPositionMs(final Bundle arguments, final long durationMs return seekPositionMs; } + private static boolean isHardwareDecoder(final String decoderName) { + if (decoderName == null) { + return false; + } + for (final MediaCodecInfo codec : new MediaCodecList( + MediaCodecList.ALL_CODECS).getCodecInfos()) { + if (decoderName.equals(codec.getName())) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + ? codec.isHardwareAccelerated() + : !decoderName.startsWith("OMX.google.") + && !decoderName.startsWith("c2.android."); + } + } + return false; + } + private static void waitForPosition(final ExoPlayer player, final long targetMs, final long timeoutSeconds) throws Exception { final long deadlineNs = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); @@ -2341,6 +2578,51 @@ private static YoutubeSabrInfo smokeInfo(final YoutubeSabrFormat audioFormat, base64(new byte[]{1, 2, 3, 4}), Arrays.asList(audioFormat, videoFormat)); } + private static SabrSessionPolicyHost shiftedProfileHost() { + final List initialRequest = Arrays.asList( + profileRequestField(101, SabrProfileRequestField.Source.CLIENT_ABR_STATE), + profileRequestField(102, SabrProfileRequestField.Source.USTREAMER_CONFIG), + profileRequestField(103, SabrProfileRequestField.Source.CLIENT_CONTEXT)); + final List followingRequest = Arrays.asList( + profileRequestField(201, SabrProfileRequestField.Source.CLIENT_ABR_STATE), + profileRequestField(202, SabrProfileRequestField.Source.USTREAMER_CONFIG), + profileRequestField(203, SabrProfileRequestField.Source.CLIENT_CONTEXT)); + final List mappings = Arrays.asList( + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_ID, 17), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_ITAG, 18), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_COMPRESSION, 19), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_IS_INIT, 20), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_SEQUENCE, 21), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_START_MS, 22), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_DURATION_MS, 23), + profileMediaMapping(SabrProfileResponseMapping.Target.MEDIA_HEADER_CONTENT_LENGTH, + 24)); + final SabrCompatibilityProfileClient client = new SabrCompatibilityProfileClient( + YoutubeSabrClientProfile.MWEB, + new SabrCompatibilityProfileClient.MediaParts(70, 71, 72), + initialRequest, followingRequest, mappings, + new SabrProfileRecovery(3, 30_000, 30_000, 0), + Collections.singletonList(new SabrProfileRule(Collections.emptyList(), + Collections.singletonList(SabrProfileRule.Action.CONTINUE)))); + return new SabrSessionPolicyHost(new ProfiledSabrSessionPolicy(client), + new SabrSessionPolicyTranscript(32)); + } + + private static SabrProfileRequestField profileRequestField( + final int field, final SabrProfileRequestField.Source source) { + return new SabrProfileRequestField(field, SabrProfileRequestField.WireType.BYTES, + source, true); + } + + private static SabrProfileResponseMapping profileMediaMapping( + final SabrProfileResponseMapping.Target target, final int field) { + final SabrProfileResponseMapping.WireType wireType = + target == SabrProfileResponseMapping.Target.MEDIA_HEADER_IS_INIT + ? SabrProfileResponseMapping.WireType.BOOL + : SabrProfileResponseMapping.WireType.VARINT; + return new SabrProfileResponseMapping(70, target, new int[]{field}, wireType, true); + } + private static byte[] nextRequestPolicy(final int backoffMs) { return nextRequestPolicy(backoffMs, null, null); } @@ -2680,12 +2962,26 @@ private SabrSmokeHarness(final Downloader previousDownloader, private static SabrSmokeHarness create() throws Exception { return create(smokeFormat(SMOKE_AUDIO_ITAG, true), - smokeFormat(SMOKE_VIDEO_ITAG, false)); + smokeFormat(SMOKE_VIDEO_ITAG, false), + SabrPolicyRuntime.createSessionHost()); + } + + private static SabrSmokeHarness create(final SabrSessionPolicyHost policyHost) + throws Exception { + return create(smokeFormat(SMOKE_AUDIO_ITAG, true), + smokeFormat(SMOKE_VIDEO_ITAG, false), policyHost); } private static SabrSmokeHarness create(final YoutubeSabrFormat audioFormat, final YoutubeSabrFormat videoFormat) throws Exception { + return create(audioFormat, videoFormat, SabrPolicyRuntime.createSessionHost()); + } + + private static SabrSmokeHarness create(final YoutubeSabrFormat audioFormat, + final YoutubeSabrFormat videoFormat, + final SabrSessionPolicyHost policyHost) + throws Exception { final Downloader previousDownloader = NewPipe.getDownloader(); final Localization previousLocalization = NewPipe.getPreferredLocalization(); final ContentCountry previousContentCountry = NewPipe.getPreferredContentCountry(); @@ -2696,7 +2992,8 @@ private static SabrSmokeHarness create(final YoutubeSabrFormat audioFormat, InstrumentationRegistry.getInstrumentation().getTargetContext().getCacheDir(), "sabr-smoke-" + System.nanoTime()); final YoutubeSabrSession session = - new YoutubeSabrSession(info, audioFormat, videoFormat, null, spoolDirectory); + new YoutubeSabrSession(info, audioFormat, videoFormat, null, spoolDirectory, + policyHost); session.getStreamState().setVideoOnlyRequestMode(); final Constructor constructor = SabrSessionStore.Holder.class.getDeclaredConstructor(Context.class, @@ -3256,6 +3553,26 @@ private UmpFixture segment(final int headerId, .mediaEnd(headerId); } + private UmpFixture profiledSegment(final int headerId, + final int itag, + final int sequence, + final long startMs, + final long durationMs) { + final byte[] header = proto() + .u64(17, headerId) + .u64(18, itag) + .u64(19, 0) + .u64(20, 0) + .u64(21, sequence) + .u64(22, Math.max(0, startMs)) + .u64(23, Math.max(0, durationMs)) + .u64(24, 4) + .bytes(); + final byte[] media = new byte[]{(byte) headerId, 10, 11, 12, 13}; + return part(70, header).part(71, media) + .part(72, new byte[]{(byte) headerId}); + } + private UmpFixture mediaHeader(final int headerId, final int itag, final int sequence) { return mediaHeader(headerId, itag, sequence, (sequence - 1) * 5_000L, 5_000L); } diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubeClickBenchmarkSetupTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubeClickBenchmarkSetupTest.java index 4fba2e946..8e33e32d2 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubeClickBenchmarkSetupTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubeClickBenchmarkSetupTest.java @@ -28,16 +28,18 @@ public void configure() throws Exception { .getTargetContext().getApplicationContext(); final Bundle arguments = InstrumentationRegistry.getArguments(); final String client = arguments.getString("youtubeClient", "mweb"); - final String cookieFile = arguments.getString("cookieFile", ""); final SharedPreferences.Editor editor = PreferenceManager .getDefaultSharedPreferences(context) .edit() .putString(context.getString(R.string.youtube_player_client_key), client); - if (cookieFile.isEmpty()) { - editor.remove(context.getString(R.string.youtube_cookies_key)); - } else { - editor.putString(context.getString(R.string.youtube_cookies_key), - readTextFile(new File(cookieFile)).trim()); + if (arguments.containsKey("cookieFile")) { + final String cookieFile = arguments.getString("cookieFile", ""); + if (cookieFile.isEmpty()) { + editor.remove(context.getString(R.string.youtube_cookies_key)); + } else { + editor.putString(context.getString(R.string.youtube_cookies_key), + readTextFile(new File(cookieFile)).trim()); + } } assertTrue("Could not persist click benchmark inputs", editor.commit()); } diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java index 11e8a70dd..54e91787b 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/YoutubePlaybackBenchmarkTest.java @@ -148,8 +148,8 @@ public void compareSabrHlsAndGeneratedDash() throws Exception { new Path("sabr", "mweb", DeliveryMethod.SABR), new Path("sabr_builtin", "mweb", DeliveryMethod.SABR, SabrPolicyRuntime.BenchmarkPolicyMode.BUILTIN), - new Path("sabr_cloud", "mweb", DeliveryMethod.SABR, - SabrPolicyRuntime.BenchmarkPolicyMode.CLOUD), + new Path("sabr_profile", "mweb", DeliveryMethod.SABR, + SabrPolicyRuntime.BenchmarkPolicyMode.PROFILE), new Path("hls", "web_safari", DeliveryMethod.HLS), new Path("tv_downgraded_generated_dash", "tv_downgraded", DeliveryMethod.PROGRESSIVE_HTTP), diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/QuickJsSabrSessionPolicyTest.kt b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/QuickJsSabrSessionPolicyTest.kt deleted file mode 100644 index d5b384290..000000000 --- a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/QuickJsSabrSessionPolicyTest.kt +++ /dev/null @@ -1,185 +0,0 @@ -package org.schabi.newpipe.player.datasource - -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test -import java.util.Collections -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrDecodedResponse -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicy -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicy - -class QuickJsSabrSessionPolicyTest { - @Test - fun changesRequestsBackoffAndMediaProtocol() { - val policy = QuickJsSabrSessionPolicy(script(POLICY)) - val state = SabrSessionPolicy.State(1, 0, 0, 0) - - val request = policy.evaluate( - state, - SabrSessionPolicy.RequestEvent(0, 0, 0, 7, byteArrayOf(1, 2, 3)), - ) - assertArrayEquals(byteArrayOf(1, 7), request.requestBody) - - val response = policy.evaluate( - state, - SabrSessionPolicy.ControlResponseEvent( - 0, - true, - SabrSessionPolicy.ControlMode.PUMP, - SabrDecodedResponse(), - ), - ) - assertEquals(SabrSessionPolicy.ActionType.SLEEP_BACKOFF, response.actions[0].type) - assertEquals(4_321, response.controlDecision!!.backoffTimeMs) - assertEquals(2, response.nextState.redirectCount) - assertEquals(null, response.statePatch) - - val header = policy.mediaProtocol.decodeHeader( - byteArrayOf(0x08, 0x04, 0x18, 0xfb.toByte(), 0x01, 0x48, 0x08), - ) - assertEquals(4, header.headerId) - assertEquals(251, header.itag) - assertEquals(8, header.sequenceNumber) - policy.close() - } - - @Test - fun compiledPoliciesKeepSessionStateIndependent() { - val script = script(POLICY) - val first = QuickJsSabrSessionPolicy(script) - val second = QuickJsSabrSessionPolicy(script) - val state = SabrSessionPolicy.State(1, 0, 0, 0) - val event = SabrSessionPolicy.RequestEvent(0, 0, 0, 3, byteArrayOf(1)) - - assertArrayEquals(byteArrayOf(1, 3), first.evaluate(state, event).requestBody) - assertArrayEquals(byteArrayOf(2, 3), first.evaluate(state, event).requestBody) - assertArrayEquals(byteArrayOf(1, 3), second.evaluate(state, event).requestBody) - first.close() - second.close() - } - - @Test - fun supportsModernJavaScriptSyntax() { - val modern = POLICY.replace( - "headerType: 120", - "headerType: ({ value: 120 })?.value", - ) - - QuickJsSabrSessionPolicy(script(modern)).use { - assertEquals(120, it.mediaProtocol.headerPartType) - } - } - - @Test - fun parsesNormalizedResponseStatePatch() { - val source = POLICY.replace( - "actions:['SLEEP_BACKOFF'],backoffMs:4321,", - "actions:['APPLY_RESPONSE_STATE','SLEEP_BACKOFF'],backoffMs:4321," + - "statePatch:{nextRequest:{targetAudioReadaheadMs:9000}," + - "live:[{headSequenceNumber:77}]," + - "formats:[{itag:251,endSegmentNumber:99}]," + - "contexts:[{type:4,scope:1,value:'AQ==',sendByDefault:true," + - "writePolicy:1}],contextPolicy:{start:[4],stop:[],discard:[]}},", - ) - val policy = QuickJsSabrSessionPolicy(script(source)) - val result = policy.evaluate( - SabrSessionPolicy.State(1, 0, 0, 0), - SabrSessionPolicy.ControlResponseEvent( - 0, - true, - SabrSessionPolicy.ControlMode.PUMP, - SabrDecodedResponse(), - ), - ) - - assertEquals(SabrSessionPolicy.ActionType.APPLY_RESPONSE_STATE, result.actions[0].type) - requireNotNull(result.statePatch) - policy.close() - } - - @Test - fun exposesDemandRouteAndReturnedSegmentIdentities() { - val policy = QuickJsSabrSessionPolicy(script(DEMAND_POLICY)) - val state = SabrSessionPolicy.DemandState(1_000, 1_250, 1, 0) - - assertEquals( - SabrSessionPolicy.DemandRoute.RECOVER_MISSING, - policy.evaluateDemandRoute( - SabrSessionPolicy.DemandRouteEvent(251, 7, 30_000, 25_000, state), - ), - ) - val decision = policy.evaluateDemandResponse( - SabrSessionPolicy.DemandResponseEvent( - 251, - 7, - 30_000, - 25_000, - state, - 1, - 0, - Collections.singletonList( - SabrSessionPolicy.DemandReturnedSegment(398, 9, 30_000, 5_000), - ), - false, - ), - ) - - assertEquals(SabrSessionPolicy.DemandOutcome.CONTINUE, decision.outcome) - assertEquals(321, decision.retryDelayMs) - policy.close() - } - - @Test - fun rejectsMissingPolicyFactory() { - assertThrows(SabrProtocolException::class.java) { - QuickJsSabrSessionPolicy(script("var unrelated = true;")) - } - } - - @Test - fun reusesRuntimeAcrossManySessions() { - val script = script(POLICY) - repeat(200) { - QuickJsSabrSessionPolicy(script).close() - } - } - - private fun script(source: String): SabrScriptPolicy { - val now = System.currentTimeMillis() - return SabrScriptPolicy(10_000, now - 1_000, now + 60_000, source) - } - - private companion object { - const val POLICY = - "function createSabrPolicy(sabr){var count=0;return{" + - "describe:function(){return{media:{headerType: 120,mediaType:121,endType:122," + - "headerDecoder:'js'}}}," + - "initialRequest:function(e){return{body:sabr.base64.encode([++count,e.bufferedRangeCount])}}," + - "followUpRequest:function(e){return{body:sabr.base64.encode([++count,e.bufferedRangeCount])}}," + - "response:function(e){return{actions:['SLEEP_BACKOFF'],backoffMs:4321," + - "state:{redirectCount:2}}}," + - "mediaHeader:function(e){var f=sabr.proto.decode(sabr.base64.decode(e.data)),r={};" + - "for(var i=0;ie.recoveryCount" + - "?'RECOVER_MISSING':'STREAM'}}," + - "demandResponse:function(e){if(e.targetItag!==251||" + - "e.targetSequenceNumber!==7||e.elapsedMs!==250||" + - "e.returnedSegments.length!==1||e.returnedSegments[0].itag!==398||" + - "e.returnedSegments[0].sequenceNumber!==9){throw new Error('bad demand event')}" + - "return{outcome:'CONTINUE',retryDelayMs:321}}" + - "}}" - } -} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntimeTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntimeTest.java index 5c5d306e8..923338166 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntimeTest.java +++ b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrPolicyRuntimeTest.java @@ -5,171 +5,275 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; -import org.junit.Test; +import android.content.Context; +import android.util.Base64; + import androidx.test.core.app.ApplicationProvider; -import androidx.test.platform.app.InstrumentationRegistry; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrScriptPolicyDocument; -import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyHost; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileDocument; import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicy; -import org.schabi.newpipe.extractor.services.youtube.sabr.BuiltinSabrSessionPolicy; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicyHost; -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.Field; +import java.io.DataOutputStream; +import java.io.FileOutputStream; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Arrays; +import java.util.Map; public final class SabrPolicyRuntimeTest { + private static final String CHANNEL = "test-cache"; + private static final String STABLE_CHANNEL = "test-stable"; + private static final String BETA_CHANNEL = "test-beta"; + private static final String CACHE_FILE = "sabr-compatibility-" + CHANNEL + ".bin"; + private static final String REVISION_FILE = "sabr-compatibility-" + CHANNEL + ".rev"; + private Context context; + + @Before + public void setUp() { + context = ApplicationProvider.getApplicationContext(); + clearFiles(); + } + + @After + public void tearDown() { + SabrPolicyRuntime.setBenchmarkPolicyMode(SabrPolicyRuntime.BenchmarkPolicyMode.AUTO); + SabrPolicyRuntime.initialize(context, "", CHANNEL, 0); + clearFiles(); + } + @Test - public void installEquivalentBuiltinPolicyForBenchmark() throws Exception { - final String privateKeyBase64 = InstrumentationRegistry.getArguments() - .getString("sabrPolicyPrivateKeyBase64"); - assertTrue("Missing benchmark SABR private key", - privateKeyBase64 != null && !privateKeyBase64.isEmpty()); - assertTrue("Benchmark build has no SABR public key", - !BuildConfig.SABR_POLICY_PUBLIC_KEY_BASE64.isEmpty()); - final android.content.Context context = ApplicationProvider.getApplicationContext(); - context.getFileStreamPath("sabr-cloud-policy.bin").delete(); - context.getFileStreamPath("sabr-cloud-policy.rev").delete(); - final InputStream sourceInput = InstrumentationRegistry.getInstrumentation().getContext() - .getAssets().open("equivalent-builtin-sabr-policy.js"); - final byte[] sourceBytes = new byte[sourceInput.available()]; - int offset = 0; - while (offset < sourceBytes.length) { - final int read = sourceInput.read(sourceBytes, offset, sourceBytes.length - offset); - if (read < 0) break; - offset += read; - } - sourceInput.close(); - assertEquals(sourceBytes.length, offset); + public void publicKeyListSupportsRotation() { + final byte[] first = new byte[32]; + final byte[] second = new byte[32]; + Arrays.fill(second, (byte) 7); + final String encoded = "old=" + Base64.encodeToString(first, Base64.NO_WRAP) + + ",current=" + Base64.encodeToString(second, Base64.NO_WRAP); + + final Map parsed = SabrPolicyRuntime.parsePublicKeys(encoded); + + assertArrayEquals(first, parsed.get("old")); + assertArrayEquals(second, parsed.get("current")); + assertThrows(IllegalArgumentException.class, + () -> SabrPolicyRuntime.parsePublicKeys("broken")); + assertThrows(IllegalArgumentException.class, + () -> SabrPolicyRuntime.parsePublicKeys(encoded + ",old=" + + Base64.encodeToString(first, Base64.NO_WRAP))); + } + + @Test + public void invalidReinitializationClearsThePreviousRegistry() throws Exception { + final Ed25519PrivateKeyParameters key = key(); final long now = System.currentTimeMillis(); - final SabrScriptPolicy policy = new SabrScriptPolicy(1_000, now - 60_000, - now + 24 * 60 * 60 * 1000L, - new String(sourceBytes, StandardCharsets.UTF_8)); - final byte[] payload = policy.serialize(); - final Ed25519PrivateKeyParameters privateKey = new Ed25519PrivateKeyParameters( - android.util.Base64.decode(privateKeyBase64, android.util.Base64.DEFAULT)); - final Ed25519Signer signer = new Ed25519Signer(); - signer.init(true, privateKey); - signer.update(payload, 0, payload.length); - SabrPolicyRuntime.initialize(context, BuildConfig.SABR_POLICY_PUBLIC_KEY_BASE64, 0); - SabrPolicyRuntime.install(payload, signer.generateSignature(), now); + SabrPolicyRuntime.initialize(context, encodedKey("current", key), CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 3, now, "current", key, true), now); + assertThrows(IllegalArgumentException.class, + () -> SabrPolicyRuntime.initialize(context, "broken", CHANNEL, 0)); + assertEquals(-1, SabrPolicyRuntime.currentRevision()); + } + + @Test + public void signedProfilePersistsOfflineAndRejectsRollback() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); + final long now = System.currentTimeMillis(); + final byte[] revisionFive = SabrProfileTestDocuments.signed( + 5, now, "current", key, true); + + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + SabrPolicyRuntime.installDocument(revisionFive, now); + SabrPolicyRuntime.setBenchmarkPolicyMode(SabrPolicyRuntime.BenchmarkPolicyMode.PROFILE); + SabrPolicyRuntime.createSessionHost().close(); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + + assertEquals(5, SabrPolicyRuntime.currentRevision()); + assertTrue(context.getFileStreamPath(CACHE_FILE).isFile()); + assertTrue(context.getFileStreamPath(REVISION_FILE).isFile()); + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + SabrProfileTestDocuments.signed(4, now, "current", key, true), now)); + final byte[] tampered = new String(revisionFive, StandardCharsets.UTF_8) + .replace("\"maximumOmissions\":3", "\"maximumOmissions\":4") + .getBytes(StandardCharsets.UTF_8); + assertThrows(IllegalArgumentException.class, + () -> SabrPolicyRuntime.installDocument(tampered, now)); + assertEquals(5, SabrPolicyRuntime.currentRevision()); + } + + @Test + public void keyRotationAcceptsDocumentsFromBothEmbeddedKeys() throws Exception { + final Ed25519PrivateKeyParameters oldKey = key(); + final Ed25519PrivateKeyParameters currentKey = key(); + final String keys = encodedKey("old", oldKey) + "," + + encodedKey("current", currentKey); + final long now = System.currentTimeMillis(); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 10, now, "old", oldKey, true), now); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 11, now, "current", currentKey, true), now); + + assertEquals(11, SabrPolicyRuntime.currentRevision()); + } + + @Test + public void profileFailurePromotesPreviousProfileAndPersistsFallback() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); + final long now = System.currentTimeMillis(); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 7, now, "current", key, true), now); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 8, now, "current", key, true), now); final SabrSessionPolicyHost host = SabrPolicyRuntime.createSessionHost(); - assertEquals(20, host.getMediaProtocol().getHeaderPartType()); + final byte[] bundledRequest = new byte[]{4, 5, 6}; + + assertArrayEquals(bundledRequest, host.evaluate(new SabrSessionPolicy.State(0, 0, 0, 0), + new SabrSessionPolicy.RequestEvent(0, 0, -1, 0, bundledRequest)) + .getRequestBody()); + assertEquals(7, SabrPolicyRuntime.currentRevision()); host.close(); + + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + assertEquals(7, SabrPolicyRuntime.currentRevision()); + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + SabrProfileTestDocuments.signed(8, now, "current", key, true), now)); + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + SabrProfileTestDocuments.signed(6, now, "current", key, true), now)); + } + + @Test + public void corruptedCacheDoesNotEraseRollbackFloor() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); + final long now = System.currentTimeMillis(); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 20, now, "current", key, true), now); + try (FileOutputStream output = context.openFileOutput(CACHE_FILE, Context.MODE_PRIVATE)) { + output.write(new byte[]{1, 2, 3}); + } + + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + + assertEquals(-1, SabrPolicyRuntime.currentRevision()); + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + SabrProfileTestDocuments.signed(19, now, "current", key, true), now)); } @Test - public void envelopeRoundTripsPayloadAndSignature() throws Exception { - final byte[] payload = new byte[]{1, 2, 3, 4}; - final byte[] signature = new byte[]{5, 6, 7}; + public void oldSignedCacheNeedsAnExplicitCircuitBreakerMarker() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); + final long now = System.currentTimeMillis(); + final byte[] oldDocument = SabrProfileTestDocuments.signed( + 49, now, "current", key, true); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 50, now, "current", key, true), now); + new SabrProfileCache(context, CHANNEL).writeState( + new SabrProfileCache.State(oldDocument, null, 0)); - final SabrPolicyRuntime.Envelope decoded = SabrPolicyRuntime.decodeEnvelope( - SabrPolicyRuntime.encodeEnvelope(payload, signature)); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); - assertArrayEquals(payload, decoded.payload); - assertArrayEquals(signature, decoded.signature); + assertEquals(-1, SabrPolicyRuntime.currentRevision()); + assertThrows(IllegalArgumentException.class, + () -> SabrPolicyRuntime.installDocument(oldDocument, now)); } @Test - public void envelopeRejectsTrailingAndUnboundedData() { - final byte[] valid = SabrPolicyRuntime.encodeEnvelope( - new byte[]{1}, new byte[]{2}); - assertThrows(IOException.class, () -> SabrPolicyRuntime.decodeEnvelope( - Arrays.copyOf(valid, valid.length + 1))); - assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.encodeEnvelope( - new byte[512 * 1024 + 1], new byte[]{1})); - assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.encodeEnvelope( - new byte[]{1}, new byte[0])); + public void legacyNormalCacheMigratesWithoutLosingTheActiveProfile() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); + final long now = System.currentTimeMillis(); + final byte[] document = SabrProfileTestDocuments.signed( + 60, now, "current", key, true); + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + SabrPolicyRuntime.installDocument(document, now); + writeLegacyState(document); + + SabrPolicyRuntime.initialize(context, keys, CHANNEL, 0); + + assertEquals(60, SabrPolicyRuntime.currentRevision()); } @Test - public void signedPolicyPersistsAndRestoresOnAndroid() throws Exception { - final android.content.Context context = ApplicationProvider.getApplicationContext(); - context.getFileStreamPath("sabr-cloud-policy.bin").delete(); - context.getFileStreamPath("sabr-cloud-policy.rev").delete(); - final Ed25519PrivateKeyParameters privateKey = - new Ed25519PrivateKeyParameters(new SecureRandom()); + public void executableExpiredAndOversizedDocumentsAreRejected() throws Exception { + final Ed25519PrivateKeyParameters key = key(); final long now = System.currentTimeMillis(); - final String source = "function createSabrPolicy(sabr){return{" - + "describe:function(){return{media:{headerType:120,mediaType:121,endType:122}}}," - + "initialRequest:function(e){return{body:e.fallbackBody}}," - + "followUpRequest:function(e){return{body:e.fallbackBody}}," - + "response:function(e){return{actions:['CONTINUE']}}," - + "mediaHeader:function(e){return{headerId:1,itag:1}}}}"; - final SabrScriptPolicy policy = new SabrScriptPolicy( - 5, now - 1_000, now + 60_000, source); - final byte[] payload = policy.serialize(); - final Ed25519Signer signer = new Ed25519Signer(); - signer.init(true, privateKey); - signer.update(payload, 0, payload.length); - - final String publicKey = android.util.Base64.encodeToString( - privateKey.generatePublicKey().getEncoded(), android.util.Base64.NO_WRAP); - SabrPolicyRuntime.initialize(context, publicKey, 0); - SabrPolicyRuntime.installDocument(SabrScriptPolicyDocument.encode( - policy, signer.generateSignature()), now); - SabrPolicyRuntime.initialize(context, publicKey, 0); - - assertEquals(120, SabrPolicyRuntime.createSessionHost() - .getMediaProtocol().getHeaderPartType()); - - SabrPolicyRuntime.initialize(context, publicKey, 0); - final SabrScriptPolicy rollback = new SabrScriptPolicy( - 4, now - 1_000, now + 60_000, source); - final byte[] rollbackPayload = rollback.serialize(); - final Ed25519Signer rollbackSigner = new Ed25519Signer(); - rollbackSigner.init(true, privateKey); - rollbackSigner.update(rollbackPayload, 0, rollbackPayload.length); + SabrPolicyRuntime.initialize(context, encodedKey("current", key), CHANNEL, 0); + + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + "{\"format\":1,\"source\":\"function(){}\"}" + .getBytes(StandardCharsets.UTF_8), now)); + assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( + SabrProfileTestDocuments.signedExpired(30, now, "current", key), now)); assertThrows(IllegalArgumentException.class, () -> SabrPolicyRuntime.installDocument( - SabrScriptPolicyDocument.encode(rollback, - rollbackSigner.generateSignature()), now)); + new byte[SabrCompatibilityProfileDocument.MAX_DOCUMENT_BYTES + 1], now)); + assertEquals(-1, SabrPolicyRuntime.currentRevision()); } @Test - public void runtimeFailureDisablesCacheAndFallsBack() throws Exception { - final android.content.Context context = ApplicationProvider.getApplicationContext(); - context.getFileStreamPath("sabr-cloud-policy.bin").delete(); - context.getFileStreamPath("sabr-cloud-policy.rev").delete(); - final Ed25519PrivateKeyParameters privateKey = - new Ed25519PrivateKeyParameters(new SecureRandom()); + public void betaVersionSelectsBetaProfileChannel() { + assertEquals(BuildConfig.VERSION_NAME.contains("-beta") ? "beta" : "stable", + BuildConfig.SABR_COMPATIBILITY_PROFILE_CHANNEL); + } + + @Test + public void stableAndBetaCachesAndRevisionFloorsAreIsolated() throws Exception { + final Ed25519PrivateKeyParameters key = key(); + final String keys = encodedKey("current", key); final long now = System.currentTimeMillis(); - final String source = "function createSabrPolicy(sabr){return{" - + "describe:function(){return{media:{headerType:20,mediaType:21,endType:22," - + "headerDecoder:'builtin'}}}," - + "initialRequest:function(){throw Error('broken')}," - + "followUpRequest:function(){throw Error('broken')}," - + "response:function(){return{actions:['CONTINUE']}}," - + "mediaHeader:function(){return{headerId:1,itag:1}}}}"; - final SabrScriptPolicy policy = new SabrScriptPolicy( - 12, now - 1_000, now + 60_000, source); - final byte[] payload = policy.serialize(); - final Ed25519Signer signer = new Ed25519Signer(); - signer.init(true, privateKey); - signer.update(payload, 0, payload.length); - final String publicKey = android.util.Base64.encodeToString( - privateKey.generatePublicKey().getEncoded(), android.util.Base64.NO_WRAP); - SabrPolicyRuntime.initialize(context, publicKey, 0); - SabrPolicyRuntime.install(payload, signer.generateSignature(), now); + SabrPolicyRuntime.initialize(context, keys, STABLE_CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 40, now, "current", key, true), now); - final SabrSessionPolicyHost host = SabrPolicyRuntime.createSessionHost(); - final byte[] fallback = new byte[]{4, 5, 6}; - assertArrayEquals(fallback, host.evaluate(new SabrSessionPolicy.State(0, 0, 0, 0), - new SabrSessionPolicy.RequestEvent(0, 0, 0, 0, fallback)).getRequestBody()); - assertTrue(!context.getFileStreamPath("sabr-cloud-policy.bin").exists()); - - final SabrSessionPolicyHost next = SabrPolicyRuntime.createSessionHost(); - final Field policyField = SabrSessionPolicyHost.class.getDeclaredField("policy"); - policyField.setAccessible(true); - assertTrue(policyField.get(next) instanceof BuiltinSabrSessionPolicy); - host.close(); - next.close(); + SabrPolicyRuntime.initialize(context, keys, BETA_CHANNEL, 0); + assertEquals(-1, SabrPolicyRuntime.currentRevision()); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + 2, now, "current", key, true), now); + + SabrPolicyRuntime.initialize(context, keys, STABLE_CHANNEL, 0); + assertEquals(40, SabrPolicyRuntime.currentRevision()); + } + + private void clearFiles() { + clearChannel(CHANNEL); + clearChannel(STABLE_CHANNEL); + clearChannel(BETA_CHANNEL); + } + + private void clearChannel(final String channel) { + context.getFileStreamPath("sabr-compatibility-" + channel + ".bin").delete(); + context.getFileStreamPath("sabr-compatibility-" + channel + ".rev").delete(); + } + + private void writeLegacyState(final byte[] document) throws Exception { + try (DataOutputStream output = new DataOutputStream( + context.openFileOutput(CACHE_FILE, Context.MODE_PRIVATE))) { + output.writeInt(0x53435043); + output.writeByte(1); + output.writeInt(document.length); + output.write(document); + output.writeInt(-1); + } + } + + private static Ed25519PrivateKeyParameters key() { + return new Ed25519PrivateKeyParameters(new SecureRandom()); + } + + private static String encodedKey(final String id, + final Ed25519PrivateKeyParameters key) { + return id + "=" + Base64.encodeToString( + key.generatePublicKey().getEncoded(), Base64.NO_WRAP); } } diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileFallbackHarnessTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileFallbackHarnessTest.java new file mode 100644 index 000000000..4603d0bb3 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileFallbackHarnessTest.java @@ -0,0 +1,84 @@ +package org.schabi.newpipe.player.datasource; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.schabi.newpipe.extractor.services.youtube.sabr.FallbackSabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.ProfiledSabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaHeader; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaProtocol; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrProtocolException; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrRecoverableException; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSessionPolicy; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrStreamingResponseReader; + +import java.io.ByteArrayInputStream; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.annotation.Nonnull; + +public final class SabrProfileFallbackHarnessTest { + @Test + public void failedProfileFramingRetriesWithBuiltinProtocolOnNextResponse() { + final AtomicInteger failures = new AtomicInteger(); + final FallbackSabrSessionPolicy policy = new FallbackSabrSessionPolicy( + failingProfile(), failure -> failures.incrementAndGet()); + final SabrMediaProtocol responseProtocol = policy.getMediaProtocol(); + final byte[] customHeaderPart = new byte[]{20, 1, 0}; + + assertEquals(20, responseProtocol.getHeaderPartType()); + assertThrows(SabrRecoverableException.class, () -> + SabrStreamingResponseReader.readUntil( + new ByteArrayInputStream(customHeaderPart), null, null, null, + responseProtocol)); + + assertTrue(policy.isDisabled()); + assertEquals(1, failures.get()); + assertEquals(20, responseProtocol.getHeaderPartType()); + assertEquals(SabrMediaProtocol.builtin().getHeaderPartType(), + policy.getMediaProtocol().getHeaderPartType()); + } + + @Test + public void profileMappingCanReuseBuiltinFieldWithDifferentWireType() + throws SabrProtocolException { + final SabrMediaProtocol protocol = new ProfiledSabrSessionPolicy( + SabrProfileTestDocuments.mediaHeaderCollisionClient()).getMediaProtocol(); + + final SabrMediaHeader header = protocol.decodeHeader(new byte[]{16, 42}); + + assertEquals(42, header.getSequenceNumber()); + assertNull(header.getVideoId()); + } + + @Nonnull + private static SabrSessionPolicy failingProfile() { + return new SabrSessionPolicy() { + @Nonnull + @Override + public Result evaluate(@Nonnull final State state, @Nonnull final Event event) { + throw new UnsupportedOperationException(); + } + + @Nonnull + @Override + public SabrMediaProtocol getMediaProtocol() { + return new SabrMediaProtocol() { + @Override public int getHeaderPartType() { return 20; } + @Override public int getMediaPartType() { return 21; } + @Override public int getEndPartType() { return 22; } + + @Nonnull + @Override + public SabrMediaHeader decodeHeader(@Nonnull final byte[] payload) + throws SabrProtocolException { + throw new SabrProtocolException("broken profile media mapping"); + } + }; + } + }; + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileProcessRestartTest.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileProcessRestartTest.java new file mode 100644 index 000000000..4f39082e5 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileProcessRestartTest.java @@ -0,0 +1,85 @@ +package org.schabi.newpipe.player.datasource; + +import static org.junit.Assert.assertEquals; + +import android.content.Context; +import android.os.Bundle; +import android.util.Base64; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.schabi.newpipe.BuildConfig; + +import java.util.Arrays; + +@RunWith(AndroidJUnit4.class) +public final class SabrProfileProcessRestartTest { + private static final String CHANNEL = "process-restart"; + private static final long REVISION = 101; + + @Test + public void signedProfileSurvivesProcessRestart() throws Exception { + final Context context = ApplicationProvider.getApplicationContext(); + final Bundle arguments = InstrumentationRegistry.getArguments(); + final String phase = arguments.getString("phase", ""); + final Ed25519PrivateKeyParameters key = deterministicKey(); + final String publicKey = "restart=" + Base64.encodeToString( + key.generatePublicKey().getEncoded(), Base64.NO_WRAP); + + if ("install-app".equals(phase)) { + final String channel = BuildConfig.SABR_COMPATIBILITY_PROFILE_CHANNEL; + clear(context, channel); + final long now = System.currentTimeMillis(); + SabrPolicyRuntime.initialize(context, publicKey, channel, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + REVISION, now, "restart", key, true), now); + assertEquals(REVISION, SabrPolicyRuntime.currentRevision()); + return; + } + if ("clear-app".equals(phase)) { + clear(context, BuildConfig.SABR_COMPATIBILITY_PROFILE_CHANNEL); + return; + } + if ("install".equals(phase)) { + clear(context, CHANNEL); + final long now = System.currentTimeMillis(); + SabrPolicyRuntime.initialize(context, publicKey, CHANNEL, 0); + SabrPolicyRuntime.installDocument(SabrProfileTestDocuments.signed( + REVISION, now, "restart", key, true), now); + assertEquals(REVISION, SabrPolicyRuntime.currentRevision()); + return; + } + if (!"restore".equals(phase)) { + throw new IllegalArgumentException( + "Expected install, restore, install-app, or clear-app phase"); + } + try { + SabrPolicyRuntime.initialize(context, publicKey, CHANNEL, 0); + SabrPolicyRuntime.setBenchmarkPolicyMode( + SabrPolicyRuntime.BenchmarkPolicyMode.PROFILE); + assertEquals(REVISION, SabrPolicyRuntime.currentRevision()); + SabrPolicyRuntime.createSessionHost().close(); + } finally { + SabrPolicyRuntime.setBenchmarkPolicyMode( + SabrPolicyRuntime.BenchmarkPolicyMode.AUTO); + SabrPolicyRuntime.initialize(context, "", CHANNEL, 0); + clear(context, CHANNEL); + } + } + + private static Ed25519PrivateKeyParameters deterministicKey() { + final byte[] seed = new byte[32]; + Arrays.fill(seed, (byte) 0x5a); + return new Ed25519PrivateKeyParameters(seed); + } + + private static void clear(final Context context, final String channel) { + context.getFileStreamPath("sabr-compatibility-" + channel + ".bin").delete(); + context.getFileStreamPath("sabr-compatibility-" + channel + ".rev").delete(); + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileTestDocuments.java b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileTestDocuments.java new file mode 100644 index 000000000..56337afd2 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/player/datasource/SabrProfileTestDocuments.java @@ -0,0 +1,174 @@ +package org.schabi.newpipe.player.datasource; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonWriter; + +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfile; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileClient; +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrCompatibilityProfileDocument; +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +public final class SabrProfileTestDocuments { + private SabrProfileTestDocuments() { + } + + public static byte[] signed(final long revision, final long now, final String keyId, + final Ed25519PrivateKeyParameters key, + final boolean includeMweb) { + return signed(revision, now - 60_000, now + 3_600_000, keyId, key, + includeMweb, false); + } + + public static byte[] signedWithFailingMediaMapping( + final long revision, final long now, final String keyId, + final Ed25519PrivateKeyParameters key) { + return signed(revision, now - 60_000, now + 3_600_000, keyId, key, + true, true); + } + + static byte[] signedExpired(final long revision, final long now, final String keyId, + final Ed25519PrivateKeyParameters key) { + return signed(revision, now - 3_600_000, now - 1, keyId, key, true, false); + } + + static SabrCompatibilityProfileClient mediaHeaderCollisionClient() { + final long now = System.currentTimeMillis(); + final JsonObject root = document(1, now - 60_000, now + 60_000, + "unused", false, false); + final JsonObject client = root.getObject("clients").getObject("WEB"); + final JsonObject mapping = new JsonObject(); + mapping.put("partType", 20); + mapping.put("target", "MEDIA_HEADER.SEQUENCE"); + mapping.put("path", new JsonArray(Collections.singletonList(2))); + mapping.put("wireType", "VARINT"); + mapping.put("required", true); + client.put("responseMappings", new JsonArray(Collections.singletonList(mapping))); + return SabrCompatibilityProfileDocument.decode( + JsonWriter.string(root).getBytes(StandardCharsets.UTF_8)) + .getProfile().getClient(YoutubeSabrClientProfile.WEB); + } + + private static byte[] signed(final long revision, final long validFrom, + final long validUntil, final String keyId, + final Ed25519PrivateKeyParameters key, + final boolean includeMweb, + final boolean failingMediaMapping) { + final JsonObject document = document( + revision, validFrom, validUntil, keyId, includeMweb, failingMediaMapping); + final byte[] unsigned = JsonWriter.string(document).getBytes(StandardCharsets.UTF_8); + final SabrCompatibilityProfile profile = + SabrCompatibilityProfileDocument.decode(unsigned).getProfile(); + final byte[] payload = profile.serialize(); + final Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, key); + signer.update(payload, 0, payload.length); + document.put("signature", java.util.Base64.getEncoder() + .encodeToString(signer.generateSignature())); + return JsonWriter.string(document).getBytes(StandardCharsets.UTF_8); + } + + private static JsonObject document(final long revision, final long validFrom, + final long validUntil, final String keyId, + final boolean includeMweb, + final boolean failingMediaMapping) { + final JsonObject root = new JsonObject(); + root.put("format", 1); + root.put("revision", revision); + root.put("validFromMs", validFrom); + root.put("validUntilMs", validUntil); + root.put("minimumExtractorRevision", 1); + root.put("capabilities", new JsonArray(Arrays.asList("request-template-v1", + "response-schema-v1", "recovery-rules-v1"))); + final JsonObject clients = new JsonObject(); + clients.put("WEB", client(failingMediaMapping)); + if (includeMweb) clients.put("MWEB", client(failingMediaMapping)); + root.put("clients", clients); + root.put("keyId", keyId); + root.put("signature", java.util.Base64.getEncoder().encodeToString(new byte[64])); + return root; + } + + private static JsonObject client(final boolean failingMediaMapping) { + final JsonObject client = new JsonObject(); + final JsonObject media = new JsonObject(); + media.put("header", 20); + media.put("payload", 21); + media.put("end", 22); + client.put("mediaParts", media); + client.put("initialRequest", request()); + client.put("followingRequest", request()); + final JsonArray mappings = new JsonArray(); + if (failingMediaMapping) { + final JsonObject mapping = new JsonObject(); + mapping.put("partType", 20); + mapping.put("target", "MEDIA_HEADER.SEQUENCE"); + mapping.put("path", new JsonArray(Collections.singletonList(536_870_911))); + mapping.put("wireType", "VARINT"); + mapping.put("required", true); + mappings.add(mapping); + } + client.put("responseMappings", mappings); + final JsonObject recovery = new JsonObject(); + recovery.put("maximumOmissions", 3); + recovery.put("maximumElapsedMs", 15_000); + recovery.put("forwardThresholdMs", 30_000); + recovery.put("retryDelayMs", 0); + client.put("recovery", recovery); + client.put("rules", rules()); + return client; + } + + private static JsonArray request() { + final JsonArray fields = new JsonArray(); + fields.add(field(1, "BYTES", "CLIENT_ABR_STATE", true)); + fields.add(field(2, "BYTES", "SELECTED_FORMATS", false)); + fields.add(field(3, "BYTES", "BUFFERED_RANGES", false)); + fields.add(field(4, "VARINT", "PLAYER_TIME_MS", false)); + fields.add(field(5, "BYTES", "USTREAMER_CONFIG", true)); + fields.add(field(16, "BYTES", "PREFERRED_AUDIO_FORMATS", false)); + fields.add(field(17, "BYTES", "PREFERRED_VIDEO_FORMATS", false)); + fields.add(field(19, "BYTES", "CLIENT_CONTEXT", true)); + return fields; + } + + private static JsonArray rules() { + final JsonArray rules = new JsonArray(); + rules.add(rule(Collections.singletonList("HAS_ERROR"), + Collections.singletonList("FAIL_SESSION"))); + rules.add(rule(Collections.singletonList("RELOAD_REQUESTED"), + Collections.singletonList("TRY_RELOAD"))); + rules.add(rule(Arrays.asList("HAS_PROTECTION_BOUNDARY", "FETCH_SEGMENT_MODE"), + Arrays.asList("REQUIRE_PO_TOKEN", "RETRY"))); + rules.add(rule(Arrays.asList("HAS_PROTECTION_BOUNDARY", "PUMP_MODE"), + Arrays.asList("REFRESH_PO_TOKEN", "CONTINUE"))); + rules.add(rule(Collections.singletonList("HAS_REDIRECT"), + Arrays.asList("APPLY_REDIRECT", "CONTINUE"))); + rules.add(rule(Collections.emptyList(), Collections.singletonList("CONTINUE"))); + return rules; + } + + private static JsonObject rule(final java.util.List predicates, + final java.util.List actions) { + final JsonObject rule = new JsonObject(); + rule.put("whenAll", new JsonArray(predicates)); + rule.put("actions", new JsonArray(actions)); + return rule; + } + + private static JsonObject field(final int number, final String wireType, + final String source, final boolean required) { + final JsonObject field = new JsonObject(); + field.put("field", number); + field.put("wireType", wireType); + field.put("source", source); + field.put("required", required); + return field; + } +} From 8db93cd3242f83f443a32a15234d8117cbcc6d79 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 22 Jul 2026 21:05:48 +0200 Subject: [PATCH 3/3] docs: document SABR profile delivery --- SABR_CLOUD_POLICY.md | 51 ------------------------ SABR_COMPATIBILITY_PROFILES.md | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 51 deletions(-) delete mode 100644 SABR_CLOUD_POLICY.md create mode 100644 SABR_COMPATIBILITY_PROFILES.md diff --git a/SABR_CLOUD_POLICY.md b/SABR_CLOUD_POLICY.md deleted file mode 100644 index b07d20ac7..000000000 --- a/SABR_CLOUD_POLICY.md +++ /dev/null @@ -1,51 +0,0 @@ -# SABR cloud policy delivery - -Release builds use the PipePipe policy repository by default. Deployments can override it with two -build environment variables: - -- `SABR_POLICY_PUBLIC_KEY_BASE64`: raw 32-byte Ed25519 public key encoded as Base64. -- `SABR_POLICY_URL`: HTTPS endpoint serving the UTF-8 JSON document accepted by - `SabrPolicyRuntime.installDocument`. - -The production defaults are: - -- Repository: `https://github.com/InfinityLoop1308/PipePipeSabrPolicies` -- Policy URL: - `https://raw.githubusercontent.com/InfinityLoop1308/PipePipeSabrPolicies/main/policy.json` -- Raw Ed25519 public key: `Yyi5q4s0ikpgAitzw+62H8+ggPt+dTILJ0Of4vSIcrU=` - -When both values are present, the client restores the last verified policy at startup, requests an -update immediately, and schedules a connected-network refresh every six hours. The endpoint must -return the signed policy document as the response body with HTTP 200. HTTP 204 is treated as no -update. Policies are signature checked, time bounded, and revision monotonic before activation. - -The document is deliberately human-readable and suitable for publication in a public source -repository: - -```json -{ - "format": 1, - "revision": 1001, - "validFromMs": 1784390400000, - "validUntilMs": 1792166400000, - "source": "function createSabrPolicy(sabr) { /* ... */ }", - "signature": "base64-encoded Ed25519 signature" -} -``` - -The Ed25519 signature covers the canonical `SabrScriptPolicy` payload reconstructed from revision, -validity bounds, and source. JSON whitespace and key order are not signed and may be changed without -invalidating the document. Numeric metadata must use exact JSON integers; fractions, scientific -notation, strings, and values outside the signed 64-bit range are rejected rather than coerced. The -client keeps its verified cache in a private binary format; that cache is never downloaded or -executed as a binary. - -If a policy throws while building requests, interpreting responses, routing demanded segments, or -decoding media headers, the client removes that cached policy, retains its highest revision to -prevent rollback, and uses the builtin policy until a higher signed revision is delivered. - -Policies that declare `demand: true` can route reader-demand retries and decide how to handle exact -target omissions using bounded, payload-free returned segment identities. Policies without that -declaration retain the builtin demand behavior, so an older valid cloud policy remains compatible -with a client that supports the expanded contract. See the Extractor's -`SABR_JAVASCRIPT_POLICY.md` for the complete event and decision schema. diff --git a/SABR_COMPATIBILITY_PROFILES.md b/SABR_COMPATIBILITY_PROFILES.md new file mode 100644 index 000000000..c9e399a93 --- /dev/null +++ b/SABR_COMPATIBILITY_PROFILES.md @@ -0,0 +1,73 @@ +# SABR compatibility profile delivery + +PipePipe can update volatile WEB and MWEB SABR schemas without executing downloaded code. The +Extractor validates and compiles a signed declarative profile; the Android client only delivers, +caches, activates, and disables it. + +## Build configuration + +The profile endpoint and trust roots are build-time values: + +- `SABR_COMPATIBILITY_PROFILE_PUBLIC_KEYS`: comma-separated + `key-id=base64-raw-ed25519-public-key` entries. Up to eight keys can be embedded for rotation. +- `SABR_COMPATIBILITY_PROFILE_STABLE_URL`: HTTPS document endpoint for stable builds. +- `SABR_COMPATIBILITY_PROFILE_BETA_URL`: HTTPS document endpoint for beta builds. + +Versions containing `-beta` select the `beta` channel; other versions select `stable`. Channel +caches and revision floors are independent. If a URL or the public keys are absent, PipePipe uses +the bundled native policy and does not schedule profile updates. + +The endpoint must return the UTF-8 profile document with HTTP 200. HTTP 204 and 304 mean no +update. Transient status codes and I/O failures are retried by WorkManager. Redirects are disabled, +and the configured endpoint must use HTTPS. + +## Activation and rollback + +At startup, PipePipe restores the active and previous signed documents from private atomic files. +The highest accepted revision is persisted separately, so a damaged profile cache does not permit +an older downloaded revision. A cached document below that floor is accepted only when the cache +also records that the exact floor revision triggered the circuit breaker. Normal active, previous, +and circuit-breaker fallback generations use distinct restore paths. + +A downloaded document is activated only after all of these steps succeed: + +1. bounded JSON parsing; +2. Ed25519 verification against an embedded key ID; +3. validity, format, capability, and Extractor revision checks; +4. monotonic revision validation; +5. atomic cache and revision-floor persistence. + +Each new SABR session snapshots the active immutable profile. If that profile fails while building +a request, interpreting a response, routing a demand, or decoding a media header, its session-local +circuit breaker switches directly to the bundled native policy. The same revision is disabled +globally for new sessions, which use the previous valid profile when one exists, while the revision +floor remains unchanged. Only a higher signed revision can replace it. + +A valid partial document is not a profile failure. If it contains WEB but not MWEB, or MWEB but not +WEB, sessions for the omitted client use the bundled policy while the signed revision remains +active for the covered client. + +Diagnostics contain revision numbers, action names, bounded counters, and failure classes. They do +not contain request bodies, response payloads, cookies, PO tokens, or media bytes. + +## Validation + +`SabrPolicyRuntimeTest` covers signature verification, key rotation, offline restoration, +anti-rollback state, cache corruption, invalid documents, and persisted circuit-breaker fallback. +`SabrPlaybackSmokeTest.compatibilityProfileReplaysShiftedUmpSchema` runs the native playback +harness against shifted request fields, UMP media part types, and media-header protobuf fields. +`SabrPlaybackSmokeTest.compatibilityProfileReplaysSabrDownloadPump` replays the same profile through +the multi-response pump used by SABR downloads and verifies the following-request template. +`SabrPlaybackSmokeTest.webOnlyProfileLeavesMwebReplayOnBuiltinPolicy` verifies that a partial WEB +profile leaves an MWEB session on the bundled policy without tripping the global circuit breaker. +`SabrProfileFallbackHarnessTest.profileMappingCanReuseBuiltinFieldWithDifferentWireType` verifies +that a shifted protobuf field can reuse an old number without failing the native baseline decoder. +`SabrPlaybackSmokeTest.signedCompatibilityProfilePlaysAndSeeks` performs real WEB or MWEB +extraction, renders video and audio, seeks, and verifies that the signed revision remains active. +`SabrPlaybackSmokeTest.signedBrokenProfileFallsBackToBuiltin` injects a required media-header +mapping failure into real playback and verifies that the revision is disabled while playback +continues with the bundled policy. `SabrProfileProcessRestartTest` verifies restoration across two +separate Android instrumentation processes. + +The profile schema and native trust boundary are documented in PipePipeExtractor's +`SABR_COMPATIBILITY_PROFILES.md`.