Files
lintunes/andtunes/README.md
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

214 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# andTunes
A music player for the Rabbit R1 that **never scans anything**.
Every other Android player rebuilds its library on the device. Auxio reads
Android's MediaStore on each launch, which on a Helio P35 is the "your songs
will show up here" hang — and Musicolet, AIMP, Vinyl, Retro, Gramophone and
Fossify are all MediaStore-backed too. Symfonium and VLC keep their own
database but still scan the device to fill it, and both assume a phone-sized
screen. (trav sideloaded Musicolet in September 2026 to check: "it was…
fine?" — nothing off the shelf is built around already knowing the answer.)
LinTunes *does* already know the answer. It has the artist, album, year and
track number of every file it just copied, so it writes them into
`Music/andTunes/library.json` at sync time and the app parses one file
instead of indexing a filesystem. That is the entire architectural bet.
**Status (2026-09-11): andTunes 0.1.0 plays music on the Rabbit.** The
desktop half shipped in LinTunes v0.17.0 (round 47); the app itself landed in
round 50 (LinTunes v0.19.0), built in plain Java with no Gradle and no
download — see *Toolchain*. Cold start to the menu measures 313–338 ms on the
R1 (target < 400). Play counts coming back and the format gate are next
(`TASKS.md`, phase 3).
---
## The device
Measured on trav's R1, not taken from the internet (which says 240×282 and is
wrong):
| | |
|---|---|
| Panel | **480 × 640 px**, physical density 320, **override density 160** |
| Build | Android **13**, API **33**, `gsi_r1-userdebug` (r1_escape AOSP flash) |
| CPU | MediaTek Helio P35, arm64-v8a, 4 GB RAM |
| Storage | 128 GB, ~65 GB free |
| Scroll wheel | input device `och1970_holl_key` → **KEY_UP / KEY_DOWN** |
| Side/PTT button | `mtk-kpd`: KEY_VOLUMEDOWN + KEY_POWER |
| Headset | `mt63xx-accdet`: KEY_PLAYPAUSE → a MediaSession gets media keys free |
### The dp trap
Density is overridden to 160, so an app sees a 480 × 640 **dp** canvas — but
the panel is 2.88", about 278 real ppi. **One dp is roughly half its usual
physical size here.** A stock 48 dp list row is 4.4 mm tall and unusable.
> **Rule: double every stock dp value.** Body text 28–32 sp, list rows ≥ 88 dp,
> menu tiles ~213 dp. A 2 × 3 grid gives six 240 × 213 dp buttons, ≈ 22 × 19 mm
> each.
Do **not** "fix" this by changing the device's density. trav has it where he
wants it and asked for it to be left alone.
### The scroll wheel, and whether we can have it
trav reports the wheel acts as **volume** in every app, even though the kernel
device emits KEY_UP/KEY_DOWN — so the ROM ships a key layout mapping those to
`KEYCODE_VOLUME_UP`/`DOWN`. Confirm which one actually arrives when the device
is next plugged in:
```sh
adb shell 'ls /system/usr/keylayout/ | grep -i holl'
adb shell 'cat /system/usr/keylayout/och1970_holl_key.kl' # if it exists
```
Either way **an app can absolutely claim it for itself**, without touching a
system file or affecting any other app. A foreground Activity sees key events
before the system's volume handling, so:
```kotlin
override fun onKeyDown(code: Int, e: KeyEvent): Boolean = when (code) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_DPAD_UP -> { wheelUp(); true }
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_DPAD_DOWN -> { wheelDown(); true }
else -> super.onKeyDown(code, e)
}
```
Handle **both** mappings so it works whichever the ROM sends, and consume
`onKeyUp` for the same codes too — returning true only from `onKeyDown` can
still let the system act on the release. Returning true is what stops the
volume UI appearing. This is ordinary Android (camera apps do it for the
shutter); it applies only while an andTunes activity is focused, so volume
behaves normally everywhere else.
Worth deciding once it's testable: the wheel scrolls lists everywhere, and on
the now-playing screen it probably *should* stay volume — that's the one
screen where the stock behaviour is the useful one.
---
## Toolchain
Everything is already on this machine, and **nothing else gets downloaded**:
- `java-21-openjdk-devel` — `build.py` uses `/usr/lib/jvm/java-21-openjdk`
(Java 25 is the system default; R8 and apksigner want 21).
- `~/Android/Sdk/platforms/android-33` + `build-tools/34.0.0` (aapt2, d8/R8,
zipalign, apksigner). No `cmdline-tools` / `sdkmanager` needed.
- `adb` / `fastboot` — in `/usr/local/sbin`, platform-tools 31.0.3.
**No Gradle and no Kotlin** (decided 2026-09-11). The original plan was
Kotlin + Gradle, which is another ~1 GB of wrapper, Android Gradle Plugin and
Kotlin compiler — tools that produce the same APK. An app that links no
libraries needs exactly five SDK steps, so `build.py` runs them directly:
aapt2 compile/link → javac (`--release 11`, against `android.jar`) → R8
(`--release`, keep rules from aapt2) → zipalign → apksigner. Plain Java also
means no Kotlin runtime inside the APK: it's 61 KiB. The source tree is the
standard Gradle shape, so Gradle could be dropped in later with no moves.
To build on a second machine: install `java-21-openjdk-devel`, and copy
`~/Android/Sdk/platforms/android-33` and `build-tools/34.0.0` across (≈ 280 MB,
no internet needed). `ANDROID_HOME` / `JAVA_HOME` override the default paths.
`andtunes.keystore` is committed on purpose: both machines must sign the same
way, or `adb install -r` refuses an update built on the other one. A sideloaded
app with no store listing has nothing for the key to protect.
R8 gotcha: `-keepattributes SourceFile,LineNumberTable` makes R8 8.2 die with
an internal NPE on javac 21's debug info for an anonymous class, so
`proguard.pro` keeps none. Stack traces still carry class and method names
(`-dontobfuscate`).
Fedora note: don't `dnf install android-tools` over the existing
`/usr/local/sbin/adb`; two adb versions on PATH fight over the server port.
---
## Building and installing
```sh
python3 andtunes/build.py # -> andtunes/build/andTunes.apk
python3 andtunes/build.py --install # ... + adb install -r and launch
python3 andtunes/build.py --ship # ... + copy into lintunes/android/
# after a fresh install by hand, the grants LinTunes' installer does for you:
adb shell appops set --uid me.teafry.andtunes MANAGE_EXTERNAL_STORAGE allow
adb shell pm grant me.teafry.andtunes android.permission.POST_NOTIFICATIONS
# the number the whole project is judged on:
adb shell am start -W -n me.teafry.andtunes/.MenuActivity | grep TotalTime
```
Target: menu visible in **under 400 ms**.
---
## Design decisions already locked
- **Java, no Compose, no androidx, no Media3** (Kotlin was the first plan;
see *Toolchain* for why it went). Framework `Activity`,
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, R8
minify. Cold start is the feature; there is nothing to initialize if nothing
is linked in. Same instinct as `trash.py` and `sync_identity.py` on the
LinTunes side.
- **Package** `me.teafry.andtunes`, minSdk 26, targetSdk 33, arm64-v8a.
- **`MANAGE_EXTERNAL_STORAGE`** with a first-run screen sending the user to
the toggle. `READ_MEDIA_AUDIO` grants the audio files but *not*
`library.json`, which isn't media — that's the whole reason. Fallback if it
turns out to be blocked: a one-time SAF folder pick.
- **Screens are activities**, so "the last screen we were at" is Android's own
back stack with no bookkeeping. `MenuActivity` (six tiles, loads nothing),
one generic `ListActivity` parameterised for every list in the spec,
`NowPlayingActivity`, and a foreground `PlaybackService`.
- **No back button on the now-playing screen** — Android's own is always on
screen and the panel is tiny (trav's call).
- **End of a queue pauses.** It does not roll into shuffle-all. Repeat is
off / all / one.
- **Replaceable button artwork**: the app loads `Buttons/<name>.png` from the
device folder when present, else a bundled drawable. LinTunes fills in
defaults only where the file is *missing*, so trav's own art is never
overwritten.
## What the app reads
LinTunes writes all of this; see `lintunes/andtunes/layout.py` for the
authority and `manifest.py` for the exact rows.
```
/sdcard/Music/andTunes/
library.json format 1; refuse an unknown format loudly
Media/<Artist>/<Album>/04 Song.mp3
Art/<8-hex album key>.jpg 480 px, one per album
Playlists/<Name>.m3u entries are ../Media/… relative paths
Buttons/*.png the user's own menu artwork — app reads only
plays/andtunes-<installid>.json the app writes counts back here (phase 3)
```
```json
{"format":1,"generated":"…","generator":"lintunes 0.17.0",
"tracks":[{"id":123,"title":"Alhambra","path":"Media/…/04 Alhambra.mp3",
"artist":"Ahmad Jamal","album":"…","year":1961,"track":4,
"secs":214,"art":"Art/3f2a1b9c.jpg"}],
"playlists":[{"id":"A1B2C3D4","name":"Roadtrip","tracks":[123,456]}]}
```
Rows are **sparse** — empty and zero fields are omitted, so use
`optString`/`optInt`. Artists and albums are deliberately *not* shipped: group
the flat list in one pass at load rather than parsing three redundant lists.
Titles are stored in full even when the filename was truncated, so always
display `title`, never the filename.
## Testing without a Rabbit
`python3 scripts/make_dev_library.py --out dev-library` builds a synthetic
LinTunes library, and syncing it produces a real `Music/andTunes/` tree you can
push onto any Android device or emulator with `adb push`. That tree includes
the awkward cases on purpose — an emoji album name, a 208-character title, two
tracks that differ only by id, a track with no artist.
## Board
`TASKS.md` in this directory.