"""Round 50: the andTunes app ships from LinTunes. The Android side is built by ``andtunes/build.py`` and verified on the device; nothing here runs Java. What LinTunes owns, and what's pinned: * sync fills ``Buttons/`` with default artwork **only where a file is missing** — the user's own art is never overwritten, and that folder is still never pruned; * the button names are a contract with the app, so the bundled set must cover every name it asks for; * installing picks the Rabbit (never some other device that happens to be plugged in), treats a refused permission grant as a warning rather than a failed install, and falls back to an MTP copy when there's no adb; * the committed APK is found through `importlib.resources` with its version. """ import subprocess import pytest from lintunes.andtunes import install from lintunes.andtunes.layout import BUTTONS_DIR from lintunes.andtunes.sync import ( AndTunesSyncWorker, default_buttons, plan_andtunes_sync, ) from lintunes.device_sync import Device from lintunes.models import Track from lintunes.models.playlist import Playlist, PlaylistType # Every name MenuActivity / NowPlayingActivity load via Ui.button(). APP_BUTTONS = {"playlists", "artists", "albums", "songs", "search", "now_playing", "shuffle_all", "prev", "play", "pause", "next"} def _sync(tmp_path, qapp): audio = tmp_path / "local" / "song.mp3" audio.parent.mkdir(parents=True, exist_ok=True) audio.write_bytes(b"x" * 64) track = Track(track_id=1, name="Song", artist="Band", album="Record", location=str(audio), total_time=60_000) playlist = Playlist(name="Mix", persistent_id="AAAA0001", playlist_type=PlaylistType.REGULAR, track_ids=[1]) root = tmp_path / "device" / "Music" / "andTunes" plan = plan_andtunes_sync([playlist], {1: track}, root, copy_art=False) worker = AndTunesSyncWorker(plan) failures = [] worker.failed.connect(failures.append) worker._run() assert not failures return root, plan # ---- buttons ---- def test_bundled_buttons_cover_every_name_the_app_asks_for(): names = {p.name.removesuffix(".png") for p in default_buttons()} assert APP_BUTTONS <= names def test_sync_ships_default_buttons(tmp_path, qapp): root, _ = _sync(tmp_path, qapp) shipped = {p.name for p in (root / BUTTONS_DIR).iterdir()} assert shipped == {p.name for p in default_buttons()} bundled = {p.name: p.read_bytes() for p in default_buttons()} assert (root / BUTTONS_DIR / "songs.png").read_bytes() == bundled["songs.png"] def test_sync_never_overwrites_the_users_button_art(tmp_path, qapp): root = tmp_path / "device" / "Music" / "andTunes" mine = root / BUTTONS_DIR / "songs.png" mine.parent.mkdir(parents=True) mine.write_bytes(b"trav's own art") (root / BUTTONS_DIR / "extra.png").write_bytes(b"not ours either") _, plan = _sync(tmp_path, qapp) assert mine.read_bytes() == b"trav's own art" assert (root / BUTTONS_DIR / "extra.png").exists() assert not any(rel.startswith(BUTTONS_DIR) for rel in plan.stale) # A second sync still leaves both alone and doesn't count them as stale. _, plan = _sync(tmp_path, qapp) assert mine.read_bytes() == b"trav's own art" assert not any(rel.startswith(BUTTONS_DIR) for rel in plan.stale) # ---- picking the device ---- def _fake_run(monkeypatch, outputs): calls = [] def run(cmd, timeout=15): calls.append(cmd) out, code = outputs(cmd) return subprocess.CompletedProcess(cmd, code, stdout=out, stderr="") monkeypatch.setattr(install, "_run", run) return calls def test_rabbit_serial_prefers_the_rabbit(monkeypatch): listing = ("List of devices attached\n" "PHONE123 device usb:1-1 product:x model:Pixel_7 device:p\n" "919109A4 device usb:3-3 product:gsi_r1 model:Rabbit_R1 device:r1\n") _fake_run(monkeypatch, lambda cmd: (listing, 0)) assert install.rabbit_serial("adb") == "919109A4" def test_rabbit_serial_never_guesses_between_strangers(monkeypatch): listing = ("List of devices attached\n" "A device model:Pixel\nB device model:Other\nC unauthorized\n") _fake_run(monkeypatch, lambda cmd: (listing, 0)) assert install.rabbit_serial("adb") is None one = "List of devices attached\nONLY device model:Whatever\nX offline\n" _fake_run(monkeypatch, lambda cmd: (one, 0)) assert install.rabbit_serial("adb") == "ONLY" def test_installed_version_parses_dumpsys(monkeypatch): dump = "Packages:\n Package [me.teafry.andtunes]\n versionCode=3 minSdk=26\n versionName=0.2.1\n" _fake_run(monkeypatch, lambda cmd: (dump, 0)) assert install.installed_version("adb", "S") == { "version_code": 3, "version_name": "0.2.1"} _fake_run(monkeypatch, lambda cmd: ("Unable to find package\n", 0)) assert install.installed_version("adb", "S") is None # ---- installing ---- def test_install_runs_install_then_grants(monkeypatch, tmp_path, qapp): apk = tmp_path / "andTunes.apk" apk.write_bytes(b"PK") calls = _fake_run(monkeypatch, lambda cmd: ( "Performing Streamed Install\nSuccess\n" if "install" in cmd else "", 0)) worker = install.InstallWorker(apk, "adb", "SER") results = [] worker.finished.connect(results.append) worker._run() assert results == [{"method": "adb", "granted": True}] assert calls[0] == ["adb", "-s", "SER", "install", "-r", str(apk)] assert any("MANAGE_EXTERNAL_STORAGE" in c for c in calls[1]) def test_a_refused_grant_is_not_a_failed_install(monkeypatch, tmp_path, qapp): apk = tmp_path / "andTunes.apk" apk.write_bytes(b"PK") _fake_run(monkeypatch, lambda cmd: ( ("Success\n", 0) if "install" in cmd else ("Security exception\n", 255))) worker = install.InstallWorker(apk, "adb", "SER") results = [] worker.finished.connect(results.append) worker._run() assert results == [{"method": "adb", "granted": False}] def test_install_failure_reports_adbs_reason(monkeypatch, tmp_path, qapp): apk = tmp_path / "andTunes.apk" apk.write_bytes(b"PK") _fake_run(monkeypatch, lambda cmd: ( "Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures differ]\n", 1)) worker = install.InstallWorker(apk, "adb", "SER") failures = [] worker.failed.connect(failures.append) worker._run() assert failures and "signatures differ" in failures[0] def test_without_adb_the_apk_is_copied_to_download(tmp_path, qapp): apk = tmp_path / "andTunes.apk" apk.write_bytes(b"PK\x03\x04 apk bytes") storage = tmp_path / "Internal shared storage" device = Device("Rabbit R1", storage, storage / "Music") worker = install.InstallWorker(apk, None, None, device) results = [] worker.finished.connect(results.append) worker._run() dest = storage / "Download" / "andTunes.apk" assert dest.read_bytes() == apk.read_bytes() assert results == [{"method": "copied", "path": str(dest)}] def test_the_committed_apk_is_found_with_its_version(): apk = install.bundled_apk() if apk is None: pytest.skip("no APK shipped in this checkout (andtunes/build.py --ship)") assert apk.read_bytes()[:2] == b"PK" version = install.bundled_version() assert version["version_code"] >= 1 and version["version_name"]