ci(android): gate name-based reachability on an R8-minified variant
R8 only ever ran on `release`, so every automated 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 `flutter test`, the Robolectric suites and `connectedDebugAndroidTest` stay green — which is exactly how #1703 shipped, with the bundled FFmpeg audio renderer shrunk out of release builds for TrueHD and DTS-HD. Add a `minified` build type that inherits release's shrinker configuration but stays debuggable and debug-signed, so it is an ordinary test artifact and never a publishable one. Three integration details took a run each to find: the Flutter plugin copies app build types into every plugin module, so library-level shrinking deleted the plugin entry points that only GeneratedPluginRegistrant references; the harness must not be shrunk or the runner disappears; and androidx.test has to survive in the app under test, or the runner cannot link its own supertype and the run reports zero tests instead of failing. Instrumentation still defaults to `debug`, because only one build type can host androidTest and the existing playback suites drive media3 builder APIs the app never calls, which R8 shrinks legitimately. The new reachability test opts into the minified variant instead and touches no builder API, so the only keeps it depends on are the ones under test. Emptying proguard-rules.pro was verified to fail it.
This commit is contained in:
@@ -197,6 +197,22 @@ jobs:
|
|||||||
-no-boot-anim -camera-back none
|
-no-boot-anim -camera-back none
|
||||||
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
|
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
|
- name: Run Android 15 suites
|
||||||
id: api35
|
id: api35
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -299,9 +315,10 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- name: Report suite failures
|
- 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: |
|
run: |
|
||||||
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
|
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 15 outcome: ${{ steps.api35.outcome }}"
|
||||||
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -391,8 +391,44 @@ android {
|
|||||||
debugSymbolLevel = "FULL"
|
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 {
|
packaging {
|
||||||
jniLibs {
|
jniLibs {
|
||||||
// pickFirst only suppresses the duplicate libc++ merge error; the
|
// pickFirst only suppresses the duplicate libc++ merge error; the
|
||||||
|
|||||||
@@ -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.**
|
||||||
@@ -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
|
||||||
+72
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,12 @@ subprojects {
|
|||||||
// their dependencies already require current APIs.
|
// their dependencies already require current APIs.
|
||||||
extension.compileSdk = 36
|
extension.compileSdk = 36
|
||||||
extension.buildToolsVersion = "36.1.0"
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ ANDROID_15_INSTRUMENTATION_CLASSES = (
|
|||||||
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
|
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
|
||||||
)
|
)
|
||||||
ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation"
|
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, ...], ...]] = {
|
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:
|
def run_recipes(recipes: tuple[tuple[str, ...], ...]) -> int:
|
||||||
failed = False
|
failed = False
|
||||||
for arguments in recipes:
|
for arguments in recipes:
|
||||||
@@ -223,6 +248,9 @@ def run_target(name: str, *, disposable_emulator: bool = False) -> int:
|
|||||||
if name == ANDROID_15_INSTRUMENTATION_TARGET:
|
if name == ANDROID_15_INSTRUMENTATION_TARGET:
|
||||||
run_android_15_instrumentation()
|
run_android_15_instrumentation()
|
||||||
return 0
|
return 0
|
||||||
|
if name == ANDROID_R8_REACHABILITY_TARGET:
|
||||||
|
run_android_r8_reachability()
|
||||||
|
return 0
|
||||||
return run_group(name)
|
return run_group(name)
|
||||||
|
|
||||||
|
|
||||||
@@ -233,6 +261,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
choices=(
|
choices=(
|
||||||
*GROUPS,
|
*GROUPS,
|
||||||
ANDROID_15_INSTRUMENTATION_TARGET,
|
ANDROID_15_INSTRUMENTATION_TARGET,
|
||||||
|
ANDROID_R8_REACHABILITY_TARGET,
|
||||||
*DESTRUCTIVE_MANUAL_TARGETS,
|
*DESTRUCTIVE_MANUAL_TARGETS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user