Files
lintunes/tests/test_round50.py
T
travandClaude Opus 5 9483d8951b v0.19.0: andTunes plays music on the Rabbit
The Android half of andTunes had been paused on a 1 GB toolchain download
(Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed:
platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that
links no libraries needs five SDK steps, not a build system. andtunes/build.py
runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK.

andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a
streaming library.json parse grouped into artists and albums in one pass, one
ListActivity for every list (songs and artist songs open with Shuffle all,
albums carry art thumbs, search as you type), a Now playing bar under every
list, and a white Now Playing screen with square art, a slim scrubber,
prev/play/next, shuffle and repeat, and no back button. A foreground
PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and
a MediaStyle notification, end of queue pauses, missing files skip, and the
last queue and position come back on launch. Black on white, and every size
doubled for the R1's override density. Cold start to the menu: 313-338 ms.

The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not
volume. The app takes both in dispatchKeyEvent: lists scroll a row per
detent, and the menu and Now Playing get volume.

LinTunes side: Connections > Install andTunes on Rabbit... installs the
committed APK over adb and grants all-files access, or copies it to Download/
over MTP when there's no adb. Sync fills Buttons/ with default artwork only
where a file is missing, so trav's own art is never overwritten.

Verified against the dev library only, synced to the R1 and driven with
adb input + screencap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
2026-09-11 14:25:55 -05:00

186 lines
7.2 KiB
Python

"""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"]