From f864613f1104db42e828d3df5aa64be5f90d8e13 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:00:12 +0200 Subject: [PATCH] feat: detect android in the installer --- install.ps1 | 2 +- install.py | 84 ++++++++++++++++++++++++++++++++-- install.sh | 2 +- tests/test_install.py | 103 ++++++++++++++++++++++++++++++++++++++++++ wiki/installer.md | 25 +++++++++- 5 files changed, 207 insertions(+), 9 deletions(-) diff --git a/install.ps1 b/install.ps1 index 4d971fd1..a4e777f1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -8,7 +8,7 @@ param( $ErrorActionPreference = "Stop" $defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -$defaultInstallSha256 = "7dbb5c48a834d31a470b1952a9b8659b4c1f49f8d37c709a4032a56abe495f27" +$defaultInstallSha256 = "b5749a3aea8679657a6d48f04ef18879f2fe04cc55d1c1a6510fd6b5d74068e1" $maximumInstallerBytes = 2MB $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } $temporary = $null diff --git a/install.py b/install.py index 2a2b5f13..857a68eb 100644 --- a/install.py +++ b/install.py @@ -104,19 +104,39 @@ DEFAULT_DESTS = { "retropie": Path.home() / "RetroPie" / "BIOS", } +# The three RetroArch packages published for Android, newest ABI first. +ANDROID_RETROARCH_PACKAGES = ( + "com.retroarch.aarch64", + "com.retroarch", + "com.retroarch.ra32", +) + + +def _is_android() -> bool: + """Android reports itself as Linux, so the runtime is named instead. + + Termux inherits the two variables the Android runtime exports, and + /system/build.prop covers a shell that was started without them. + """ + if os.environ.get("ANDROID_ROOT") and os.environ.get("ANDROID_DATA"): + return True + return Path("/system/build.prop").exists() + def detect_os() -> str: """Return normalized OS identifier. - RETROBIOS_OS names the platform outright (linux, wsl, windows, darwin), - for a run that has to behave like another host: the PowerShell wrapper - tests drive a Windows layout on a Linux runner. + RETROBIOS_OS names the platform outright (linux, wsl, windows, darwin, + android), for a run that has to behave like another host: the PowerShell + wrapper tests drive a Windows layout on a Linux runner. """ forced = os.environ.get("RETROBIOS_OS", "").strip().lower() - if forced in ("linux", "wsl", "windows", "darwin"): + if forced in ("linux", "wsl", "windows", "darwin", "android"): return forced system = platform.system().lower() if system == "linux": + if _is_android(): + return "android" proc_version = Path("/proc/version") if proc_version.exists(): try: @@ -326,6 +346,40 @@ def _detect_embedded() -> list[tuple[str, Path]]: return found +def _android_storage_root() -> Path: + """Shared storage root, the parent RetroArch builds its defaults from.""" + value = os.environ.get("EXTERNAL_STORAGE", "").strip() + return Path(value) if value else Path("/storage/emulated/0") + + +def _detect_android() -> list[tuple[str, Path]]: + """Find the RetroArch system directory on Android. + + The config is read from the app external files directory first, then from + the internal one (platform_unix.c:1327-1395). A system_directory left at + default resolves against /RetroArch, which is also where + the directory sits when no config has been written yet + (platform_unix.c:2836-2851). + """ + found: list[tuple[str, Path]] = [] + storage = _android_storage_root() + default_root = storage / "RetroArch" + + for package in ANDROID_RETROARCH_PACKAGES: + for cfg in ( + storage / "Android" / "data" / package / "files" / "retroarch.cfg", + Path("/data/data") / package / "files" / "retroarch.cfg", + ): + system_dir = _parse_retroarch_system_dir(cfg, default_root) + if system_dir: + found.append(("retroarch", system_dir)) + return found + + if default_root.exists(): + found.append(("retroarch", default_root / "system")) + return found + + def _lnk_target(lnk_path: Path, exe_name: str) -> Path | None: """Read the target path of a Windows shortcut. @@ -530,6 +584,9 @@ def detect_platforms(os_type: str) -> list[tuple[str, Path]]: """Detect installed emulator platforms and their BIOS directories.""" found: list[tuple[str, Path]] = [] + if os_type == "android": + found.extend(_detect_android()) + if os_type in ("linux", "wsl"): found.extend(_detect_embedded()) @@ -1227,6 +1284,17 @@ def _prompt_manual_platform(os_type: str) -> list[tuple[str, Path]]: return [(plat, dest)] +def _default_dest(os_type: str, platform_name: str) -> Path: + """Fall back path for a platform named on the command line but not found. + + A home directory is meaningless for RetroArch on Android, where Termux + owns the home and the frontend reads from shared storage. + """ + if os_type == "android" and platform_name == "retroarch": + return _android_storage_root() / "RetroArch" / "system" + return DEFAULT_DESTS.get(platform_name, Path.home() / "bios") + + def _manual_usage_hint(os_type: str) -> list[str]: """How to name the platform and directory when nothing is detected.""" if os_type == "windows": @@ -1236,6 +1304,12 @@ def _manual_usage_hint(os_type: str) -> list[str]: "https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1))) " "--platform retroarch --dest 'C:\\RetroArch\\system'", ] + if os_type == "android": + return [ + " Use --platform and --dest to specify manually, for example:", + " python3 install.py --platform retroarch " + "--dest /storage/emulated/0/RetroArch/system", + ] return [ " Use --platform and --dest to specify manually, for example:", " python3 install.py --platform retroarch --dest ~/.config/retroarch/system", @@ -1352,7 +1426,7 @@ def main() -> None: if matched: platforms = matched else: - default_dest = DEFAULT_DESTS.get(args.platform, Path.home() / "bios") + default_dest = _default_dest(os_type, args.platform) print( f" Platform '{args.platform}' not detected, " f"using default path: {default_dest}" diff --git a/install.sh b/install.sh index 6d9750a6..4482f9b8 100755 --- a/install.sh +++ b/install.sh @@ -18,7 +18,7 @@ esac TEMP_INSTALLER="" TEMP_DIRECTORY="" DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py" -DEFAULT_INSTALL_SHA256="7dbb5c48a834d31a470b1952a9b8659b4c1f49f8d37c709a4032a56abe495f27" +DEFAULT_INSTALL_SHA256="b5749a3aea8679657a6d48f04ef18879f2fe04cc55d1c1a6510fd6b5d74068e1" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/tests/test_install.py b/tests/test_install.py index 379d7d90..b14793fe 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -214,6 +214,109 @@ class TestEmbeddedDetection(unittest.TestCase): self.assertEqual(self._detect_with(set()), []) +class TestAndroidDetection(unittest.TestCase): + """Android reports itself as Linux, and RetroArch is the frontend there.""" + + def setUp(self): + self.saved = { + key: os.environ.get(key) + for key in ("ANDROID_ROOT", "ANDROID_DATA", "EXTERNAL_STORAGE", "RETROBIOS_OS") + } + for key in self.saved: + os.environ.pop(key, None) + + def tearDown(self): + for key, value in self.saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + def test_runtime_variables_name_android(self): + os.environ["ANDROID_ROOT"] = "/system" + os.environ["ANDROID_DATA"] = "/data" + self.assertTrue(install._is_android()) + + def test_plain_linux_is_not_android(self): + self.assertFalse( + install._is_android() and not Path("/system/build.prop").exists() + ) + + def test_forced_os_accepts_android(self): + os.environ["RETROBIOS_OS"] = "android" + self.assertEqual(install.detect_os(), "android") + + def test_default_directory_found_without_a_config(self): + storage = Path(tempfile.mkdtemp()) + (storage / "RetroArch" / "system").mkdir(parents=True) + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual( + install._detect_android(), + [("retroarch", storage / "RetroArch" / "system")], + ) + shutil.rmtree(storage) + + def test_config_default_resolves_against_the_retroarch_root(self): + storage = Path(tempfile.mkdtemp()) + files = storage / "Android" / "data" / "com.retroarch.aarch64" / "files" + files.mkdir(parents=True) + (files / "retroarch.cfg").write_text('system_directory = "default"\n') + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual( + install._detect_android(), + [("retroarch", storage / "RetroArch" / "system")], + ) + shutil.rmtree(storage) + + def test_custom_system_directory_wins(self): + storage = Path(tempfile.mkdtemp()) + files = storage / "Android" / "data" / "com.retroarch" / "files" + files.mkdir(parents=True) + custom = storage / "Emulation" / "bios" + (files / "retroarch.cfg").write_text(f'system_directory = "{custom}"\n') + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual(install._detect_android(), [("retroarch", custom)]) + shutil.rmtree(storage) + + def test_nothing_found_on_a_bare_device(self): + storage = Path(tempfile.mkdtemp()) + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual(install._detect_android(), []) + shutil.rmtree(storage) + + def test_detect_platforms_routes_android(self): + storage = Path(tempfile.mkdtemp()) + (storage / "RetroArch" / "system").mkdir(parents=True) + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual( + install.detect_platforms("android"), + [("retroarch", storage / "RetroArch" / "system")], + ) + shutil.rmtree(storage) + + def test_manual_hint_names_the_android_path(self): + hint = "\n".join(install._manual_usage_hint("android")) + self.assertIn("/storage/emulated/0/RetroArch/system", hint) + + def test_named_platform_falls_back_to_shared_storage(self): + storage = Path(tempfile.mkdtemp()) + os.environ["EXTERNAL_STORAGE"] = str(storage) + self.assertEqual( + install._default_dest("android", "retroarch"), + storage / "RetroArch" / "system", + ) + self.assertEqual( + install._default_dest("linux", "batocera"), Path("/userdata/bios") + ) + shutil.rmtree(storage) + + def test_packages_match_the_published_flavors(self): + self.assertEqual( + install.ANDROID_RETROARCH_PACKAGES, + ("com.retroarch.aarch64", "com.retroarch", "com.retroarch.ra32"), + ) + + class TestLaunchboxDetection(unittest.TestCase): """LaunchBox references its emulators in Data/Emulators.xml.""" diff --git a/wiki/installer.md b/wiki/installer.md index f23d0298..eaf6645d 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -217,8 +217,29 @@ redirect the write somewhere else again. ## Android -There is no Android detection. Termux reports itself as Linux, so nothing is -found and the platform has to be named along with where it writes: +Android reports itself as Linux, so the runtime is named from its own +variables instead: `ANDROID_ROOT` and `ANDROID_DATA`, which Termux inherits, +with `/system/build.prop` covering a shell started without them. + +Detection then looks for RetroArch, the only Android frontend keeping a shared +BIOS directory. `retroarch.cfg` is read from the app external files directory +and then from the internal one, the order the frontend itself follows +(`platform_unix.c:1327-1395`), and a `system_directory` left at `default` +resolves against `/RetroArch`, where the directory also sits +when no config has been written yet (`platform_unix.c:2836-2851`). + +Under scoped storage those two config paths are unreadable from Termux on +Android 11 and later, so on current devices the shared directory is what +answers. Running `termux-setup-storage` once is what makes it reachable: + +```bash +pkg install python +termux-setup-storage +curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh +``` + +`EXTERNAL_STORAGE` overrides the storage root for a device that mounts it +elsewhere. A custom BIOS directory is still named directly: ```bash python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system