215 lines
9.7 KiB
Markdown
215 lines
9.7 KiB
Markdown
# 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). Round 51 (andTunes 0.2.0, LinTunes v0.20.0) finished
|
||
phase 3: play counts come back to LinTunes on every sync, and songs Android
|
||
can't play are converted to FLAC or, for FairPlay, left out and reported.
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
## Device facts
|
||
|
||
`DEVICE.md` in this directory: screen density, wheel keycodes, USB modes.
|