Plezy consumed mpv through four unrelated supply chains: an MPVKit fork via SwiftPM for the Apple platforms, a libmpv-android fork's AAR for Android, an in-CI from-source build for Linux, and an unpinned sourceforge mpv-dev 7z for Windows. All four now consume the same per-commit, content-addressed binaries from https://github.com/edde746/mpv-build, pinned to one commit and built from one set of pinned sources (mpv v0.41.0 on Apple/Android/Windows, ffmpeg n8.0.1, our libass fork). - Apple: the SwiftPM package moves from edde746/MPVKit to edde746/mpv-build across the ios/macos/tvos projects; scripts/set_mpvkit_revision.sh becomes set_native_revision.sh, writes every pin site plus the new repo-root mpv-build.lock.json, and tvos/scripts/wire_mpv.rb derives the package repo from the locks. - Android: the mpv Kotlin API and JNI glue move in-app under android/libmpv (repackaged com.edde746.plezy.libmpv, exports renamed, shrinker rules covered), and the module downloads per-ABI native tarballs (lib/*.so incl. libc++_shared.so + include/) driven entirely by mpv-build.lock.json, with a PLEZY_LOCAL_MPV_DIR escape hatch for locally built artifacts. The fork AAR and its Maven coordinates are gone. - Linux: CI downloads the prebuilt self-relocating libmpv prefix (lock-driven, sha256-verified) instead of compiling mpv/ffmpeg/dav1d/ libplacebo/shaderc from source; linux/packaging/build-libmpv.sh and its test are deleted and native-inputs.json shrinks to the simdutf entry the CMake builds still fetch. - Windows: both arches FetchContent the mpv-build dev zips with URL_HASH enforcement, replacing the checksum-less sourceforge download and its ARM64 7-Zip special case; guard scripts updated. The lock plus the Apple pin sites all point at mpv-build commit d7c3d559, whose manifest was verified asset-by-asset against the published release digests (17/17 match). Verified locally: wire and pin-script suites green, runtime-input checks green, all four Android ABI tarballs downloaded/verified/extracted through the real Gradle tasks, the Windows FetchContent block exercised end to end through cmake, the Linux asset hash and layout checked against the workflow contract, and xcodebuild resolved the flipped SwiftPM graph with SwiftPM validating every binary checksum.
378 lines
16 KiB
Python
Executable File
378 lines
16 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"
|
|
)
|
|
|
|
# Mirrors the production rule: MatroskaExtractor is referenced at compile time, so only
|
|
# the member names need pinning for the getDeclaredField lookups.
|
|
MATROSKA_KEEP = (
|
|
"-keepclassmembernames class androidx.media3.extractor.mkv.MatroskaExtractor {\n"
|
|
" private androidx.media3.extractor.ExtractorOutput extractorOutput;\n"
|
|
" private androidx.media3.common.util.ParsableByteArray subtitleSample;\n"
|
|
"}\n"
|
|
)
|
|
|
|
# The shape AssMatroskaExtractor.kt uses: import + Type::class.java receivers.
|
|
KOTLIN_REFLECTION_SOURCE = """
|
|
package com.edde746.plezy.libass
|
|
|
|
import androidx.media3.extractor.mkv.MatroskaExtractor
|
|
|
|
internal val extractorOutputField = MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
|
|
isAccessible = true
|
|
}
|
|
internal val subtitleSampleField = MatroskaExtractor::class.java.getDeclaredField("subtitleSample").apply {
|
|
isAccessible = true
|
|
}
|
|
"""
|
|
|
|
# The Java receiver spelling of the same lookup.
|
|
JAVA_REFLECTION_SOURCE = """
|
|
package com.edde746.plezy.exoplayer;
|
|
|
|
import androidx.media3.extractor.mkv.MatroskaExtractor;
|
|
|
|
class MatroskaFields {
|
|
static final java.lang.reflect.Field OUTPUT;
|
|
static {
|
|
try {
|
|
OUTPUT = MatroskaExtractor.class.getDeclaredField("extractorOutput");
|
|
} catch (NoSuchFieldException error) {
|
|
throw new ExceptionInInitializerError(error);
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
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")
|
|
|
|
def _write_source(self, relative: str, text: str) -> None:
|
|
path = self.root / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, 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[2]))
|
|
|
|
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])
|
|
|
|
def test_kotlin_field_reflection_without_a_member_name_pin_is_reported(self) -> None:
|
|
self._write_source("android/libass/src/main/java/com/edde746/plezy/libass/AssProbe.kt", KOTLIN_REFLECTION_SOURCE)
|
|
self._write_rules(FULL_RULES)
|
|
|
|
self.assertEqual(
|
|
["android/libass/src/main/java/com/edde746/plezy/libass/AssProbe.kt resolves "
|
|
"androidx.media3.extractor.mkv.MatroskaExtractor.extractorOutput with "
|
|
"getDeclaredField/getDeclaredMethod but no -keep rule pins that member name",
|
|
"android/libass/src/main/java/com/edde746/plezy/libass/AssProbe.kt resolves "
|
|
"androidx.media3.extractor.mkv.MatroskaExtractor.subtitleSample with "
|
|
"getDeclaredField/getDeclaredMethod but no -keep rule pins that member name"],
|
|
CHECKER.validate(self.root),
|
|
)
|
|
|
|
def test_keepclassmembernames_satisfies_reflective_field_lookups(self) -> None:
|
|
self._write_source("android/libass/src/main/java/com/edde746/plezy/libass/AssProbe.kt", KOTLIN_REFLECTION_SOURCE)
|
|
self._write_source(
|
|
"android/app/src/main/java/com/edde746/plezy/exoplayer/MatroskaFields.java", JAVA_REFLECTION_SOURCE
|
|
)
|
|
self._write_rules(FULL_RULES + MATROSKA_KEEP)
|
|
|
|
self.assertEqual([], CHECKER.validate(self.root))
|
|
|
|
def test_class_forname_is_not_satisfied_by_a_member_name_pin(self) -> None:
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/edde746/plezy/boot/PluginLoader.kt",
|
|
'package com.edde746.plezy.boot\n\n'
|
|
'internal fun loadPlugin(): Class<*> = Class.forName("com.example.ReflectedPlugin")\n',
|
|
)
|
|
self._write_rules(FULL_RULES + "-keepclassmembernames class com.example.ReflectedPlugin { *; }\n")
|
|
|
|
self.assertEqual(
|
|
["android/app/src/main/kotlin/com/edde746/plezy/boot/PluginLoader.kt resolves "
|
|
"com.example.ReflectedPlugin with Class.forName but no -keep covers it"],
|
|
CHECKER.validate(self.root),
|
|
)
|
|
|
|
def test_class_keep_satisfies_class_forname(self) -> None:
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/edde746/plezy/boot/PluginLoader.kt",
|
|
'package com.edde746.plezy.boot\n\n'
|
|
'internal fun loadPlugin(): Class<*> = Class.forName("com.example.ReflectedPlugin")\n',
|
|
)
|
|
self._write_rules(FULL_RULES + "-keep class com.example.ReflectedPlugin { *; }\n")
|
|
|
|
self.assertEqual([], CHECKER.validate(self.root))
|
|
|
|
def test_reflection_in_test_sources_needs_no_keep(self) -> None:
|
|
# Unit and instrumentation sources never run under R8, so their reflection
|
|
# helpers must not demand keep rules.
|
|
self._write_source("android/app/src/test/kotlin/com/edde746/plezy/Probe.kt", KOTLIN_REFLECTION_SOURCE)
|
|
self._write_source("android/app/src/androidTest/kotlin/com/edde746/plezy/Probe.kt", KOTLIN_REFLECTION_SOURCE)
|
|
self._write_rules(FULL_RULES)
|
|
|
|
self.assertEqual([], CHECKER.validate(self.root))
|
|
|
|
def test_unresolvable_reflective_receiver_fails_loudly(self) -> None:
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/edde746/plezy/Probe.kt",
|
|
'package com.edde746.plezy\n\n'
|
|
'internal fun grab(target: Any) = target.javaClass.getDeclaredField("pendingResult")\n',
|
|
)
|
|
self._write_rules(FULL_RULES)
|
|
|
|
errors = CHECKER.validate(self.root)
|
|
|
|
self.assertEqual(1, len(errors))
|
|
self.assertIn("cannot resolve", errors[0])
|
|
|
|
def test_non_literal_class_for_name_fails_loudly(self) -> None:
|
|
# A concatenated or constant name is invisible to the literal-matching
|
|
# patterns; staying silent would green-light a release R8 may break.
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/edde746/plezy/boot/PluginLoader.kt",
|
|
'package com.edde746.plezy.boot\n\n'
|
|
'internal fun loadPlugin(name: String): Class<*> = Class.forName("com.example." + name)\n',
|
|
)
|
|
self._write_rules(FULL_RULES + "-keep class com.example.** { *; }\n")
|
|
|
|
errors = CHECKER.validate(self.root)
|
|
|
|
self.assertEqual(1, len(errors))
|
|
self.assertIn("cannot trace to a single string", errors[0])
|
|
|
|
def test_non_literal_member_lookup_fails_loudly(self) -> None:
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/edde746/plezy/Probe.kt",
|
|
'package com.edde746.plezy\n\n'
|
|
'private const val FIELD_NAME = "pendingResult"\n\n'
|
|
'internal fun grab(target: Any) = target.javaClass.getDeclaredField(FIELD_NAME)\n',
|
|
)
|
|
self._write_rules(FULL_RULES)
|
|
|
|
errors = CHECKER.validate(self.root)
|
|
|
|
self.assertEqual(1, len(errors))
|
|
self.assertIn("cannot trace to a single string", errors[0])
|
|
|
|
def test_same_package_receiver_resolves_against_the_file_package(self) -> None:
|
|
self._write_source(
|
|
"android/app/src/main/kotlin/com/example/app/Host.kt",
|
|
'package com.example.app\n\n'
|
|
'internal fun poke() = Helper::class.java.getDeclaredMethod("secret")\n',
|
|
)
|
|
self._write_rules(FULL_RULES)
|
|
|
|
errors = CHECKER.validate(self.root)
|
|
|
|
self.assertEqual(1, len(errors))
|
|
self.assertIn("com.example.app.Helper.secret", errors[0])
|
|
|
|
def test_keepclassmembernames_does_not_satisfy_a_native_member_lookup(self) -> None:
|
|
# A natively-reached member has no compile-time reference at all, so a rule that
|
|
# allows shrinking (-keepclassmembernames) must not count as covering it.
|
|
self._write_rules(
|
|
"-keep class androidx.media3.decoder.ffmpeg.** {\n"
|
|
" <init>(android.os.Handler);\n"
|
|
"}\n"
|
|
"-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }\n"
|
|
"-keepclassmembernames class androidx.media3.decoder.ffmpeg.FfmpegAudioDecoder { growOutputBuffer; }\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),
|
|
)
|
|
|
|
# The libmpv module shape: JNI callbacks resolved from a library module's own
|
|
# cpp tree, kept alive by that module's consumer rules rather than the app's.
|
|
LIBMPV_STYLE_SOURCE = (
|
|
"static void cache(JNIEnv *env) {\n"
|
|
' mpv_MpvPlayer = env->FindClass("com/edde746/plezy/libmpv/MpvPlayer");\n'
|
|
" mpv_MpvPlayer = reinterpret_cast<jclass>(env->NewGlobalRef(mpv_MpvPlayer));\n"
|
|
' onEvent = env->GetStaticMethodID(mpv_MpvPlayer, "onEvent", "(I)V");\n'
|
|
"}\n"
|
|
)
|
|
|
|
def test_library_module_native_lookups_are_scanned(self) -> None:
|
|
self._write_rules(FULL_RULES)
|
|
self._write_source("android/libmpv/src/main/cpp/jni_utils.cpp", self.LIBMPV_STYLE_SOURCE)
|
|
|
|
self.assertEqual(
|
|
{"with FindClass", "from native code"},
|
|
self._failure_kinds(CHECKER.validate(self.root)),
|
|
)
|
|
|
|
def test_module_consumer_rules_satisfy_native_lookups(self) -> None:
|
|
self._write_rules(FULL_RULES)
|
|
self._write_source("android/libmpv/src/main/cpp/jni_utils.cpp", self.LIBMPV_STYLE_SOURCE)
|
|
self._write_source(
|
|
"android/libmpv/consumer-rules.pro",
|
|
"-keep class com.edde746.plezy.libmpv.MpvPlayer {\n"
|
|
" public static void onEvent(int);\n"
|
|
"}\n",
|
|
)
|
|
|
|
self.assertEqual([], CHECKER.validate(self.root))
|
|
|
|
def test_bootclasspath_native_lookups_need_no_keep(self) -> None:
|
|
# Boxing helpers resolve java.lang.Integer and its constructor by name;
|
|
# neither is in the app dex, so R8 cannot shrink or rename them.
|
|
self._write_rules(FULL_RULES)
|
|
self._write_source(
|
|
"android/libmpv/src/main/cpp/boxing.cpp",
|
|
"static void cache(JNIEnv *env) {\n"
|
|
' java_Integer = env->FindClass("java/lang/Integer");\n'
|
|
' java_Integer_init = env->GetMethodID(java_Integer, "<init>", "(I)V");\n'
|
|
"}\n",
|
|
)
|
|
|
|
self.assertEqual([], CHECKER.validate(self.root))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|