From 39bc5f020bc299c214a0036569605c0541797c76 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:52:52 +0100 Subject: [PATCH] feat: put a .nomedia in packs unzipped on Android The RetroArch pack lands in RetroArch Android's system folder, where MediaProvider indexed 3 842 images, sounds and videos (552 MB) into the gallery and music apps: a folder holding .nomedia is hidden with every folder below it (FileUtils.java:1587-1629, ModernMediaScanner.java:845-846, a183e2b9). A pack whose platform files an Android target, unfiltered or built for one, carries an empty .nomedia. pack_files counts it and the integrity check takes it for a marker. --- install/retroarch.json | 2 +- scripts/generate_pack.py | 19 +++++++++++++- scripts/packpaths.py | 17 ++++++++++++ scripts/packverify.py | 8 +++--- tests/test_pack_counts.py | 54 +++++++++++++++++++++++++++++++++++++++ wiki/installer.md | 4 ++- 6 files changed, 97 insertions(+), 7 deletions(-) diff --git a/install/retroarch.json b/install/retroarch.json index 1b052c818..171cab28a 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -2599,7 +2599,7 @@ ], "total_files": 8338, "total_size": 7080104487, - "pack_files": 14921, + "pack_files": 14922, "pack_size": 7222137851, "total_omitted": 37, "omitted_files": [ diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 25b9f97ca..bf780878d 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -416,6 +416,17 @@ def platform_list_version(config: dict, platform: str) -> str: PACK_DOCUMENTS = ("README.txt", "manifest.json") +def pack_markers( + platform_name: str, target_name: str | None, platforms_dir: str +) -> tuple[str, ...]: + """The empty files a pack carries for the systems it is unzipped on, + as named before flattening: the .nomedia of a pack for Android.""" + from common import list_available_targets + + names = [t["name"] for t in list_available_targets(platform_name, platforms_dir)] + return (MEDIA_MARKER,) if lands_on_android(names, target_name) else () + + def core_data_trees( config: dict, profiles: dict[str, dict], @@ -1477,6 +1488,10 @@ def generate_pack( ), ) _write_generated_member(zf, "README.txt", readme_text) + for marker in pack_markers(platform_name, target_name, platforms_dir): + _write_generated_member( + zf, _flat(f"{base_dest}/{marker}" if base_dest else marker, base_dest, flatten), "" + ) _report_pack_counts( zip_path, file_status, total_files, core_count, source, @@ -5147,7 +5162,7 @@ def generate_manifest( "total_size": total_size, "pack_files": ( len(manifest_files) + len(data_sizes) + len(PACK_DOCUMENTS) - + len(rename_notes) + + len(rename_notes) + len(pack_markers(platform_name, target_name, platforms_dir)) ), "pack_size": total_size + sum(data_sizes), "total_omitted": len(omitted_by_destination), @@ -5719,6 +5734,8 @@ def verify_and_finalize_packs( return all_ok from packpaths import ( # noqa: E402,F401 + MEDIA_MARKER, + lands_on_android, _archive_destination, _path_parents, _has_path_conflict, diff --git a/scripts/packpaths.py b/scripts/packpaths.py index 3673628e5..c7b953432 100644 --- a/scripts/packpaths.py +++ b/scripts/packpaths.py @@ -192,6 +192,23 @@ def _register_path(dest: str, seen_files: set[str], seen_parents: set[str]) -> N for parent in _path_parents(dest): seen_parents.add(parent) +# Android's media scanner hides a folder holding a .nomedia file, and every +# folder below it (MediaProvider a183e2b9, FileUtils.java:1587-1629 +# isDirectoryHidden, ModernMediaScanner.java:845-846). RetroArch writes none +# (libretro-common/file/file_path_io.c:194 only skips one), so a pack +# unzipped into RetroArch Android's system folder put its samples, overlays +# and videos in the gallery and music apps. +MEDIA_MARKER = ".nomedia" + + +def lands_on_android(target_names: Iterable[str], target: str | None) -> bool: + """Whether a pack can be unzipped on Android: its target is an Android + one, or, unfiltered, the platform files one.""" + if target: + return target.startswith("android") + return any(name.startswith("android") for name in target_names) + + def _flat(arcname: str, prefix: str, flatten: bool) -> str: """Strip base_destination prefix from ZIP arcname when flattening.""" if flatten and prefix and arcname.startswith(prefix + "/"): diff --git a/scripts/packverify.py b/scripts/packverify.py index 2f6d825d1..499a1bed8 100644 --- a/scripts/packverify.py +++ b/scripts/packverify.py @@ -10,7 +10,7 @@ from collections import Counter from auto_fetch import DEFAULT_BIOS_DIR from artifacts import _build_timestamp -from packpaths import _has_path_conflict +from packpaths import MEDIA_MARKER, _has_path_conflict from ziptools import build_zip_contents_index from ziptools import check_inside_zip from nativemode import digest_algorithm @@ -170,7 +170,7 @@ def verify_pack( # (bios/INSTRUCTIONS_x.txt, games/keen6/INSTRUCTIONS_y.txt), so # they are told by their own name, not by a root prefix. base = name.rsplit("/", 1)[-1] - if base.startswith(("INSTRUCTIONS_", "RENAMED_")) or name in ( + if base.startswith(("INSTRUCTIONS_", "RENAMED_")) or base == MEDIA_MARKER or name in ( "manifest.json", "README.txt", ): @@ -539,7 +539,7 @@ def _structural_errors(zf: zipfile.ZipFile, zip_set: set) -> list[str]: if "GraphicMods" not in info.filename and info.filename not in ( "manifest.json", "README.txt", - ): + ) and info.filename.rsplit("/", 1)[-1] != MEDIA_MARKER: errors.append(f"zero-byte: {info.filename}") return errors @@ -746,7 +746,7 @@ def verify_pack_against_platform( is_flat = bool(base_dest) and not any( n.startswith(base_dest + "/") for n in zip_set - if n not in ("README.txt", "manifest.json") and not n.endswith("/") + if n not in ("README.txt", "manifest.json", MEDIA_MARKER) and not n.endswith("/") ) errors.extend(_structural_errors(zf, zip_set)) diff --git a/tests/test_pack_counts.py b/tests/test_pack_counts.py index 55f34e029..05d04534d 100644 --- a/tests/test_pack_counts.py +++ b/tests/test_pack_counts.py @@ -301,6 +301,60 @@ RECORD = { } +class APackForAndroidHidesItsMedia(PackCountFixture): + """RetroArch Android writes no .nomedia, and its system folder held the + samples, overlays and videos the gallery and music apps then showed.""" + + def _pack(self, target: str | None) -> dict[str, int]: + targets = self.platforms / "targets" + targets.mkdir(exist_ok=True) + (targets / "demo.yml").write_text(yaml.dump({"platform": "demo", "targets": { + "android-arm64-v8a": {"architecture": "aarch64", "cores": ["demo"]}, + "linux-x86_64": {"architecture": "x86_64", "cores": ["demo"]}, + }})) + out = self.root / f"dist-{target}" + out.mkdir() + zip_path = builder.generate_pack( + "demo", str(self.platforms), self.db, str(self.bios), str(out), + emulators_dir=str(self.emulators), emu_profiles=self.profiles, + data_registry=self.registry, offline=True, target_name=target, + ) + with zipfile.ZipFile(zip_path) as archive: + return {m.filename: m.file_size for m in archive.infolist()} + + def test_the_whole_pack_and_an_android_one_carry_it(self): + for target in (None, "android-arm64-v8a"): + with self.subTest(target=target): + self.assertEqual(self._pack(target).get(".nomedia"), 0) + + def test_a_pack_for_another_target_does_not(self): + self.assertNotIn(".nomedia", self._pack("linux-x86_64")) + + def test_the_manifest_counts_it(self): + members = self._pack(None) + manifest = builder.generate_manifest( + "demo", str(self.platforms), self.db, str(self.bios), + str(self.platforms / "_registry.yml"), + emulators_dir=str(self.emulators), emu_profiles=self.profiles, + offline=True, data_registry=self.registry, + ) + self.assertEqual(manifest["pack_files"], len(set(members) | {"manifest.json"})) + + def test_the_integrity_check_takes_it_for_a_marker(self): + import packverify + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(".nomedia", b"") + archive.writestr("empty.bin", b"") + with zipfile.ZipFile(buffer) as archive: + errors = packverify._structural_errors(archive, set(archive.namelist())) + self.assertEqual(errors, ["zero-byte: empty.bin"]) + + def test_a_platform_without_android_gets_none(self): + self.assertEqual(builder.pack_markers("demo", None, str(self.platforms)), ()) + + class TheTableDescribesTheRelease(unittest.TestCase): """The Download link gives the last release, not what main would build. diff --git a/wiki/installer.md b/wiki/installer.md index 886ec39c1..05c4a860a 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1466,7 +1466,9 @@ media scanner hides a folder holding one, and every folder below it (MediaProvider, `FileUtils.java` `isDirectoryHidden`), so the samples, overlays and videos of the BIOS folder stay out of the gallery and music apps. RetroArch writes none. A run that finds one already there leaves it, -and `--check` and `--dry-run` write nothing. +and `--check` and `--dry-run` write nothing. The RetroArch pack, unzipped on +Android as on other systems, carries the same empty `.nomedia` beside +`README.txt`. A pack built for a target other than Android does not. ## On a handheld { #on-a-handheld-the-retrobios-port }