Files
plezy/scripts/test_check_shrinker_rules.py
T
edde746 ae331b217c fix(android): keep the FFmpeg audio decoder through R8
Flutter enables minification for every release build, and nothing but a
keep rule reaches androidx.media3.decoder.ffmpeg. DefaultRenderersFactory
instantiates FfmpegAudioRenderer with Class.forName, media3's consumer
rules only -keepclassmembers its constructor, and this project had no
proguard-rules.pro at all, so R8 shrank the renderer out of the shipped
dex and the reflective lookup failed with ClassNotFoundException. The
same pass dropped FfmpegAudioDecoder.growOutputBuffer, which ffmpeg_jni
resolves in JNI_OnLoad and whose absence fails the whole
System.loadLibrary("ffmpegJNI") call.

Release builds therefore lost every codec that decoder adds. TrueHD and
DTS-HD fell through to MediaCodecAudioRenderer, which has no decoder for
them, so a 4K Dolby Vision file died with NO_SUITABLE_DECODER_ERROR and
handed off to the mpv fallback — losing ExoPlayer's Profile 7 to 8.1
conversion on hardware that could have direct-played it. Only debug
builds, where R8 never runs, exercised the working path.

Keep the package and the type named in the JNI callback descriptor, and
guard the invariant so it cannot silently rot again: check_shrinker_rules
fails when an app class in a reflected namespace, a FindClass target, a
native callback member, or a descriptor type has no keep covering it.
Also record the built audio renderers, because whether the extension
loaded is otherwise indistinguishable in an uploaded log.

close #1703
2026-07-28 19:44:04 +02:00

158 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
import importlib.util
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("check_shrinker_rules.py")
SPEC = importlib.util.spec_from_file_location("check_shrinker_rules", SCRIPT)
CHECKER = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(CHECKER)
# The descriptor is split across two literals exactly as ffmpeg_jni.cc has it, so the
# fixture also covers C string concatenation.
JNI_SOURCE = """
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
growOutputBufferMethod = env->GetMethodID(
clazz, "growOutputBuffer",
"(Landroidx/media3/decoder/"
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
return JNI_VERSION_1_6;
}
"""
FULL_RULES = (
"-keep class androidx.media3.decoder.ffmpeg.** { *; }\n"
"-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }\n"
)
class ShrinkerRulesCheckerTest(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
java_path = self.root / "android/app/src/main/java/androidx/media3/decoder/ffmpeg"
java_path.mkdir(parents=True)
(java_path / "FfmpegAudioRenderer.java").write_text("// fixture\n", encoding="utf-8")
(java_path / "FfmpegAudioDecoder.java").write_text("// fixture\n", encoding="utf-8")
self.cpp_path = self.root / "android/app/src/main/cpp/media3_ffmpeg_decoder"
self.cpp_path.mkdir(parents=True)
self.jni_path = self.cpp_path / "ffmpeg_jni.cc"
self.jni_path.write_text(JNI_SOURCE, encoding="utf-8")
self.rules_path = self.root / "android/app/proguard-rules.pro"
self.rules_path.parent.mkdir(parents=True, exist_ok=True)
def tearDown(self) -> None:
self.temporary.cleanup()
def _write_rules(self, rules: str) -> None:
self.rules_path.write_text(rules, encoding="utf-8")
@staticmethod
def _failure_kinds(errors: list[str]) -> set[str]:
kinds = set()
for error in errors:
for marker in ("reflected namespace", "with FindClass", "from native code", "in the descriptor"):
if marker in error:
kinds.add(marker)
return kinds
@staticmethod
def _every_failure_kind() -> set[str]:
return {"reflected namespace", "with FindClass", "from native code", "in the descriptor"}
def test_repository_rules_cover_every_name_reached_class_and_member(self) -> None:
self.assertEqual([], CHECKER.validate(Path(__file__).resolve().parents[1]))
def test_package_keep_plus_descriptor_keep_passes(self) -> None:
self._write_rules(FULL_RULES)
self.assertEqual([], CHECKER.validate(self.root))
def test_missing_rules_file_is_reported(self) -> None:
errors = CHECKER.validate(self.root)
self.assertEqual(1, len(errors))
self.assertIn("proguard-rules.pro is missing", errors[0])
def test_uncovered_reflected_class_is_reported(self) -> None:
self._write_rules(
"-keep class androidx.media3.decoder.ffmpeg.FfmpegAudioDecoder { *; }\n"
"-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }\n"
)
self.assertEqual(
["androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer lives in a reflected namespace "
"but no -keep in android/app/proguard-rules.pro covers it"],
CHECKER.validate(self.root),
)
def test_single_star_does_not_span_packages(self) -> None:
self._write_rules("-keep class androidx.media3.* { *; }\n")
self.assertEqual(self._every_failure_kind(), self._failure_kinds(CHECKER.validate(self.root)))
def test_keepclassmembers_alone_does_not_count_as_a_keep(self) -> None:
# media3's consumer rules only keep the constructor, which neither keeps the class
# nor pins its name. That is the state that shipped a release without the decoder.
self._write_rules(
"-keepclassmembers class androidx.media3.decoder.ffmpeg.FfmpegAudioRenderer {\n"
" <init>(android.os.Handler);\n"
"}\n"
)
self.assertEqual(self._every_failure_kind(), self._failure_kinds(CHECKER.validate(self.root)))
def test_class_keep_without_the_native_callback_member_is_reported(self) -> None:
self._write_rules(
"-keep class androidx.media3.decoder.ffmpeg.** {\n"
" <init>(android.os.Handler);\n"
"}\n"
"-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }\n"
)
self.assertEqual(
["android/app/src/main/cpp/media3_ffmpeg_decoder/ffmpeg_jni.cc resolves "
"androidx.media3.decoder.ffmpeg.FfmpegAudioDecoder.growOutputBuffer from native code "
"but no -keep retains that member"],
CHECKER.validate(self.root),
)
def test_renamable_descriptor_class_is_reported(self) -> None:
self._write_rules("-keep class androidx.media3.decoder.ffmpeg.** { *; }\n")
errors = CHECKER.validate(self.root)
self.assertEqual(1, len(errors))
self.assertIn("names androidx.media3.decoder.SimpleDecoderOutputBuffer in the descriptor", errors[0])
def test_includedescriptorclasses_satisfies_the_descriptor_requirement(self) -> None:
self._write_rules("-keep,includedescriptorclasses class androidx.media3.decoder.ffmpeg.** { *; }\n")
self.assertEqual([], CHECKER.validate(self.root))
def test_platform_descriptor_types_need_no_keep(self) -> None:
# java.nio.ByteBuffer is in the return descriptor and must not be demanded.
self._write_rules(FULL_RULES)
self.assertEqual([], CHECKER.validate(self.root))
def test_untraceable_member_lookup_fails_loudly(self) -> None:
self.jni_path.write_text(
'growOutputBufferMethod = env->GetMethodID(someClass, "growOutputBuffer", "()V");\n',
encoding="utf-8",
)
self._write_rules(FULL_RULES)
errors = CHECKER.validate(self.root)
self.assertEqual(1, len(errors))
self.assertIn("cannot trace", errors[0])
if __name__ == "__main__":
unittest.main()