diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2e7ded8f..483e7e79 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -197,6 +197,22 @@ jobs: -no-boot-anim -camera-back none script: python3 scripts/run_maestro_ci.py android-15-instrumentation + - name: Run Android R8 reachability + id: api35-r8 + continue-on-error: true + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 + with: + api-level: 35 + arch: x86_64 + profile: pixel_6 + avd-name: maestro-api35-instrumentation + force-avd-creation: false + disable-animations: true + emulator-options: >- + -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio + -no-boot-anim -camera-back none + script: python3 scripts/run_maestro_ci.py android-r8-reachability + - name: Run Android 15 suites id: api35 continue-on-error: true @@ -299,9 +315,10 @@ jobs: retention-days: 7 - name: Report suite failures - if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }} + if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }} run: | echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}" + echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}" echo "Android 15 outcome: ${{ steps.api35.outcome }}" echo "Android 9 outcome: ${{ steps.api28.outcome }}" exit 1 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0b83ca8d..6dd132a0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -391,8 +391,44 @@ android { debugSymbolLevel = "FULL" } } + + // Instrumentation target that runs R8 (see testBuildType below). + // + // R8 only ever ran on `release`, so every gate in this repository exercised code + // the shipped APK does not contain: reflective lookups, JNI callbacks and native + // library loading can all break under shrinking while every debug check passes. + // #1703 shipped that way — DefaultRenderersFactory's Class.forName for the bundled + // FFmpeg audio renderer failed in release builds only. + // + // Inherits release's minification and keep rules (the Flutter plugin has already + // installed them by the time this block runs) but stays debuggable and debug-signed, + // so it is an ordinary test artifact and never a publishable one. Debuggable also + // makes the Flutter plugin treat it as debug mode, so it uses debug Dart artifacts. + create("minified") { + initWith(getByName("release")) + isDebuggable = true + // Resource shrinking is orthogonal to the reachability this variant guards and + // would only slow the instrumentation build down. + isShrinkResources = false + testProguardFiles("proguard-test-rules.pro") + proguardFile("proguard-instrumentation-rules.pro") + // Release has no signing config unless key.properties exists, which would leave + // this variant unsigned and uninstallable in CI. + signingConfig = signingConfigs.getByName("debug") + // Plugin subprojects only publish debug and release variants. + matchingFallbacks += listOf("debug", "release") + ndk { + debugSymbolLevel = "NONE" + } + } } + // Instrumentation normally runs against `debug`; the R8 reachability gate opts into the + // minified variant with -Pplezy.testBuildType=minified. Only one build type can host + // androidTest, and the existing playback suites need media3 builder APIs the app itself + // never calls — which R8 legitimately shrinks — so they stay on debug. + testBuildType = (findProperty("plezy.testBuildType") as String?) ?: "debug" + packaging { jniLibs { // pickFirst only suppresses the duplicate libc++ merge error; the diff --git a/android/app/proguard-instrumentation-rules.pro b/android/app/proguard-instrumentation-rules.pro new file mode 100644 index 00000000..8ac53a60 --- /dev/null +++ b/android/app/proguard-instrumentation-rules.pro @@ -0,0 +1,9 @@ +# Applied only to the `minified` variant, which exists to run R8 over the app under test. +# +# The instrumentation runner is loaded through the tested app's class loader, and its +# supertypes resolve from the app APK. Shrinking androidx.test there leaves the harness +# with an AndroidJUnitRunner it cannot link, and the run dies with ClassNotFoundException +# before a single test starts — reported as `tests="0"`, which is easy to mistake for a +# passing gate. No shipped build includes this file. +-keep class androidx.test.** { *; } +-dontwarn androidx.test.** diff --git a/android/app/proguard-test-rules.pro b/android/app/proguard-test-rules.pro new file mode 100644 index 00000000..aa395bbf --- /dev/null +++ b/android/app/proguard-test-rules.pro @@ -0,0 +1,11 @@ +# Shrinker rules for the instrumentation APK of the `minified` variant. +# +# That variant exists to run R8 over the app under test, not over the harness. AGP +# applies the app's rules to the test APK too, which deletes the instrumentation runner +# and every test class the runner resolves by name — the run then dies with +# ClassNotFoundException before a single test starts. The harness is never shipped, so +# it has nothing to gain from shrinking. +-dontshrink +-dontoptimize +# The runner resolves the instrumentation class and every -e class filter by name. +-dontobfuscate diff --git a/android/app/src/androidTest/kotlin/androidx/media3/decoder/ffmpeg/FfmpegDecoderReachabilityTest.kt b/android/app/src/androidTest/kotlin/androidx/media3/decoder/ffmpeg/FfmpegDecoderReachabilityTest.kt new file mode 100644 index 00000000..db68adbb --- /dev/null +++ b/android/app/src/androidTest/kotlin/androidx/media3/decoder/ffmpeg/FfmpegDecoderReachabilityTest.kt @@ -0,0 +1,72 @@ +package androidx.media3.decoder.ffmpeg + +import android.os.Handler +import android.os.Looper +import androidx.media3.common.MimeTypes +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.AudioRendererEventListener +import androidx.media3.exoplayer.video.VideoRendererEventListener +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.edde746.plezy.exoplayer.PlezyRenderersFactory +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Asserts the bundled FFmpeg audio decoder is reachable the way production reaches it. + * + * Every path here is name-based, so R8 can sever it while the code still compiles and + * every debug check passes. #1703 shipped exactly that: the shrinker dropped + * FfmpegAudioRenderer and FfmpegAudioDecoder.growOutputBuffer, TrueHD and DTS-HD lost + * their only decoder, and 4K Dolby Vision files bailed to the mpv fallback. + * + * Run this against the `minified` build type (`-Pplezy.testBuildType=minified`); on an + * unminified variant it can only ever pass. Deliberately touches no ExoPlayer builder + * API, so no keep rule beyond the ones under test has to exist for it to run. + * + * Emptying `proguard-rules.pro` was verified to fail + * [nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode]; the renderer-list assertion + * kept passing, because something else in this variant still retains that class. Treat + * the JNI assertion as the load-bearing one, and `scripts/check_shrinker_rules.py` as the + * guard for the renderer's own keep. + */ +@RunWith(AndroidJUnit4::class) +class FfmpegDecoderReachabilityTest { + + @Test + fun productionRendererListIncludesTheFfmpegAudioRenderer() { + // Goes through the app's own factory rather than repeating media3's Class.forName: + // the instrumentation APK shares a class loader with the app, so a direct reflective + // lookup can resolve a copy the harness carries even when the app APK lost its own. + // DefaultRenderersFactory swallows ClassNotFoundException as "built without the + // extension", so a shrunk renderer leaves no trace but missing codecs. + val context = InstrumentationRegistry.getInstrumentation().targetContext + val factory = PlezyRenderersFactory(context) + .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + val handler = Handler(Looper.getMainLooper()) + + val names = factory.createRenderers( + handler, + object : VideoRendererEventListener {}, + object : AudioRendererEventListener {}, + { }, + { } + ).map { it.name } + + assertTrue("FfmpegAudioRenderer missing from $names", names.contains("FfmpegAudioRenderer")) + } + + @Test + fun nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode() { + // isAvailable() covers the whole JNI handshake: the shared library loads, JNI_OnLoad + // resolves FfmpegAudioDecoder by name, and GetMethodID finds growOutputBuffer with a + // descriptor naming SimpleDecoderOutputBuffer. Any of those renamed or shrunk away + // makes this false. + assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable()) + + // The formats MediaCodec has no decoder for on the affected devices. + assertTrue("no truehd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_TRUEHD)) + assertTrue("no dts-hd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_DTS_HD)) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index c7d008ed..22bf7559 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -35,6 +35,12 @@ subprojects { // their dependencies already require current APIs. extension.compileSdk = 36 extension.buildToolsVersion = "36.1.0" + // The Flutter plugin copies the app's build types into every plugin + // module, so `minified` arrives here carrying the app's shrinker flags. + // Library-level shrinking then deletes the plugin entry points that only + // GeneratedPluginRegistrant references, and the app's R8 fails on the + // missing classes. Only the app should shrink. + extension.buildTypes.findByName("minified")?.isMinifyEnabled = false } } } diff --git a/scripts/run_maestro_ci.py b/scripts/run_maestro_ci.py index 546b611d..0cb2bab5 100755 --- a/scripts/run_maestro_ci.py +++ b/scripts/run_maestro_ci.py @@ -15,6 +15,11 @@ ANDROID_15_INSTRUMENTATION_CLASSES = ( "com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest" ) ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation" +# Kept separate from the suites above: only one build type can host androidTest, and +# those suites drive media3 builder APIs the app itself never calls, which R8 shrinks +# legitimately. This class asserts only name-based reachability (#1703). +ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest" +ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability" GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { @@ -194,6 +199,26 @@ def run_android_15_instrumentation() -> None: ) +def run_android_r8_reachability() -> None: + print("==> Android R8 reachability", flush=True) + # The `minified` build type runs R8 over the app under test, so a keep rule that stops + # covering a reflective lookup, a JNI callback or a native library load fails here + # instead of shipping. No other gate in this repository runs R8 at all. + # + # compileFlutterBuildMinified is deliberately not excluded: CI only prebuilds the + # debug APK, so this variant has no Flutter outputs to reuse. + run_maestro._run_checked( + ( + "android/gradlew", + "-p", + "android", + ":app:connectedMinifiedAndroidTest", + "-Pplezy.testBuildType=minified", + f"-Pandroid.testInstrumentationRunnerArguments.class={ANDROID_R8_REACHABILITY_CLASSES}", + ) + ) + + def run_recipes(recipes: tuple[tuple[str, ...], ...]) -> int: failed = False for arguments in recipes: @@ -223,6 +248,9 @@ def run_target(name: str, *, disposable_emulator: bool = False) -> int: if name == ANDROID_15_INSTRUMENTATION_TARGET: run_android_15_instrumentation() return 0 + if name == ANDROID_R8_REACHABILITY_TARGET: + run_android_r8_reachability() + return 0 return run_group(name) @@ -233,6 +261,7 @@ def main(argv: Sequence[str] | None = None) -> int: choices=( *GROUPS, ANDROID_15_INSTRUMENTATION_TARGET, + ANDROID_R8_REACHABILITY_TARGET, *DESTRUCTIVE_MANUAL_TARGETS, ), )