From 9483d8951b8fdc8057a374e7a2059eae1467ba28 Mon Sep 17 00:00:00 2001 From: trav Date: Fri, 11 Sep 2026 14:25:55 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9 --- .gitignore | 4 + CLAUDE.md | 18 + andtunes/README.md | 73 ++- andtunes/TASKS.md | 119 ++-- andtunes/andtunes.keystore | Bin 0 -> 2604 bytes andtunes/app/src/main/AndroidManifest.xml | 40 ++ .../java/me/teafry/andtunes/BaseActivity.java | 109 ++++ .../main/java/me/teafry/andtunes/Library.java | 312 ++++++++++ .../java/me/teafry/andtunes/ListActivity.java | 400 ++++++++++++ .../java/me/teafry/andtunes/MenuActivity.java | 173 ++++++ .../teafry/andtunes/NowPlayingActivity.java | 207 +++++++ .../me/teafry/andtunes/PlaybackService.java | 581 ++++++++++++++++++ .../src/main/java/me/teafry/andtunes/Ui.java | 152 +++++ .../src/main/res/drawable-nodpi/albums.png | Bin 0 -> 4282 bytes .../src/main/res/drawable-nodpi/artists.png | Bin 0 -> 3589 bytes .../main/res/drawable-nodpi/ic_launcher.png | Bin 0 -> 3231 bytes .../src/main/res/drawable-nodpi/ic_notify.png | Bin 0 -> 784 bytes .../app/src/main/res/drawable-nodpi/next.png | Bin 0 -> 772 bytes .../main/res/drawable-nodpi/now_playing.png | Bin 0 -> 6321 bytes .../app/src/main/res/drawable-nodpi/pause.png | Bin 0 -> 351 bytes .../app/src/main/res/drawable-nodpi/play.png | Bin 0 -> 830 bytes .../src/main/res/drawable-nodpi/playlists.png | Bin 0 -> 4919 bytes .../app/src/main/res/drawable-nodpi/prev.png | Bin 0 -> 748 bytes .../src/main/res/drawable-nodpi/search.png | Bin 0 -> 4556 bytes .../main/res/drawable-nodpi/shuffle_all.png | Bin 0 -> 4698 bytes .../app/src/main/res/drawable-nodpi/songs.png | Bin 0 -> 4769 bytes andtunes/app/src/main/res/values/styles.xml | 16 + andtunes/build.py | 155 +++++ andtunes/proguard.pro | 9 + andtunes/tools/make_buttons.py | 135 ++++ lintunes/__init__.py | 2 +- lintunes/android/__init__.py | 6 + lintunes/android/andTunes.apk | Bin 0 -> 62871 bytes lintunes/android/andTunes.json | 1 + lintunes/andtunes/buttons/albums.png | Bin 0 -> 4282 bytes lintunes/andtunes/buttons/artists.png | Bin 0 -> 3589 bytes lintunes/andtunes/buttons/next.png | Bin 0 -> 772 bytes lintunes/andtunes/buttons/now_playing.png | Bin 0 -> 6321 bytes lintunes/andtunes/buttons/pause.png | Bin 0 -> 351 bytes lintunes/andtunes/buttons/play.png | Bin 0 -> 830 bytes lintunes/andtunes/buttons/playlists.png | Bin 0 -> 4919 bytes lintunes/andtunes/buttons/prev.png | Bin 0 -> 748 bytes lintunes/andtunes/buttons/search.png | Bin 0 -> 4556 bytes lintunes/andtunes/buttons/shuffle_all.png | Bin 0 -> 4698 bytes lintunes/andtunes/buttons/songs.png | Bin 0 -> 4769 bytes lintunes/andtunes/install.py | 180 ++++++ lintunes/andtunes/sync.py | 40 +- lintunes/gui/main_window.py | 81 +++ setup.py | 8 +- tasks-done.md | 55 ++ tests/test_round50.py | 185 ++++++ 51 files changed, 2963 insertions(+), 98 deletions(-) create mode 100644 andtunes/andtunes.keystore create mode 100644 andtunes/app/src/main/AndroidManifest.xml create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/Library.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java create mode 100644 andtunes/app/src/main/java/me/teafry/andtunes/Ui.java create mode 100644 andtunes/app/src/main/res/drawable-nodpi/albums.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/artists.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/ic_notify.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/next.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/now_playing.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/pause.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/play.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/playlists.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/prev.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/search.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/shuffle_all.png create mode 100644 andtunes/app/src/main/res/drawable-nodpi/songs.png create mode 100644 andtunes/app/src/main/res/values/styles.xml create mode 100644 andtunes/build.py create mode 100644 andtunes/proguard.pro create mode 100644 andtunes/tools/make_buttons.py create mode 100644 lintunes/android/__init__.py create mode 100644 lintunes/android/andTunes.apk create mode 100644 lintunes/android/andTunes.json create mode 100644 lintunes/andtunes/buttons/albums.png create mode 100644 lintunes/andtunes/buttons/artists.png create mode 100644 lintunes/andtunes/buttons/next.png create mode 100644 lintunes/andtunes/buttons/now_playing.png create mode 100644 lintunes/andtunes/buttons/pause.png create mode 100644 lintunes/andtunes/buttons/play.png create mode 100644 lintunes/andtunes/buttons/playlists.png create mode 100644 lintunes/andtunes/buttons/prev.png create mode 100644 lintunes/andtunes/buttons/search.png create mode 100644 lintunes/andtunes/buttons/shuffle_all.png create mode 100644 lintunes/andtunes/buttons/songs.png create mode 100644 lintunes/andtunes/install.py create mode 100644 tests/test_round50.py diff --git a/.gitignore b/.gitignore index 16b8737..18296ea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ itunes-test-library/ # Synthetic library for development (scripts/make_dev_library.py) /dev-library/ + +# andTunes build output (andtunes/build.py); the shipped APK lives in +# lintunes/android/ and *is* committed +/andtunes/build/ diff --git a/CLAUDE.md b/CLAUDE.md index 922efb6..d0eb360 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -325,6 +325,24 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal `device_sync` (so it rides the Syncthing share and both machines agree); keeping it off the `Playlist` keeps it out of the conflict-merge machinery, and **unticking is the only way a playlist comes off the device**. + Since Round 50 the app exists: `andtunes/app/` is plain Java against the + Android framework, built by `andtunes/build.py` (aapt2 → javac → R8 → + zipalign → apksigner) — **no Gradle, no Kotlin**, because platform 33 + + build-tools 34 were already on disk and trav didn't want a 1 GB download + over cell for tools that make the same APK. `--ship` copies it to + `lintunes/android/andTunes.apk` with its version in `andTunes.json`; + **re-run `--ship` whenever the app changes**, since the committed APK is + what the self-updater carries and what `install.py` installs. + `andtunes/andtunes.keystore` is committed on purpose (both machines must + sign identically or `adb install -r` refuses the update). `install.py` is + Connections → Install andTunes on Rabbit…: adb as a runtime tool, then the + all-files + notification grants (a refused grant is not a failed install — + the app's first-run screen covers it), or an MTP copy to `Download/` with + no adb. Sync copies `buttons/*.png` (rendered by + `andtunes/tools/make_buttons.py`) into the device's `Buttons/` **only + where the file is missing** — those names are a contract with the app's + `Ui.button()`, which prefers the device copy. `proguard.pro` keeps no + debug attributes because R8 8.2 NPEs on javac 21's. - **`lintunes/export/`** — `File → Export Playlist…` (also on a playlist's right-click menu). A sibling of `device_sync`, reusing its filename helpers diff --git a/andtunes/README.md b/andtunes/README.md index d40dc3f..3ac3907 100644 --- a/andtunes/README.md +++ b/andtunes/README.md @@ -15,10 +15,12 @@ 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-10): the desktop half is done and shipped** in LinTunes -v0.17.0 (`lintunes/andtunes/`, round 47). The Android app has not been -started. Work is paused on a slow connection — see *Toolchain* below for the -one blocking download. +**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). --- @@ -89,49 +91,51 @@ screen where the stock behaviour is the useful one. ## Toolchain -Already done on this machine: +Everything is already on this machine, and **nothing else gets downloaded**: -- `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also - installed; AGP won't run on it, so the Gradle build must be pointed at 21.) +- `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. -**The one thing left, and the reason this is paused: the Android SDK is a -~1–1.5 GB download** and trav is on a very weak connection. +**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. -```sh -# 1. commandline-tools (~150 MB) from developer.android.com/studio -# (bottom of the page, "Command line tools only", linux zip) -mkdir -p ~/Android/Sdk/cmdline-tools -unzip commandlinetools-linux-*_latest.zip -d ~/Android/Sdk/cmdline-tools -mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest +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. -export ANDROID_HOME="$HOME/Android/Sdk" -export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" -export JAVA_HOME=/usr/lib/jvm/java-21-openjdk +`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. -# 2. the platform we build against (~150 MB total) -sdkmanager --licenses -sdkmanager "platforms;android-33" "build-tools;34.0.0" -``` - -Then the **first** Gradle build pulls the wrapper (~130 MB) and the Android -Gradle Plugin's dependencies (several hundred MB). After that it's all cached -and `./gradlew --offline assembleDebug` works with no connection at all — so -the download is a one-time cost, but it is not a small one. Do it on a decent -connection. +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 (once the SDK is there) +## Building and installing ```sh -cd andtunes -./gradlew assembleDebug -adb install -r app/build/outputs/apk/debug/app-debug.apk -adb shell am start -n me.teafry.andtunes/.MenuActivity +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 @@ -143,7 +147,8 @@ Target: menu visible in **under 400 ms**. ## Design decisions already locked -- **Kotlin, no Compose, no androidx, no Media3.** Framework `Activity`, +- **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 diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index cdb0304..a5917b8 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -6,11 +6,10 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes Legend: `[ ]` todo · `[~]` in progress · `[x]` done. -**Status: paused 2026-09-10** on a weak connection — the Android SDK is a -~1–1.5 GB download. Everything needed to resume cold is in `README.md`: -device facts, toolchain commands, locked design decisions, and the exact -shape of the `library.json` LinTunes already writes. Phases are andTunes' -own; which LinTunes round each lands in is decided when it starts. +**Status: 0.1.0 plays music (2026-09-11, LinTunes round 50).** Built with +`python3 andtunes/build.py` — plain Java, no Gradle, nothing downloaded (see +`README.md`, *Toolchain*). Phases are andTunes' own; which LinTunes round +each lands in is decided when it starts. ## Device facts (measured 2026-09-06, `adb shell wm size` etc.) @@ -21,76 +20,76 @@ own; which LinTunes round each lands in is decided when it starts. row would be 4.4 mm tall. Do not "fix" this by changing the device density — trav likes it where it is. - Android 13 / API 33, `gsi_r1-userdebug`, arm64-v8a, 65 GB free. -- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN** - (`KEYCODE_DPAD_UP` / `DPAD_DOWN`). Side/PTT button sits on `mtk-kpd` with - KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession - gets media keys for free. +- Scroll wheel = input device `och1970_holl_key` → **KEY_UP / KEY_DOWN**, + mapped by the stock `Generic.kl` (checked 2026-09-11 via `dumpsys input`) + to **`KEYCODE_DPAD_UP` / `DPAD_DOWN`** — not volume. Side/PTT button sits + on `mtk-kpd` with KEY_VOLUMEDOWN + KEY_POWER. Headset exposes + KEY_PLAYPAUSE, so a MediaSession gets media keys for free. +- USB: set to come up as **`mtp,adb`** whenever the screen is unlocked + (`adb shell svc usb setScreenUnlockedFunctions mtp`, 2026-09-11), so the + gvfs mount sync uses and the adb install uses are both there at once. -## Phase 1 — plays a song (v0.1.0) +## Phase 1 — plays a song (v0.1.0) ✔ - [x] `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also - present; AGP won't run on it, so point the build at 21.) -- [ ] **Android SDK — the one blocker.** commandline-tools + platform 33 + - build-tools into `~/Android/Sdk`, then a first Gradle build to warm the - wrapper and AGP caches. ~1–1.5 GB all told; after that - `./gradlew --offline assembleDebug` needs no connection. Exact commands - in `README.md`. -- [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33. - **No Compose, no androidx, no Media3** — framework `Activity`, - `ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, - R8 minify. Cold start is the whole feature; there is nothing to - initialize if nothing is linked in. -- [ ] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends 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. -- [ ] Library loader: parse `library.json` off the main thread into a - singleton that survives between activities. Refuse an unknown `format` - with a readable message rather than half-reading it. -- [ ] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads + present; `build.py` points at 21.) +- [x] Android SDK: platform 33 + build-tools 34.0.0 were already in + `~/Android/Sdk`. **No Gradle, no Kotlin** — `build.py` runs aapt2 → + javac → R8 → zipalign → apksigner directly (trav, 2026-09-11: don't + pull a GB over cell if it isn't needed; it isn't). +- [x] Project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33, + framework only: `Activity`, `ListView`, `MediaPlayer`, `MediaSession`, + `Notification.MediaStyle`, R8. The APK is 61 KiB. +- [x] `MANAGE_EXTERNAL_STORAGE` + a first-run screen that sends the user to + the toggle (LinTunes' installer grants it over adb, so it rarely shows). +- [x] Library loader: `android.util.JsonReader` streaming parse off the main + thread into a process singleton; artists/albums grouped in one pass; + an unknown `format` gets a readable message; reloads when + `library.json` changes (a re-sync) the next time the menu resumes. +- [x] `MenuActivity`: six tiles, 2 × 3, **loads nothing**. The sixth reads "Now Playing" or "Shuffle All" depending on the service. -- [ ] `ListActivity` in songs mode + "Shuffle all" header row. -- [ ] `PlaybackService` (foreground) + `NowPlayingActivity`: white - background, square art, slim scrubber, ◀ ⏯ ▶, shuffle. **No back - button** — Android's own is always on screen. -- [ ] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification. -- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one. -- [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms. +- [x] `ListActivity` in songs mode + "Shuffle all" header row. +- [x] `PlaybackService` (foreground) + `NowPlayingActivity`: white + background, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat. + **No back button.** +- [x] Audio focus, ACTION_AUDIO_BECOMING_NOISY, MediaSession + notification. +- [x] End of queue **pauses** unless repeat is on. Repeat: off / all / one. +- [x] `adb shell am start -W`: **313–338 ms** cold to the menu (551 ms on the + very first launch after install, which includes dex verification). -## Phase 2 — the rest of the screens +## Phase 2 — the rest of the screens ✔ -- [ ] Playlists, artists (→ albums + "All songs" + shuffle for that artist), - albums, search. -- [ ] Album art thumbs in album rows, full art on now playing. -- [ ] The "Now playing" bar at the bottom of every list screen. -- [ ] Scroll wheel → list scrolling, and volume on the now-playing screen. - Consume `KEYCODE_VOLUME_UP/DOWN` **and** `DPAD_UP/DOWN` in both - `onKeyDown` and `onKeyUp` — the ROM's key layout decides which arrives, - and claiming them in a focused activity affects no other app. First - check `/system/usr/keylayout/` on the device; see `README.md`. -- [ ] Replaceable button images: load `Buttons/.png` from the device - folder when present, else the bundled drawable. LinTunes ships defaults - into `Buttons/` only when the file is missing, so the user's own - artwork is never overwritten. -- [ ] Missing file (synced then deleted) skips with a toast, doesn't crash +- [x] Playlists, artists (→ "All songs" + albums; all songs has its own + shuffle), albums, search (artists, albums and songs as you type). +- [x] Album art thumbs in album rows, full art on now playing. +- [x] The "Now playing" bar at the bottom of every list screen; the playing + song is drawn inverted in any list it appears in. +- [x] Scroll wheel → list scrolling (one row per detent), volume on the menu + and now-playing screens. Taken in `dispatchKeyEvent`, DPAD *and* + VOLUME codes, down *and* up, so the search box can't swallow it. +- [x] Replaceable button images: `Buttons/.png` beats the bundled + drawable, for the six menu tiles plus ◀ ▶ ⏯. LinTunes ships the + defaults into `Buttons/` only where the file is missing. +- [x] Missing file (synced then deleted) skips with a toast, doesn't stop the queue. -- [ ] Resume last track + position on launch. +- [x] Resume last queue + track + position on launch (paused). ## Phase 3 — install from LinTunes, counts coming back -- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")` - (the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then - `adb install -r`; fall back to copying the APK over MTP for a manual - tap. APK committed at `lintunes/android/andTunes.apk` with - `package_data`, read via `importlib.resources` like `export/templates`. +- [x] `Connections → Install andTunes on Rabbit…` (LinTunes 0.19.0): + `adb install -r` + the two permission grants; says Install / Update / + Reinstall from the version on the device; with no adb, copies the APK + to `Download/` over MTP for a manual tap. APK committed at + `lintunes/android/andTunes.apk` (+ `andTunes.json`, its version). - [ ] Format gate shaped like `export/web_support.py`: deny-by-default on - suffix, AIFF and protected AAC refused, anything else unplayable - converted to **FLAC** (never a lossy re-encode). + suffix, protected AAC refused, anything else unplayable converted to + **FLAC** (never a lossy re-encode). - [ ] Play counts back: the app writes `plays/andtunes-.json` in the per-machine-totals shape of `storage/play_journal.py`; sync copies it back and folds it in. The R1 becomes one more machine in the Round 38 model — no new merge rules. -- [ ] Retire `Sync Playlist to Rabbit (Auxio)` once andTunes covers it, and - offer a one-off cleanup of the old `Music//` folders. +- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it. + (trav's R1 has no old `Music//` folders, so no cleanup.) ## Parked diff --git a/andtunes/andtunes.keystore b/andtunes/andtunes.keystore new file mode 100644 index 0000000000000000000000000000000000000000..1a774c08890c38f18313d80c9edcda5f3532ca50 GIT binary patch literal 2604 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 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java new file mode 100644 index 0000000..c865896 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/BaseActivity.java @@ -0,0 +1,109 @@ +package me.teafry.andtunes; + +import android.app.Activity; +import android.content.Intent; +import android.media.AudioManager; +import android.os.Bundle; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * What every andTunes screen shares: the scroll wheel, and the "Now playing" + * bar along the bottom. + * + *

The R1's wheel arrives as DPAD_UP / DPAD_DOWN (its key layout is the stock + * Generic.kl), but a ROM could map it to VOLUME_UP / DOWN instead, so both are + * claimed. They're taken in {@link #dispatchKeyEvent} — before any focused view + * (the search box, a list) can swallow them — and on key-up as well as + * key-down, or the system can still act on the release. This only applies + * while an andTunes screen is in front; volume behaves normally everywhere else. + */ +abstract class BaseActivity extends Activity implements PlaybackService.Listener { + protected AudioManager audio; + private TextView nowBar; + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + audio = getSystemService(AudioManager.class); + setVolumeControlStream(AudioManager.STREAM_MUSIC); + } + + /** Lay out {@code content} above a now-playing bar (shown only when useful). */ + protected void setScreen(View content, boolean withNowBar) { + LinearLayout column = new LinearLayout(this); + column.setOrientation(LinearLayout.VERTICAL); + column.setBackgroundColor(Ui.WHITE); + column.addView(content, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + if (withNowBar) { + nowBar = Ui.text(this, 24, true, Ui.WHITE); + nowBar.setBackgroundColor(Ui.BLACK); + nowBar.setGravity(Gravity.CENTER_VERTICAL); + int pad = Ui.dp(this, 20); + nowBar.setPadding(pad, 0, pad, 0); + nowBar.setOnClickListener(v -> openNowPlaying()); + column.addView(nowBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 80))); + } + setContentView(column); + } + + protected void openNowPlaying() { + startActivity(new Intent(this, NowPlayingActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)); + } + + @Override protected void onResume() { + super.onResume(); + PlaybackService.addListener(this); + onPlaybackChanged(); + } + + @Override protected void onPause() { + PlaybackService.removeListener(this); + super.onPause(); + } + + @Override public void onPlaybackChanged() { + if (nowBar == null) return; + PlaybackService s = PlaybackService.get(); + Library.Track t = s != null ? s.current() : null; + if (t == null) { + nowBar.setVisibility(View.GONE); + return; + } + nowBar.setVisibility(View.VISIBLE); + nowBar.setText((s.isPlaying() ? "Now playing: " : "Paused: ") + t.title); + } + + @Override public boolean dispatchKeyEvent(KeyEvent e) { + int dir = wheelDirection(e.getKeyCode()); + if (dir == 0) return super.dispatchKeyEvent(e); + if (e.getAction() == KeyEvent.ACTION_DOWN) onWheel(dir); + return true; + } + + static int wheelDirection(int code) { + switch (code) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_VOLUME_UP: + return 1; + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_VOLUME_DOWN: + return -1; + default: + return 0; + } + } + + /** One wheel detent. Volume unless the screen has something to scroll. */ + protected void onWheel(int dir) { + audio.adjustStreamVolume(AudioManager.STREAM_MUSIC, + dir > 0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER, + AudioManager.FLAG_SHOW_UI); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Library.java b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java new file mode 100644 index 0000000..3b4941b --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Library.java @@ -0,0 +1,312 @@ +package me.teafry.andtunes; + +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.util.JsonReader; +import android.util.JsonToken; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; + +/** + * Everything andTunes knows, read from the one file LinTunes wrote. + * + * This is the whole bet: no MediaStore, no directory walk, no tag reads. + * {@code library.json} is parsed with a streaming reader on a background + * thread (kicked off by the menu, which itself loads nothing), then artists + * and albums are grouped from the flat track list in one pass. The result is + * immutable and lives for the life of the process, so every screen after the + * first is instant. + */ +final class Library { + static final int FORMAT = 1; + static final String UNKNOWN_ARTIST = "Unknown Artist"; + static final String UNKNOWN_ALBUM = "Unknown Album"; + + static final class Track { + long id; + String title = "", path = "", artist = "", albumArtist = "", album = "", art = ""; + int year, number, disc, secs; + String sortKey, haystack; + Album albumRef; + + File file() { return new File(root(), path); } + String artistLabel() { return artist.isEmpty() ? UNKNOWN_ARTIST : artist; } + } + + static final class Album { + String key, name, artist, art = ""; + int year; + String sortKey; + final ArrayList tracks = new ArrayList<>(); + } + + static final class Artist { + String name, sortKey; + final ArrayList albums = new ArrayList<>(); + final ArrayList tracks = new ArrayList<>(); // alphabetical + } + + static final class Playlist { + String id, name; + final ArrayList tracks = new ArrayList<>(); // playing order + } + + interface Listener { void onLibrary(Library library, String error); } + + final ArrayList tracks = new ArrayList<>(); // alphabetical + final ArrayList artists = new ArrayList<>(); + final ArrayList albums = new ArrayList<>(); + final ArrayList playlists = new ArrayList<>(); + final HashMap byId = new HashMap<>(); + final HashMap artistsByName = new HashMap<>(); + final HashMap albumsByKey = new HashMap<>(); + long stamp; // library.json mtime + size, to notice a re-sync + + // ---- process-wide instance ---- + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static Library sLibrary; + private static String sError; + private static boolean sLoading; + private static final ArrayList sWaiting = new ArrayList<>(); + + static File root() { + return new File(Environment.getExternalStorageDirectory(), "Music/andTunes"); + } + + static File manifest() { return new File(root(), "library.json"); } + + static Library get() { return sLibrary; } + + /** Start loading if nothing is loaded yet, or the file changed under us. */ + static void preload() { + if (sLoading) return; + if (sLibrary != null && sLibrary.stamp == stampOf(manifest())) return; + sLoading = true; + new Thread(() -> { + Library lib = null; + String err = null; + try { + lib = load(manifest()); + } catch (IOException | RuntimeException e) { + err = describe(e); + } + final Library done = lib; + final String error = err; + MAIN.post(() -> { + sLoading = false; + if (done != null) { sLibrary = done; sError = null; } + else if (sLibrary == null) sError = error; + ArrayList waiting = new ArrayList<>(sWaiting); + sWaiting.clear(); + for (Listener l : waiting) l.onLibrary(sLibrary, sError); + }); + }, "library-load").start(); + } + + /** Call back on the main thread once a library (or an error) is ready. */ + static void whenReady(Listener listener) { + if (!sLoading && (sLibrary != null || sError != null)) { + listener.onLibrary(sLibrary, sError); + return; + } + sWaiting.add(listener); + preload(); + } + + static void forget(Listener listener) { sWaiting.remove(listener); } + + private static long stampOf(File f) { return f.lastModified() * 31 + f.length(); } + + private static String describe(Exception e) { + if (e instanceof java.io.FileNotFoundException) { + return "No music yet.\n\nIn LinTunes: Connections → Sync to Rabbit."; + } + String msg = e.getMessage(); + return msg != null && msg.startsWith("andTunes:") ? msg.substring(9) + : "Couldn't read library.json:\n" + e; + } + + // ---- parsing ---- + + static Library load(File file) throws IOException { + Library lib = new Library(); + lib.stamp = stampOf(file); + HashMap> playlistIds = new HashMap<>(); + int format = -1; + try (JsonReader r = new JsonReader(new InputStreamReader( + new FileInputStream(file), StandardCharsets.UTF_8))) { + r.beginObject(); + while (r.hasNext()) { + String key = r.nextName(); + if (key.equals("format")) { + format = r.nextInt(); + checkFormat(format); + } else if (key.equals("tracks")) { + r.beginArray(); + while (r.hasNext()) lib.addTrack(readTrack(r)); + r.endArray(); + } else if (key.equals("playlists")) { + r.beginArray(); + while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds)); + r.endArray(); + } else { + r.skipValue(); + } + } + r.endObject(); + } + checkFormat(format); + for (Playlist p : lib.playlists) { + for (Long id : playlistIds.get(p.id)) { + Track t = lib.byId.get(id); + if (t != null) p.tracks.add(t); + } + } + lib.group(); + return lib; + } + + private static void checkFormat(int format) { + if (format != FORMAT) { + throw new IllegalStateException("andTunes:This library was written in format " + + format + ", and this andTunes only reads format " + FORMAT + + ".\n\nIn LinTunes: Connections → Install andTunes on Rabbit."); + } + } + + private static Track readTrack(JsonReader r) throws IOException { + Track t = new Track(); + r.beginObject(); + while (r.hasNext()) { + String key = r.nextName(); + if (r.peek() == JsonToken.NULL) { r.skipValue(); continue; } + switch (key) { + case "id": t.id = r.nextLong(); break; + case "title": t.title = r.nextString(); break; + case "path": t.path = r.nextString(); break; + case "artist": t.artist = r.nextString(); break; + case "album_artist": t.albumArtist = r.nextString(); break; + case "album": t.album = r.nextString(); break; + case "art": t.art = r.nextString(); break; + case "year": t.year = r.nextInt(); break; + case "track": t.number = r.nextInt(); break; + case "disc": t.disc = r.nextInt(); break; + case "secs": t.secs = r.nextInt(); break; + default: r.skipValue(); + } + } + r.endObject(); + if (t.title.isEmpty()) { + String name = new File(t.path).getName(); + int dot = name.lastIndexOf('.'); + t.title = dot > 0 ? name.substring(0, dot) : name; + } + return t; + } + + private static Playlist readPlaylist(JsonReader r, HashMap> ids) + throws IOException { + Playlist p = new Playlist(); + ArrayList members = new ArrayList<>(); + r.beginObject(); + while (r.hasNext()) { + String key = r.nextName(); + switch (key) { + case "id": p.id = r.nextString(); break; + case "name": p.name = r.nextString(); break; + case "tracks": + r.beginArray(); + while (r.hasNext()) members.add(r.nextLong()); + r.endArray(); + break; + default: r.skipValue(); + } + } + r.endObject(); + if (p.id == null) p.id = "p" + ids.size(); + if (p.name == null) p.name = "Untitled"; + ids.put(p.id, members); + return p; + } + + private void addTrack(Track t) { + t.sortKey = sortKey(t.title); + t.haystack = (t.title + "\n" + t.artist + "\n" + t.albumArtist + "\n" + t.album) + .toLowerCase(Locale.ROOT); + tracks.add(t); + byId.put(t.id, t); + } + + /** Artists and albums, grouped from the flat list in one pass. */ + private void group() { + Collections.sort(tracks, (a, b) -> a.sortKey.compareTo(b.sortKey)); + for (Track t : tracks) { + // Grouped by album artist when there is one, the way the desktop + // files them, so a compilation stays one album under one name. + String who = !t.albumArtist.isEmpty() ? t.albumArtist + : !t.artist.isEmpty() ? t.artist : UNKNOWN_ARTIST; + String albumName = t.album.isEmpty() ? UNKNOWN_ALBUM : t.album; + String key = who.toLowerCase(Locale.ROOT) + "" + + albumName.toLowerCase(Locale.ROOT); + Album album = albumsByKey.get(key); + if (album == null) { + album = new Album(); + album.key = key; + album.name = albumName; + album.artist = who; + album.sortKey = sortKey(albumName); + albumsByKey.put(key, album); + albums.add(album); + Artist artist = artistsByName.get(who); + if (artist == null) { + artist = new Artist(); + artist.name = who; + artist.sortKey = sortKey(who); + artistsByName.put(who, artist); + artists.add(artist); + } + artist.albums.add(album); + } + album.tracks.add(t); + if (album.art.isEmpty() && !t.art.isEmpty()) album.art = t.art; + if (t.year > 0 && (album.year == 0 || t.year < album.year)) album.year = t.year; + t.albumRef = album; + artistsByName.get(who).tracks.add(t); + } + for (Album a : albums) { + Collections.sort(a.tracks, (x, y) -> x.disc != y.disc ? x.disc - y.disc + : x.number != y.number ? x.number - y.number + : x.sortKey.compareTo(y.sortKey)); + } + Collections.sort(albums, (a, b) -> a.sortKey.compareTo(b.sortKey)); + Collections.sort(artists, (a, b) -> a.sortKey.compareTo(b.sortKey)); + for (Artist a : artists) { + // An artist's own albums read best oldest first. + Collections.sort(a.albums, (x, y) -> x.year != y.year ? x.year - y.year + : x.sortKey.compareTo(y.sortKey)); + } + } + + /** iTunes-style: case-folded, with a leading "The", "A" or "An" ignored. */ + static String sortKey(String s) { + String k = s.trim().toLowerCase(Locale.ROOT); + for (String article : new String[] {"the ", "a ", "an "}) { + if (k.startsWith(article) && k.length() > article.length()) { + k = k.substring(article.length()); + break; + } + } + return k; + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java new file mode 100644 index 0000000..a21f7a0 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/ListActivity.java @@ -0,0 +1,400 @@ +package me.teafry.andtunes; + +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputType; +import android.text.TextWatcher; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.widget.BaseAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.Locale; + +/** + * Every list in the app, parameterised by {@link #MODE}. Screens are separate + * activities so "go back to where I was" is just Android's back stack. + */ +public class ListActivity extends BaseActivity { + static final String MODE = "mode", ID = "id"; + static final String PLAYLISTS = "playlists", PLAYLIST = "playlist", + ARTISTS = "artists", ARTIST = "artist", ARTIST_SONGS = "artist_songs", + ALBUMS = "albums", ALBUM = "album", SONGS = "songs", SEARCH = "search"; + + private static final int SHUFFLE = 0, SONG = 1, PLAYLIST_ROW = 2, ARTIST_ROW = 3, + ALBUM_ROW = 4, ALL_SONGS = 5, HEADER = 6, NOTE = 7; + private static final int SEARCH_CAP = 300; + + private static final class Row { + final int kind; + final String title, sub; + final Object ref; // Track / Playlist / Artist / Album + final int songIndex; // into songs, for SONG rows + Row(int kind, String title, String sub, Object ref, int songIndex) { + this.kind = kind; this.title = title; this.sub = sub; + this.ref = ref; this.songIndex = songIndex; + } + } + + private String mode, id; + private TextView titleBar; + private ListView list; + private EditText searchBox; + private final RowAdapter adapter = new RowAdapter(); + private final ArrayList rows = new ArrayList<>(); + private final ArrayList songs = new ArrayList<>(); + private Library library; + private final Library.Listener onLoaded = (lib, error) -> populate(lib, error); + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + mode = getIntent().getStringExtra(MODE); + id = getIntent().getStringExtra(ID); + if (mode == null) mode = SONGS; + + LinearLayout column = new LinearLayout(this); + column.setOrientation(LinearLayout.VERTICAL); + + titleBar = Ui.text(this, 26, true, Ui.WHITE); + titleBar.setBackgroundColor(Ui.BLACK); + titleBar.setGravity(Gravity.CENTER_VERTICAL); + int pad = Ui.dp(this, 20); + titleBar.setPadding(pad, 0, pad, 0); + column.addView(titleBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64))); + + if (SEARCH.equals(mode)) column.addView(buildSearchBox()); + + list = new ListView(this); + list.setDivider(new ColorDrawable(Ui.BLACK)); + list.setDividerHeight(Ui.dp(this, 1)); + list.setBackgroundColor(Ui.WHITE); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position))); + column.addView(list, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + setScreen(column, true); + titleBar.setText(defaultTitle()); + rows.add(new Row(NOTE, "Loading…", null, null, -1)); + Library.whenReady(onLoaded); + } + + @Override protected void onDestroy() { + Library.forget(onLoaded); + super.onDestroy(); + } + + @Override public void onPlaybackChanged() { + super.onPlaybackChanged(); + adapter.notifyDataSetChanged(); // re-mark the playing row + } + + @Override protected void onWheel(int dir) { + list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP)); + } + + private String defaultTitle() { + switch (mode) { + case PLAYLISTS: return "Playlists"; + case ARTISTS: return "Artists"; + case ALBUMS: return "Albums"; + case SEARCH: return "Search"; + default: return "Songs"; + } + } + + // ---- building the rows ---- + + private void populate(Library lib, String error) { + library = lib; + rows.clear(); + songs.clear(); + if (lib == null) { + rows.add(new Row(NOTE, "No library", error, null, -1)); + adapter.notifyDataSetChanged(); + return; + } + switch (mode) { + case PLAYLISTS: + for (Library.Playlist p : lib.playlists) { + rows.add(new Row(PLAYLIST_ROW, p.name, count(p.tracks.size()), p, -1)); + } + if (lib.playlists.isEmpty()) note("No playlists synced yet"); + break; + case PLAYLIST: { + Library.Playlist p = findPlaylist(lib); + if (p == null) { note("This playlist isn't on the Rabbit anymore"); break; } + titleBar.setText(p.name); + songList(p.tracks, "Shuffle", false); + break; + } + case ARTISTS: + for (Library.Artist a : lib.artists) { + rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1)); + } + break; + case ARTIST: { + Library.Artist a = lib.artistsByName.get(id); + if (a == null) { note("Not in the library anymore"); break; } + titleBar.setText(a.name); + rows.add(new Row(ALL_SONGS, "All songs", count(a.tracks.size()), a, -1)); + for (Library.Album al : a.albums) { + rows.add(new Row(ALBUM_ROW, al.name, + al.year > 0 ? String.valueOf(al.year) : count(al.tracks.size()), al, -1)); + } + break; + } + case ARTIST_SONGS: { + Library.Artist a = lib.artistsByName.get(id); + if (a == null) { note("Not in the library anymore"); break; } + titleBar.setText(a.name); + songList(a.tracks, "Shuffle all", false); + break; + } + case ALBUMS: + for (Library.Album al : lib.albums) { + rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1)); + } + break; + case ALBUM: { + Library.Album al = lib.albumsByKey.get(id); + if (al == null) { note("Not in the library anymore"); break; } + titleBar.setText(al.name); + songList(al.tracks, "Shuffle", true); + break; + } + case SEARCH: + search(searchBox.getText().toString()); + return; + default: + songList(lib.tracks, "Shuffle all", false); + } + adapter.notifyDataSetChanged(); + } + + private void songList(ArrayList tracks, String shuffleLabel, boolean numbered) { + if (tracks.isEmpty()) { note("No songs"); return; } + rows.add(new Row(SHUFFLE, shuffleLabel, count(tracks.size()), null, -1)); + for (Library.Track t : tracks) addSong(t, numbered); + } + + private void addSong(Library.Track t, boolean numbered) { + String sub; + if (numbered) { + sub = (t.number > 0 ? t.number + " · " : "") + (t.secs > 0 ? Ui.time(t.secs * 1000) : ""); + } else { + sub = t.artistLabel(); + } + rows.add(new Row(SONG, t.title, sub, t, songs.size())); + songs.add(t); + } + + private void note(String text) { rows.add(new Row(NOTE, text, null, null, -1)); } + + private Library.Playlist findPlaylist(Library lib) { + for (Library.Playlist p : lib.playlists) if (p.id.equals(id)) return p; + return null; + } + + private static String count(int n) { return n == 1 ? "1 song" : n + " songs"; } + + // ---- search ---- + + private View buildSearchBox() { + searchBox = new EditText(this); + searchBox.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28); + searchBox.setTextColor(Ui.BLACK); + searchBox.setHintTextColor(Ui.DIM); + searchBox.setHint("Song, artist or album"); + searchBox.setSingleLine(true); + searchBox.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + searchBox.setImeOptions(EditorInfo.IME_ACTION_SEARCH); + GradientDrawable border = new GradientDrawable(); + border.setColor(Ui.WHITE); + border.setStroke(Ui.dp(this, 3), Ui.BLACK); + searchBox.setBackground(border); + int pad = Ui.dp(this, 16); + searchBox.setPadding(pad, pad, pad, pad); + searchBox.addTextChangedListener(new TextWatcher() { + @Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void onTextChanged(CharSequence s, int a, int b, int c) { } + @Override public void afterTextChanged(Editable s) { + if (library != null) search(s.toString()); + } + }); + searchBox.setOnEditorActionListener((v, action, event) -> { + getSystemService(InputMethodManager.class).hideSoftInputFromWindow(v.getWindowToken(), 0); + return true; + }); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); + searchBox.requestFocus(); + return searchBox; + } + + private void search(String raw) { + rows.clear(); + songs.clear(); + String q = raw.trim().toLowerCase(Locale.ROOT); + if (q.isEmpty()) { + adapter.notifyDataSetChanged(); + return; + } + int before = rows.size(); + rows.add(new Row(HEADER, "Artists", null, null, -1)); + for (Library.Artist a : library.artists) { + if (a.name.toLowerCase(Locale.ROOT).contains(q)) { + rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1)); + } + } + if (rows.size() == before + 1) rows.remove(before); + + before = rows.size(); + rows.add(new Row(HEADER, "Albums", null, null, -1)); + for (Library.Album al : library.albums) { + if (al.name.toLowerCase(Locale.ROOT).contains(q)) { + rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1)); + } + } + if (rows.size() == before + 1) rows.remove(before); + + before = rows.size(); + rows.add(new Row(HEADER, "Songs", null, null, -1)); + for (Library.Track t : library.tracks) { + if (t.haystack.contains(q)) { + addSong(t, false); + if (songs.size() >= SEARCH_CAP) break; + } + } + if (rows.size() == before + 1) rows.remove(before); + if (rows.isEmpty()) note("Nothing matches “" + raw.trim() + "”"); + adapter.notifyDataSetChanged(); + list.setSelection(0); + } + + // ---- taps ---- + + private void onRow(Row row) { + switch (row.kind) { + case SHUFFLE: + PlaybackService.play(this, songs, -1, true); + openNowPlaying(); + break; + case SONG: + PlaybackService.play(this, songs, row.songIndex, null); + openNowPlaying(); + break; + case PLAYLIST_ROW: + open(PLAYLIST, ((Library.Playlist) row.ref).id); + break; + case ARTIST_ROW: + open(ARTIST, ((Library.Artist) row.ref).name); + break; + case ALL_SONGS: + open(ARTIST_SONGS, ((Library.Artist) row.ref).name); + break; + case ALBUM_ROW: + open(ALBUM, ((Library.Album) row.ref).key); + break; + } + } + + private void open(String newMode, String newId) { + startActivity(new Intent(this, ListActivity.class) + .putExtra(MODE, newMode).putExtra(ID, newId)); + } + + // ---- rows on screen ---- + + private static final class Holder { + ImageView art; + TextView title, sub; + } + + private final class RowAdapter extends BaseAdapter { + @Override public int getCount() { return rows.size(); } + @Override public Object getItem(int i) { return rows.get(i); } + @Override public long getItemId(int i) { return i; } + @Override public boolean isEnabled(int i) { + int k = rows.get(i).kind; + return k != HEADER && k != NOTE; + } + @Override public boolean areAllItemsEnabled() { return false; } + + @Override public View getView(int i, View convert, ViewGroup parent) { + Holder h; + if (convert == null) { + h = new Holder(); + LinearLayout line = new LinearLayout(ListActivity.this); + line.setOrientation(LinearLayout.HORIZONTAL); + line.setGravity(Gravity.CENTER_VERTICAL); + int pad = Ui.dp(ListActivity.this, 16); + line.setPadding(pad, Ui.dp(ListActivity.this, 8), pad, Ui.dp(ListActivity.this, 8)); + + h.art = new ImageView(ListActivity.this); + h.art.setScaleType(ImageView.ScaleType.CENTER_CROP); + int size = Ui.dp(ListActivity.this, 72); + LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams(size, size); + alp.rightMargin = pad; + line.addView(h.art, alp); + + LinearLayout texts = new LinearLayout(ListActivity.this); + texts.setOrientation(LinearLayout.VERTICAL); + h.title = Ui.text(ListActivity.this, Ui.TITLE_SP, false, Ui.BLACK); + h.sub = Ui.text(ListActivity.this, Ui.SUB_SP, false, Ui.DIM); + texts.addView(h.title); + LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + slp.topMargin = Ui.dp(ListActivity.this, 4); + texts.addView(h.sub, slp); + line.addView(texts, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + line.setTag(h); + convert = line; + } else { + h = (Holder) convert.getTag(); + } + + Row row = rows.get(i); + boolean header = row.kind == HEADER; + convert.setMinimumHeight(Ui.dp(ListActivity.this, header ? 48 : Ui.ROW_DP)); + h.title.setText(row.title); + h.title.setTypeface(null, row.kind == SHUFFLE || row.kind == ALL_SONGS || header + ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL); + h.title.setTextSize(TypedValue.COMPLEX_UNIT_SP, header ? 20 : Ui.TITLE_SP); + h.title.setSingleLine(row.kind != NOTE); + h.sub.setText(row.sub); + h.sub.setSingleLine(row.kind != NOTE); + h.sub.setVisibility(row.sub == null || row.sub.isEmpty() ? View.GONE : View.VISIBLE); + + if (row.kind == ALBUM_ROW) { + h.art.setVisibility(View.VISIBLE); + Ui.art(h.art, ((Library.Album) row.ref).art, Ui.dp(ListActivity.this, 72)); + } else { + h.art.setVisibility(View.GONE); + h.art.setTag(null); + } + + // The playing song is drawn inverted, so it can be found at a glance. + PlaybackService s = PlaybackService.get(); + boolean playing = row.kind == SONG && s != null && s.current() == row.ref; + convert.setBackgroundColor(playing ? Ui.BLACK : header ? Ui.PLACEHOLDER : Ui.WHITE); + h.title.setTextColor(playing ? Ui.WHITE : Ui.BLACK); + h.sub.setTextColor(playing ? Ui.WHITE : Ui.DIM); + return convert; + } + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java new file mode 100644 index 0000000..911edbe --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/MenuActivity.java @@ -0,0 +1,173 @@ +package me.teafry.andtunes; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.provider.Settings; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** + * The first screen, and the number the project is judged on: six tiles, + * visible in well under 400 ms. It loads nothing — it only starts the + * library parse in the background, so the data is usually ready by the time a + * finger lands on a tile. + */ +public class MenuActivity extends BaseActivity { + private static final String[] MODES = { + ListActivity.PLAYLISTS, ListActivity.ARTISTS, ListActivity.ALBUMS, + ListActivity.SONGS, ListActivity.SEARCH}; + private static final String[] NAMES = {"playlists", "artists", "albums", "songs", "search"}; + private static final String[] LABELS = {"Playlists", "Artists", "Albums", "Songs", "Search"}; + private static final int[] ART = { + R.drawable.playlists, R.drawable.artists, R.drawable.albums, + R.drawable.songs, R.drawable.search}; + + private ImageView sixth; + private Boolean builtWithAccess; + private boolean sixthShowsNowPlaying; + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + build(); + } + + @Override protected void onResume() { + super.onResume(); + if (builtWithAccess != hasAccess()) build(); + if (hasAccess()) { + Library.preload(); // no-op unless library.json changed + PlaybackService.restore(this); + } + } + + private void build() { + builtWithAccess = hasAccess(); + if (!builtWithAccess) { + setScreen(setupView(), false); + return; + } + Library.preload(); + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 1); + } + + LinearLayout grid = new LinearLayout(this); + grid.setOrientation(LinearLayout.VERTICAL); + int pad = Ui.dp(this, 4); + grid.setPadding(pad, pad, pad, pad); + LinearLayout row = null; + for (int i = 0; i < 6; i++) { + if (i % 2 == 0) { + row = new LinearLayout(this); + grid.addView(row, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + } + ImageView tile = new ImageView(this); + tile.setScaleType(ImageView.ScaleType.FIT_CENTER); + tile.setPadding(pad, pad, pad, pad); + if (i < 5) { + final int n = i; + tile.setImageDrawable(Ui.button(this, NAMES[i], ART[i])); + tile.setContentDescription(LABELS[i]); + tile.setOnClickListener(v -> startActivity( + new Intent(this, ListActivity.class).putExtra(ListActivity.MODE, MODES[n]))); + } else { + sixth = tile; + tile.setOnClickListener(v -> onSixth()); + } + row.addView(tile, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + } + sixthShowsNowPlaying = !hasTrack(); // force the first paint + setScreen(grid, false); + onPlaybackChanged(); + } + + private static boolean hasTrack() { + PlaybackService s = PlaybackService.get(); + return s != null && s.current() != null; + } + + @Override public void onPlaybackChanged() { + super.onPlaybackChanged(); + if (sixth == null) return; + boolean now = hasTrack(); + if (now == sixthShowsNowPlaying) return; + sixthShowsNowPlaying = now; + sixth.setImageDrawable(now ? Ui.button(this, "now_playing", R.drawable.now_playing) + : Ui.button(this, "shuffle_all", R.drawable.shuffle_all)); + sixth.setContentDescription(now ? "Now Playing" : "Shuffle All"); + } + + /** "Now Playing" when there is something, else shuffle everything we know. */ + private void onSixth() { + if (hasTrack()) { + openNowPlaying(); + return; + } + Library.whenReady((lib, error) -> { + if (lib == null || lib.tracks.isEmpty()) { + Ui.toast(this, error != null ? error : "No songs synced yet"); + return; + } + PlaybackService.play(this, lib.tracks, -1, true); + openNowPlaying(); + }); + } + + // ---- first run ---- + + private boolean hasAccess() { + if (Build.VERSION.SDK_INT >= 30) return Environment.isExternalStorageManager(); + return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) + == PackageManager.PERMISSION_GRANTED; + } + + private View setupView() { + LinearLayout box = new LinearLayout(this); + box.setOrientation(LinearLayout.VERTICAL); + box.setGravity(Gravity.CENTER); + int pad = Ui.dp(this, 28); + box.setPadding(pad, pad, pad, pad); + + TextView msg = Ui.text(this, 26, false, Ui.BLACK); + msg.setSingleLine(false); + msg.setEllipsize(null); + msg.setText("andTunes plays the music LinTunes puts in Music/andTunes.\n\n" + + "Android needs to let it read that folder: turn on " + + "“Allow access to manage all files”."); + box.addView(msg); + + TextView allow = Ui.text(this, 28, true, Ui.BLACK); + allow.setText("Allow access"); + Ui.toggleStyle(this, allow, true); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96)); + lp.topMargin = Ui.dp(this, 32); + box.addView(allow, lp); + allow.setOnClickListener(v -> { + if (Build.VERSION.SDK_INT >= 30) { + startActivity(new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:" + getPackageName()))); + } else { + requestPermissions(new String[] {Manifest.permission.READ_EXTERNAL_STORAGE}, 2); + } + }); + return box; + } + + @Override public void onRequestPermissionsResult(int code, String[] perms, int[] results) { + if (code == 2 && builtWithAccess != hasAccess()) build(); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java b/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java new file mode 100644 index 0000000..f066824 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/NowPlayingActivity.java @@ -0,0 +1,207 @@ +package me.teafry.andtunes; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +/** + * White background, square art, a slim scrubber, ◀ ⏯ ▶, then shuffle and + * repeat. No back button — Android's own is always on screen, and the + * panel is too small to spend on a second one (trav's call). The wheel stays + * volume here: this is the one screen where that's the useful thing. + */ +public class NowPlayingActivity extends BaseActivity { + private final Handler main = new Handler(Looper.getMainLooper()); + private ImageView art, playPause; + private TextView title, detail, elapsed, remaining, shuffleBtn, repeatBtn; + private SeekBar scrubber; + private boolean dragging; + private Library.Track shown; + private Drawable playD, pauseD; + + /** An ImageView that stays square inside whatever space it's given. */ + private static final class Square extends ImageView { + Square(Context c) { super(c); } + @Override protected void onMeasure(int w, int h) { + int s = Math.min(android.view.View.MeasureSpec.getSize(w), + android.view.View.MeasureSpec.getSize(h)); + setMeasuredDimension(s, s); + } + } + + @Override protected void onCreate(Bundle state) { + super.onCreate(state); + int pad = Ui.dp(this, 16); + LinearLayout col = new LinearLayout(this); + col.setOrientation(LinearLayout.VERTICAL); + col.setBackgroundColor(Ui.WHITE); + col.setPadding(pad, pad, pad, Ui.dp(this, 8)); + + art = new Square(this); + art.setScaleType(ImageView.ScaleType.CENTER_CROP); + LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f); + alp.gravity = Gravity.CENTER_HORIZONTAL; + col.addView(art, alp); + + title = Ui.text(this, 26, true, Ui.BLACK); + title.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams tlp = wrap(); + tlp.topMargin = Ui.dp(this, 12); + col.addView(title, tlp); + detail = Ui.text(this, 20, false, Ui.DIM); + detail.setGravity(Gravity.CENTER); + LinearLayout.LayoutParams dlp = wrap(); + dlp.topMargin = Ui.dp(this, 4); + col.addView(detail, dlp); + + scrubber = new SeekBar(this); + ColorStateList black = ColorStateList.valueOf(Ui.BLACK); + scrubber.setProgressTintList(black); + scrubber.setThumbTintList(black); + scrubber.setProgressBackgroundTintList(ColorStateList.valueOf(0xFF9A9A9A)); + scrubber.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override public void onProgressChanged(SeekBar b, int p, boolean fromUser) { + if (fromUser) showTimes(p, b.getMax()); + } + @Override public void onStartTrackingTouch(SeekBar b) { dragging = true; } + @Override public void onStopTrackingTouch(SeekBar b) { + dragging = false; + PlaybackService s = PlaybackService.get(); + if (s != null) s.seekTo(b.getProgress()); + } + }); + LinearLayout.LayoutParams slp = wrap(); + slp.topMargin = Ui.dp(this, 8); + col.addView(scrubber, slp); + + LinearLayout times = new LinearLayout(this); + elapsed = Ui.text(this, 18, false, Ui.BLACK); + remaining = Ui.text(this, 18, false, Ui.BLACK); + remaining.setGravity(Gravity.END); + times.addView(elapsed, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + times.addView(remaining, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + col.addView(times, wrap()); + + playD = Ui.button(this, "play", R.drawable.play); + pauseD = Ui.button(this, "pause", R.drawable.pause); + LinearLayout transport = new LinearLayout(this); + ImageView prev = transportButton(transport, Ui.button(this, "prev", R.drawable.prev)); + playPause = transportButton(transport, playD); + ImageView next = transportButton(transport, Ui.button(this, "next", R.drawable.next)); + prev.setContentDescription("Previous"); + next.setContentDescription("Next"); + prev.setOnClickListener(v -> withService(PlaybackService::previous)); + playPause.setOnClickListener(v -> withService(PlaybackService::toggle)); + next.setOnClickListener(v -> withService(PlaybackService::next)); + col.addView(transport, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96))); + + LinearLayout toggles = new LinearLayout(this); + shuffleBtn = toggle(toggles, "Shuffle"); + repeatBtn = toggle(toggles, "Repeat"); + shuffleBtn.setOnClickListener(v -> withService(s -> s.setShuffle(!s.shuffle()))); + repeatBtn.setOnClickListener(v -> withService(PlaybackService::cycleRepeat)); + LinearLayout.LayoutParams glp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64)); + glp.topMargin = Ui.dp(this, 8); + col.addView(toggles, glp); + + setScreen(col, false); + } + + private LinearLayout.LayoutParams wrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private ImageView transportButton(LinearLayout row, Drawable d) { + ImageView b = new ImageView(this); + b.setImageDrawable(d); + b.setScaleType(ImageView.ScaleType.FIT_CENTER); + int p = Ui.dp(this, 12); + b.setPadding(p, p, p, p); + row.addView(b, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + return b; + } + + private TextView toggle(LinearLayout row, String label) { + TextView t = Ui.text(this, 22, true, Ui.BLACK); + t.setText(label); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f); + int m = Ui.dp(this, 6); + lp.setMargins(m, 0, m, 0); + row.addView(t, lp); + return t; + } + + private interface Action { void run(PlaybackService s); } + + private void withService(Action a) { + PlaybackService s = PlaybackService.get(); + if (s != null) a.run(s); + } + + // ---- keeping it current ---- + + private final Runnable ticker = new Runnable() { + @Override public void run() { + PlaybackService s = PlaybackService.get(); + if (s != null && !dragging) { + int dur = s.duration(); + scrubber.setMax(Math.max(dur, 1)); + scrubber.setProgress(s.position()); + showTimes(s.position(), dur); + } + main.postDelayed(this, 500); + } + }; + + @Override protected void onResume() { + super.onResume(); + main.post(ticker); + } + + @Override protected void onPause() { + main.removeCallbacks(ticker); + super.onPause(); + } + + private void showTimes(int pos, int dur) { + elapsed.setText(Ui.time(pos)); + remaining.setText("-" + Ui.time(Math.max(dur - pos, 0))); + } + + @Override public void onPlaybackChanged() { + PlaybackService s = PlaybackService.get(); + Library.Track t = s != null ? s.current() : null; + if (t == null) { + finish(); // nothing to show; back to wherever we came from + return; + } + if (t != shown) { + shown = t; + title.setText(t.title); + String album = t.album.isEmpty() ? "" : " — " + t.album; + detail.setText(t.artistLabel() + album); + Ui.art(art, t.art, 480); + } + playPause.setImageDrawable(s.isPlaying() ? pauseD : playD); + playPause.setContentDescription(s.isPlaying() ? "Pause" : "Play"); + Ui.toggleStyle(this, shuffleBtn, s.shuffle()); + int r = s.repeat(); + repeatBtn.setText(r == PlaybackService.REPEAT_ONE ? "Repeat one" + : r == PlaybackService.REPEAT_ALL ? "Repeat all" : "Repeat"); + Ui.toggleStyle(this, repeatBtn, r != PlaybackService.REPEAT_OFF); + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java new file mode 100644 index 0000000..bcc8317 --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/PlaybackService.java @@ -0,0 +1,581 @@ +package me.teafry.andtunes; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.drawable.Icon; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.MediaMetadata; +import android.media.MediaPlayer; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +/** + * The queue and the one MediaPlayer, as a foreground service so music keeps + * going with the screen off. + * + *

Activities talk to it directly ({@link #get()}) — it's the same process, + * and a binder would only add ceremony. A new queue arrives through a static + * pending request plus {@code startForegroundService}, because the service may + * not exist yet. + * + *

Rules trav set: the end of a queue pauses (it never rolls into + * shuffle-all), and repeat is off / all / one. + */ +public class PlaybackService extends Service implements + MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener { + + interface Listener { void onPlaybackChanged(); } + + static final int REPEAT_OFF = 0, REPEAT_ALL = 1, REPEAT_ONE = 2; + + private static final String ACTION_START = "start", ACTION_RESTORE = "restore", + ACTION_TOGGLE = "toggle", ACTION_NEXT = "next", ACTION_PREV = "prev"; + private static final String CHANNEL = "playback"; + private static final int NOTIFICATION_ID = 1; + private static final int RESTART_WINDOW_MS = 3000; + + private static PlaybackService sService; + private static final ArrayList sListeners = new ArrayList<>(); + private static ArrayList sPendingQueue; + private static int sPendingIndex; + private static Boolean sPendingShuffle; + private static boolean sRestoreTried; + + private final Handler main = new Handler(Looper.getMainLooper()); + private final Random random = new Random(); + private MediaPlayer mp; + private MediaSession session; + private AudioManager audio; + private AudioFocusRequest focusRequest; + private NotificationManager notifications; + + private ArrayList queue = new ArrayList<>(); + private int[] order = new int[0]; // play order: indices into queue + private int pos = -1; // index into order + private boolean shuffle; + private int repeat = REPEAT_OFF; + + private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground; + private int pendingSeek; + private int failures; + + // ---- the static front door ---- + + static PlaybackService get() { return sService; } + + static void addListener(Listener l) { if (!sListeners.contains(l)) sListeners.add(l); } + + static void removeListener(Listener l) { sListeners.remove(l); } + + /** + * Play {@code tracks} from {@code index} (-1 = a random start). {@code + * shuffle} null keeps whatever shuffle mode the user left on. + */ + static void play(Context c, List tracks, int index, Boolean shuffle) { + if (tracks.isEmpty()) return; + ArrayList copy = new ArrayList<>(tracks); + if (sService != null) { + sService.setQueue(copy, index, shuffle); + return; + } + sPendingQueue = copy; + sPendingIndex = index; + sPendingShuffle = shuffle; + c.startForegroundService(new Intent(c, PlaybackService.class).setAction(ACTION_START)); + } + + /** Once per process: bring back the last queue, paused where it was left. */ + static void restore(Context c) { + if (sRestoreTried || sService != null) return; + sRestoreTried = true; + if (prefs(c).getString("queue", "").isEmpty()) return; + c.startService(new Intent(c, PlaybackService.class).setAction(ACTION_RESTORE)); + } + + // ---- lifecycle ---- + + @Override public void onCreate() { + super.onCreate(); + sService = this; + sRestoreTried = true; + audio = getSystemService(AudioManager.class); + notifications = getSystemService(NotificationManager.class); + notifications.createNotificationChannel(new NotificationChannel( + CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)); + + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build(); + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs).setOnAudioFocusChangeListener(this).build(); + + mp = new MediaPlayer(); + mp.setAudioAttributes(attrs); + mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); + mp.setOnPreparedListener(this); + mp.setOnCompletionListener(this); + mp.setOnErrorListener(this); + + session = new MediaSession(this, "andTunes"); + session.setCallback(new MediaSession.Callback() { + @Override public void onPlay() { play(); } + @Override public void onPause() { pause(); } + @Override public void onStop() { pause(); } + @Override public void onSkipToNext() { next(); } + @Override public void onSkipToPrevious() { previous(); } + @Override public void onSeekTo(long ms) { seekTo((int) ms); } + }); + session.setActive(true); + + SharedPreferences p = prefs(this); + shuffle = p.getBoolean("shuffle", false); + repeat = p.getInt("repeat", REPEAT_OFF); + } + + @Override public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent != null ? intent.getAction() : null; + if (ACTION_START.equals(action) && sPendingQueue != null) { + // startForegroundService() gives us five seconds to say so. + ArrayList q = sPendingQueue; + sPendingQueue = null; + setQueue(q, sPendingIndex, sPendingShuffle); + } else if (ACTION_RESTORE.equals(action)) { + restoreState(); + } else if (ACTION_TOGGLE.equals(action)) { + toggle(); + } else if (ACTION_NEXT.equals(action)) { + next(); + } else if (ACTION_PREV.equals(action)) { + previous(); + } + if (ACTION_START.equals(action) && !foreground) goForeground(); + return START_NOT_STICKY; + } + + @Override public void onTaskRemoved(Intent rootIntent) { + if (!playing) stopSelf(); + } + + @Override public void onDestroy() { + saveState(); + abandonFocus(); + unregisterNoisy(); + main.removeCallbacksAndMessages(null); + session.release(); + mp.release(); + notifications.cancel(NOTIFICATION_ID); + sService = null; + changed(); + super.onDestroy(); + } + + @Override public IBinder onBind(Intent intent) { return null; } + + // ---- what the screens read ---- + + Library.Track current() { + return pos >= 0 && pos < order.length ? queue.get(order[pos]) : null; + } + + boolean isPlaying() { return playing; } + + boolean shuffle() { return shuffle; } + + int repeat() { return repeat; } + + int position() { + if (!prepared) return pendingSeek; + try { return mp.getCurrentPosition(); } catch (IllegalStateException e) { return 0; } + } + + int duration() { + if (prepared) { + try { return mp.getDuration(); } catch (IllegalStateException ignored) { } + } + Library.Track t = current(); + return t != null ? t.secs * 1000 : 0; + } + + // ---- transport ---- + + void toggle() { if (playing) pause(); else play(); } + + void play() { + if (current() == null) return; + if (!prepared) { playWhenReady = true; return; } + if (audio.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + return; + } + mp.setVolume(1f, 1f); + mp.start(); + playing = true; + registerNoisy(); + goForeground(); + tick(); + changed(); + } + + void pause() { + playWhenReady = false; + if (playing) { + try { mp.pause(); } catch (IllegalStateException ignored) { } + } + playing = false; + unregisterNoisy(); + if (foreground) { + // Keep the notification (and the session) around while paused, but + // let the system reclaim the service if it has to — state is saved. + stopForeground(false); + foreground = false; + } + saveState(); + changed(); + } + + void seekTo(int ms) { + if (prepared) mp.seekTo(ms); + else pendingSeek = ms; + changed(); + } + + void next() { advance(false); } + + void previous() { + if (position() > RESTART_WINDOW_MS || pos <= 0) { + seekTo(0); + return; + } + load(pos - 1, playing, 0); + } + + void setShuffle(boolean on) { + shuffle = on; + Library.Track cur = current(); + int curIndex = cur != null ? order[pos] : -1; + buildOrder(curIndex); + saveState(); + changed(); + } + + void cycleRepeat() { + repeat = (repeat + 1) % 3; + saveState(); + changed(); + } + + // ---- queue ---- + + private void setQueue(ArrayList tracks, int index, Boolean shuffleMode) { + queue = tracks; + if (shuffleMode != null) shuffle = shuffleMode; + int start = index >= 0 && index < tracks.size() ? index : random.nextInt(tracks.size()); + if (shuffleMode == null && index < 0) start = 0; + buildOrder(start); + failures = 0; + load(pos, true, 0); + } + + /** Rebuild the play order so {@code keepIndex} (a queue index) is current. */ + private void buildOrder(int keepIndex) { + int n = queue.size(); + order = new int[n]; + for (int i = 0; i < n; i++) order[i] = i; + if (shuffle) { + for (int i = n - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + int tmp = order[i]; order[i] = order[j]; order[j] = tmp; + } + if (keepIndex >= 0) { + for (int i = 0; i < n; i++) { + if (order[i] == keepIndex) { order[i] = order[0]; order[0] = keepIndex; break; } + } + pos = 0; + } else { + pos = n > 0 ? 0 : -1; + } + } else { + pos = keepIndex >= 0 ? keepIndex : (n > 0 ? 0 : -1); + } + } + + /** Step forward. {@code auto} = the song finished by itself. */ + private void advance(boolean auto) { + if (order.length == 0) return; + if (auto && repeat == REPEAT_ONE) { + load(pos, true, 0); + return; + } + int n = pos + 1; + if (n >= order.length) { + if (repeat != REPEAT_ALL) { + if (auto) { + // End of the queue pauses, parked at the top of the last song. + playing = false; + pendingSeek = 0; + if (prepared) mp.seekTo(0); + pause(); + } + return; + } + if (shuffle) buildOrder(-1); + n = 0; + } + load(n, auto || playing, 0); + } + + private void load(int newPos, boolean play, int seekMs) { + pos = newPos; + Library.Track t = current(); + if (t == null) return; + prepared = false; + playWhenReady = play; + pendingSeek = seekMs; + try { + mp.reset(); + if (!t.file().isFile()) throw new java.io.FileNotFoundException(t.path); + mp.setDataSource(t.file().getPath()); + mp.prepareAsync(); + } catch (Exception e) { + skipBroken(t, e instanceof java.io.FileNotFoundException ? "missing" : "can't play"); + return; + } + saveState(); + changed(); + } + + /** A synced-then-deleted file, or one MediaPlayer chokes on: skip, don't stop. */ + private void skipBroken(Library.Track t, String why) { + Ui.toast(this, "Skipped “" + t.title + "” (" + why + ")"); + if (++failures >= order.length) { + failures = 0; + playing = false; + playWhenReady = false; + changed(); + return; + } + boolean wasPlaying = playWhenReady; + if (pos + 1 < order.length) load(pos + 1, wasPlaying, 0); + else if (repeat == REPEAT_ALL) load(0, wasPlaying, 0); + else { playing = false; changed(); } + } + + @Override public void onPrepared(MediaPlayer player) { + prepared = true; + failures = 0; + if (pendingSeek > 0) mp.seekTo(pendingSeek); + pendingSeek = 0; + if (playWhenReady) play(); + else changed(); + } + + @Override public void onCompletion(MediaPlayer player) { + if (prepared) advance(true); + } + + @Override public boolean onError(MediaPlayer player, int what, int extra) { + Library.Track t = current(); + prepared = false; + if (t != null) skipBroken(t, "can't play"); + return true; + } + + // ---- audio focus, noisy ---- + + @Override public void onAudioFocusChange(int change) { + switch (change) { + case AudioManager.AUDIOFOCUS_LOSS: + resumeOnFocus = false; + pause(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + resumeOnFocus = playing; + pause(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + if (playing) mp.setVolume(0.2f, 0.2f); + break; + case AudioManager.AUDIOFOCUS_GAIN: + mp.setVolume(1f, 1f); + if (resumeOnFocus) { resumeOnFocus = false; play(); } + break; + } + } + + private void abandonFocus() { audio.abandonAudioFocusRequest(focusRequest); } + + private final BroadcastReceiver noisy = new BroadcastReceiver() { + @Override public void onReceive(Context c, Intent i) { pause(); } + }; + private boolean noisyRegistered; + + private void registerNoisy() { + if (noisyRegistered) return; + registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + noisyRegistered = true; + } + + private void unregisterNoisy() { + if (!noisyRegistered) return; + unregisterReceiver(noisy); + noisyRegistered = false; + } + + // ---- session, notification, listeners ---- + + private final Runnable ticker = this::tick; + + /** While playing: keep the session position honest and save now and then. */ + private void tick() { + main.removeCallbacks(ticker); + if (!playing) return; + saveState(); + main.postDelayed(ticker, 15000); + } + + private void changed() { + if (sService == this) updateSession(); + for (Listener l : new ArrayList<>(sListeners)) l.onPlaybackChanged(); + } + + private Library.Track shownInSession; + + private void updateSession() { + Library.Track t = current(); + long actions = PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE + | PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SEEK_TO + | PlaybackState.ACTION_STOP; + session.setPlaybackState(new PlaybackState.Builder().setActions(actions) + .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, + position(), playing ? 1f : 0f).build()); + if (t == null) return; + if (t != shownInSession) { + shownInSession = t; + session.setMetadata(new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, t.title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, t.artistLabel()) + .putString(MediaMetadata.METADATA_KEY_ALBUM, t.album) + .putLong(MediaMetadata.METADATA_KEY_DURATION, t.secs * 1000L) + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, Ui.artNow(t.art, 240)) + .build()); + } + notifications.notify(NOTIFICATION_ID, buildNotification()); + } + + private void goForeground() { + startForeground(NOTIFICATION_ID, buildNotification()); + foreground = true; + } + + private Notification buildNotification() { + Library.Track t = current(); + Intent open = new Intent(this, MenuActivity.class) + .setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + Notification.Builder b = new Notification.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_notify) + .setContentTitle(t != null ? t.title : "andTunes") + .setContentText(t != null ? t.artistLabel() : "") + .setContentIntent(PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_IMMUTABLE)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOngoing(playing) + .setShowWhen(false) + .addAction(action(R.drawable.prev, "Previous", ACTION_PREV, 1)) + .addAction(action(playing ? R.drawable.pause : R.drawable.play, + playing ? "Pause" : "Play", ACTION_TOGGLE, 2)) + .addAction(action(R.drawable.next, "Next", ACTION_NEXT, 3)) + .setStyle(new Notification.MediaStyle() + .setMediaSession(session.getSessionToken()) + .setShowActionsInCompactView(0, 1, 2)); + if (t != null) b.setLargeIcon(Ui.artNow(t.art, 240)); + return b.build(); + } + + private Notification.Action action(int icon, String label, String act, int code) { + PendingIntent pi = PendingIntent.getService(this, code, + new Intent(this, PlaybackService.class).setAction(act), + PendingIntent.FLAG_IMMUTABLE); + return new Notification.Action.Builder(Icon.createWithResource(this, icon), label, pi) + .build(); + } + + // ---- remembering where we were ---- + + private static SharedPreferences prefs(Context c) { + return c.getSharedPreferences("playback", MODE_PRIVATE); + } + + private void saveState() { + StringBuilder ids = new StringBuilder(), ord = new StringBuilder(); + for (Library.Track t : queue) ids.append(t.id).append(','); + for (int i : order) ord.append(i).append(','); + prefs(this).edit() + .putString("queue", ids.toString()) + .putString("order", ord.toString()) + .putInt("pos", pos) + .putInt("ms", current() != null ? position() : 0) + .putBoolean("shuffle", shuffle) + .putInt("repeat", repeat) + .apply(); + } + + private void restoreState() { + Library.whenReady((lib, error) -> { + if (lib == null || current() != null) return; + SharedPreferences p = prefs(this); + ArrayList q = new ArrayList<>(); + ArrayList kept = new ArrayList<>(); // old index -> still present + String[] ids = p.getString("queue", "").split(","); + for (int i = 0; i < ids.length; i++) { + if (ids[i].isEmpty()) continue; + Library.Track t = lib.byId.get(Long.parseLong(ids[i])); + if (t != null) { q.add(t); kept.add(i); } + } + if (q.isEmpty()) { stopSelf(); return; } + queue = q; + int[] saved = parseInts(p.getString("order", "")); + int savedPos = p.getInt("pos", 0); + if (kept.size() == ids.length - (ids.length > 0 && ids[ids.length - 1].isEmpty() ? 1 : 0) + && saved.length == q.size() && savedPos >= 0 && savedPos < saved.length) { + order = saved; + pos = savedPos; + } else { + // The library changed under the saved queue; keep it, lose the spot. + buildOrder(0); + } + load(pos, false, p.getInt("ms", 0)); + }); + } + + private static int[] parseInts(String csv) { + ArrayList out = new ArrayList<>(); + for (String s : csv.split(",")) { + if (s.isEmpty()) continue; + try { out.add(Integer.parseInt(s)); } catch (NumberFormatException ignored) { } + } + int[] a = new int[out.size()]; + for (int i = 0; i < a.length; i++) a[i] = out.get(i); + return a; + } +} diff --git a/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java b/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java new file mode 100644 index 0000000..488f0dc --- /dev/null +++ b/andtunes/app/src/main/java/me/teafry/andtunes/Ui.java @@ -0,0 +1,152 @@ +package me.teafry.andtunes; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.LruCache; +import android.util.TypedValue; +import android.view.Gravity; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Sizes, colours and the two things every screen loads from the device: + * button artwork and album covers. + * + *

The R1 runs at an override density of 160 on a 2.88" panel, so one dp is + * about half its usual physical size — every size in here is roughly double + * what a phone app would use. Don't "fix" that by touching the density. + */ +final class Ui { + static final int BLACK = 0xFF000000; + static final int WHITE = 0xFFFFFFFF; + static final int DIM = 0xFF3A3A3A; // secondary text, still high contrast + static final int PLACEHOLDER = 0xFFE6E6E6; + + static final int ROW_DP = 88; + static final float TITLE_SP = 28; + static final float SUB_SP = 21; + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final ExecutorService ART = Executors.newFixedThreadPool(2); + private static final LruCache CACHE = + new LruCache(12 * 1024 * 1024) { + @Override protected int sizeOf(String k, Bitmap b) { return b.getByteCount(); } + }; + + private Ui() {} + + static int dp(Context c, float v) { + return Math.round(v * c.getResources().getDisplayMetrics().density); + } + + static TextView text(Context c, float sp, boolean bold, int color) { + TextView t = new TextView(c); + t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp); + t.setTextColor(color); + t.setSingleLine(true); + t.setEllipsize(TextUtils.TruncateAt.END); + t.setIncludeFontPadding(false); + if (bold) t.setTypeface(Typeface.DEFAULT_BOLD); + return t; + } + + /** A black-outlined pill; filled black when {@code on}. */ + static void toggleStyle(Context c, TextView t, boolean on) { + GradientDrawable d = new GradientDrawable(); + d.setCornerRadius(dp(c, 12)); + d.setStroke(dp(c, 3), BLACK); + d.setColor(on ? BLACK : WHITE); + t.setBackground(d); + t.setTextColor(on ? WHITE : BLACK); + t.setGravity(Gravity.CENTER); + } + + static void toast(Context c, String msg) { + Toast.makeText(c.getApplicationContext(), msg, Toast.LENGTH_SHORT).show(); + } + + static String time(int ms) { + int s = Math.max(ms, 0) / 1000; + return (s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60); + } + + /** + * {@code Buttons/.png} from the device when the user put one there, + * else the bundled default. LinTunes only ever fills that folder in where + * a file is missing, so what's there is either ours or theirs — theirs wins. + */ + static Drawable button(Context c, String name, int fallback) { + File custom = new File(Library.root(), "Buttons/" + name + ".png"); + if (custom.isFile()) { + Bitmap b = BitmapFactory.decodeFile(custom.getPath()); + if (b != null) return new android.graphics.drawable.BitmapDrawable(c.getResources(), b); + } + return c.getDrawable(fallback); + } + + /** + * Load {@code Art/.jpg} into {@code view}, decoded off the main thread + * at roughly {@code px} wide. Recycled list rows are handled by tagging the + * view with what it should show and dropping results that arrive late. + */ + static void art(ImageView view, String artRel, int px) { + if (artRel == null || artRel.isEmpty()) { + view.setTag(null); + view.setImageDrawable(null); + view.setBackgroundColor(PLACEHOLDER); + return; + } + final String key = artRel + "@" + px; + view.setTag(key); + Bitmap hit = CACHE.get(key); + if (hit != null) { + view.setImageBitmap(hit); + return; + } + view.setImageDrawable(null); + view.setBackgroundColor(PLACEHOLDER); + ART.execute(() -> { + Bitmap b = decode(new File(Library.root(), artRel), px); + if (b != null) CACHE.put(key, b); + MAIN.post(() -> { + if (key.equals(view.getTag()) && b != null) view.setImageBitmap(b); + }); + }); + } + + private static Bitmap decode(File f, int px) { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + BitmapFactory.decodeFile(f.getPath(), o); + if (o.outWidth <= 0) return null; + int sample = 1; + while (o.outWidth / (sample * 2) >= px) sample *= 2; + o = new BitmapFactory.Options(); + o.inSampleSize = sample; + return BitmapFactory.decodeFile(f.getPath(), o); + } + + /** Synchronous, for the notification and lock screen; small and cached. */ + static Bitmap artNow(String artRel, int px) { + if (artRel == null || artRel.isEmpty()) return null; + String key = artRel + "@" + px; + Bitmap b = CACHE.get(key); + if (b == null) { + b = decode(new File(Library.root(), artRel), px); + if (b != null) CACHE.put(key, b); + } + return b; + } +} diff --git a/andtunes/app/src/main/res/drawable-nodpi/albums.png b/andtunes/app/src/main/res/drawable-nodpi/albums.png new file mode 100644 index 0000000000000000000000000000000000000000..1cb5bd0987fc3215100ee36f81aa3006411310af GIT binary patch literal 4282 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 literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/artists.png b/andtunes/app/src/main/res/drawable-nodpi/artists.png new file mode 100644 index 0000000000000000000000000000000000000000..f264249252ac005142d51f759d614f9be4833990 GIT binary patch literal 3589 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 WDwXeH*f#tw09!j+Rb9FL;C}!?GSh1S literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png b/andtunes/app/src/main/res/drawable-nodpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b8f364991665523b09b9050439506e25ec591402 GIT binary patch literal 3231 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 ArT_o{ literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/ic_notify.png b/andtunes/app/src/main/res/drawable-nodpi/ic_notify.png new file mode 100644 index 0000000000000000000000000000000000000000..6f1b3687cc1986de90151948b78bb3639b6eff03 GIT binary patch literal 784 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9F5M?jcysy3fA0|V0` zPZ!6KiaBrZ?#&7b6gm3wznGw-&J{1~jUorVwoLspiQPayp`)oc>ZXU*#()6t&kU39 zC1^)Qu2|4i7S-9xp>!eC!-ch+V{GGo!FB6MaD>ghiqtaqpZXZ@PBPNSW zVJF9);_uS$_U->-;Jn(9p@I3p+LMlZU%ZQ~TR$Zro8e^P1H%mu-!1pzH*UGb>~YS& z?#SKZ@5)QtJFj>#_@wP+*;D>Kwfuf`)dCJ()(JANR$j|6Y*^AAwfHH+q-EK?a!d=J zMlGJ{xco}Y+KbT)p2l0<)-$SHy)Lzv-Qn%lV-idrtJatv6LAO%U2AxZVa3X864?x% zAET1rFsdBQ;$__s`YpM|bssonV=n);A@;Z3PqC8YXX;#H0vLVr zd3WTc%~#T2#ZdN*DZ+5`bLW_t^aJ1Qn7t3|T$pEfi}C+g;T^ndX5HMrP&}9+dM$JK z**DJXSVa%SuW!^&+cvTK;l}xl@r|huN;+qs?jw{X#H(xh<=&}a@m0x)z z8L$UPUE*7@3`lt~gkEKznDw7y)h4@+kk@=Gb^)nmhO5_sf?Nw`{!$3yO?N2%>bPW* z^@5u~-b#j9K&zkfE$9Yv&aqqMF!6t7U>AggiEsWfY=35S<8b8SHNYg!;OXk;vd$@? F2>_+$Rr&w` literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/next.png b/andtunes/app/src/main/res/drawable-nodpi/next.png new file mode 100644 index 0000000000000000000000000000000000000000..ac0d2f46337df0a4a7aa29ec834336441278ac23 GIT binary patch literal 772 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSoCO|{#S9F5M?jcysy3fA0|V1G zPZ!6KiaBrZY|IUH6lr!*J?R6E0Enf8IH zOT0bfRc(vqwHh_KH=J%=a(>dh9sKDgrHo~k)4v2>QvR;Ut8rlKpNU`ME;)Zsv|hwe zU8MGM?GpBP&gBk_d{06ybuOuXci7y8vG0Vw=lVPI9h5~99NlClYaHn6F;NR_m@1j- zvxq@DxE4q{uCxM*JP47Vtl_XYL=s3oPLT(a%Tr{5g@K0pFmIX+OzzUUsPV_ zU3GkYbcV;bcal3CVMm{aCm*7)gS8uA6#*Di1WJNQ0CpPbxjq z35+zA-yOn@Om94=g3R18EeoV0pyEiUK`G;^eha~6ItMhA{aRjf8C>cRPMpQmWAVsk g#xjf)1rk`WcfXL*hTgpQ*Y1P3p00i_>zopr0H4$^p#T5? literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/now_playing.png b/andtunes/app/src/main/res/drawable-nodpi/now_playing.png new file mode 100644 index 0000000000000000000000000000000000000000..b6ab7614a3c0f666814dc57247a3dac34fd2c8cd GIT binary patch literal 6321 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 literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/playlists.png b/andtunes/app/src/main/res/drawable-nodpi/playlists.png new file mode 100644 index 0000000000000000000000000000000000000000..348f1de2a8f8bde10405172bc9ffcfd591b1cc6e GIT binary patch literal 4919 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 Q(@T)Nr>mdKI;Vst01fONHUIzs literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/search.png b/andtunes/app/src/main/res/drawable-nodpi/search.png new file mode 100644 index 0000000000000000000000000000000000000000..a3236e603cabcee2ad84857a1eff4b00b253e78d GIT binary patch literal 4556 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 literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/drawable-nodpi/songs.png b/andtunes/app/src/main/res/drawable-nodpi/songs.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8d85ece25a093e066644711dae421b4edc7036 GIT binary patch literal 4769 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 Y3c1K7rJX>4e@+lRZ6jpm4d;jd0T0;;L;wH) literal 0 HcmV?d00001 diff --git a/andtunes/app/src/main/res/values/styles.xml b/andtunes/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..4a72832 --- /dev/null +++ b/andtunes/app/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/andtunes/build.py b/andtunes/build.py new file mode 100644 index 0000000..dc04c40 --- /dev/null +++ b/andtunes/build.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Build the andTunes APK without Gradle. + +Everything an app with no dependencies needs is already in the Android SDK: +aapt2 packages resources, javac compiles against the platform's android.jar, +R8 shrinks and dexes, zipalign + apksigner finish the file. Gradle and the +Android Gradle Plugin are a ~1 GB download that would do the same five steps +— and andTunes links nothing in, which is the point of it (cold start is the +feature), so there is nothing for a dependency manager to manage. + + python3 andtunes/build.py # -> andtunes/build/andTunes.apk + python3 andtunes/build.py --install # ... then adb install -r + launch + python3 andtunes/build.py --ship # ... then copy into lintunes/android/ + +The source tree is the standard Gradle shape (app/src/main/{java,res, +AndroidManifest.xml}), so a Gradle build could be dropped in later with no +moves. Stdlib only. +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +VERSION_NAME = "0.1.0" +VERSION_CODE = 1 + +PACKAGE = "me.teafry.andtunes" +MIN_SDK = 26 +TARGET_SDK = 33 +BUILD_TOOLS = "34.0.0" + +HERE = Path(__file__).resolve().parent +SRC = HERE / "app" / "src" / "main" +OUT = HERE / "build" +APK_NAME = "andTunes.apk" +SHIP_TO = HERE.parent / "lintunes" / "android" / APK_NAME + +# Committed on purpose: both of trav's machines must sign identically, or +# `adb install -r` refuses an update built on the other one. A sideloaded app +# with no store listing has nothing for this key to protect. +KEYSTORE = HERE / "andtunes.keystore" +KEY_ALIAS = "andtunes" +KEY_PASS = "andtunes" + + +def _sdk() -> Path: + for env in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + if os.environ.get(env): + return Path(os.environ[env]) + return Path.home() / "Android" / "Sdk" + + +def _java_home() -> Path: + # AGP-era tools want 21; Fedora's default `java` may be newer. + if os.environ.get("JAVA_HOME"): + return Path(os.environ["JAVA_HOME"]) + return Path("/usr/lib/jvm/java-21-openjdk") + + +def run(*cmd): + printable = " ".join(str(c) for c in cmd) + print(f" $ {printable[:160]}{'…' if len(printable) > 160 else ''}") + subprocess.run([str(c) for c in cmd], check=True) + + +def build() -> Path: + sdk = _sdk() + bt = sdk / "build-tools" / BUILD_TOOLS + android_jar = sdk / "platforms" / f"android-{TARGET_SDK}" / "android.jar" + jbin = _java_home() / "bin" + for need in (bt / "aapt2", android_jar, jbin / "javac"): + if not need.exists(): + sys.exit(f"missing {need} — see andtunes/README.md, Toolchain") + + if OUT.exists(): + shutil.rmtree(OUT) + gen, classes, dex = OUT / "gen", OUT / "classes", OUT / "dex" + for d in (gen, classes, dex): + d.mkdir(parents=True) + + print("resources") + run(bt / "aapt2", "compile", "--dir", SRC / "res", "-o", OUT / "res.zip") + run(bt / "aapt2", "link", "-o", OUT / "base.apk", + "-I", android_jar, "--manifest", SRC / "AndroidManifest.xml", + "--java", gen, "--proguard", OUT / "aapt-rules.pro", + "--min-sdk-version", MIN_SDK, "--target-sdk-version", TARGET_SDK, + "--version-code", VERSION_CODE, "--version-name", VERSION_NAME, + "--auto-add-overlay", OUT / "res.zip") + + print("java") + sources = sorted(str(p) for p in (SRC / "java").rglob("*.java")) + sources += sorted(str(p) for p in gen.rglob("*.java")) + run(jbin / "javac", "--release", "11", "-Xlint:-options", "-nowarn", + "-encoding", "UTF-8", "-cp", android_jar, "-d", classes, *sources) + classes_jar = OUT / "classes.jar" + with zipfile.ZipFile(classes_jar, "w") as jar: + for f in classes.rglob("*.class"): + jar.write(f, f.relative_to(classes).as_posix()) + + print("r8") + run(jbin / "java", "-cp", bt / "lib" / "d8.jar", "com.android.tools.r8.R8", + "--release", "--min-api", MIN_SDK, "--lib", android_jar, + "--pg-conf", OUT / "aapt-rules.pro", "--pg-conf", HERE / "proguard.pro", + "--output", dex, classes_jar) + + print("package") + unsigned = OUT / "unsigned.apk" + shutil.copyfile(OUT / "base.apk", unsigned) + with zipfile.ZipFile(unsigned, "a", zipfile.ZIP_DEFLATED) as apk: + apk.write(dex / "classes.dex", "classes.dex") + aligned = OUT / "aligned.apk" + run(bt / "zipalign", "-f", "-p", "4", unsigned, aligned) + final = OUT / APK_NAME + env_java = dict(os.environ, JAVA_HOME=str(_java_home()), + PATH=f"{jbin}:{os.environ.get('PATH', '')}") + print(f" $ apksigner sign … {final.name}") + subprocess.run([str(bt / "apksigner"), "sign", "--ks", str(KEYSTORE), + "--ks-key-alias", KEY_ALIAS, + "--ks-pass", f"pass:{KEY_PASS}", + "--key-pass", f"pass:{KEY_PASS}", + "--out", str(final), str(aligned)], + check=True, env=env_java) + print(f"built {final} ({final.stat().st_size // 1024} KiB, " + f"v{VERSION_NAME} / {VERSION_CODE})") + return final + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--install", action="store_true", + help="adb install -r the result and launch it") + ap.add_argument("--ship", action="store_true", + help="copy the APK into lintunes/android/ for LinTunes") + args = ap.parse_args() + apk = build() + if args.ship: + SHIP_TO.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(apk, SHIP_TO) + # LinTunes can't cheaply read a binary manifest, so the version the + # APK carries rides next to it as plain JSON (lintunes/andtunes/install.py). + SHIP_TO.with_suffix(".json").write_text(json.dumps( + {"version_name": VERSION_NAME, "version_code": VERSION_CODE}) + "\n") + print(f"shipped to {SHIP_TO}") + if args.install: + run("adb", "install", "-r", apk) + run("adb", "shell", "am", "start", "-n", f"{PACKAGE}/.MenuActivity") + + +if __name__ == "__main__": + main() diff --git a/andtunes/proguard.pro b/andtunes/proguard.pro new file mode 100644 index 0000000..b75b99e --- /dev/null +++ b/andtunes/proguard.pro @@ -0,0 +1,9 @@ +# aapt2 --proguard already keeps everything the manifest names (activities, +# the service). This file only adds what R8 can't see from there. + +# Keep real class and method names, so a logcat stack trace reads. +-dontobfuscate + +# Not `-keepattributes SourceFile,LineNumberTable`: R8 8.2 (build-tools 34) +# dies with an internal NPE on javac 21's debug info for an anonymous class. +# Class + method names are enough to find a crash in an app this size. diff --git a/andtunes/tools/make_buttons.py b/andtunes/tools/make_buttons.py new file mode 100644 index 0000000..33537c6 --- /dev/null +++ b/andtunes/tools/make_buttons.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Render andTunes' default button artwork. + +These are the stand-ins trav can replace: LinTunes copies each one into +``Music/andTunes/Buttons/`` on the Rabbit only when that file is missing, and +the app draws ``Buttons/.png`` in preference to its bundled copy. So the +names here are a contract with both halves — change one and the user's own art +for it silently stops being used. + + python3 andtunes/tools/make_buttons.py + +Writes the same PNGs to the app's ``res/drawable-nodpi/`` (bundled fallback) +and to ``lintunes/andtunes/buttons/`` (what sync ships). Black and white only: +the R1's panel is dim and contrast is the only thing that reads on it. +""" + +import os +import sys +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QPointF, QRectF, Qt # noqa: E402 +from PyQt6.QtGui import ( # noqa: E402 + QColor, QFont, QGuiApplication, QImage, QPainter, QPainterPath, QPolygonF, +) + +HERE = Path(__file__).resolve().parent +RES = HERE.parent / "app" / "src" / "main" / "res" / "drawable-nodpi" +SHIP = HERE.parent.parent / "lintunes" / "andtunes" / "buttons" + +# The menu: a 2 × 3 grid on a 480-dp-wide screen, so ~240 × 208 per tile. +TILE_W, TILE_H = 240, 208 +MENU = { + "playlists": "Playlists", + "artists": "Artists", + "albums": "Albums", + "songs": "Songs", + "search": "Search", + "now_playing": "Now Playing", + "shuffle_all": "Shuffle All", +} +ICON = 128 +TRANSPORT = ("prev", "play", "pause", "next") + + +def _canvas(w, h): + image = QImage(w, h, QImage.Format.Format_ARGB32) + image.fill(Qt.GlobalColor.transparent) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + return image, painter + + +def tile(label: str) -> QImage: + image, p = _canvas(TILE_W, TILE_H) + path = QPainterPath() + path.addRoundedRect(QRectF(4, 4, TILE_W - 8, TILE_H - 8), 20, 20) + p.fillPath(path, QColor("black")) + font = QFont("DejaVu Sans") + font.setPixelSize(40) + font.setBold(True) + p.setFont(font) + p.setPen(QColor("white")) + p.drawText(QRectF(12, 12, TILE_W - 24, TILE_H - 24), + int(Qt.AlignmentFlag.AlignCenter | Qt.TextFlag.TextWordWrap), + label) + p.end() + return image + + +def _tri(x0, y0, x1, y1, pointing_right=True) -> QPolygonF: + if pointing_right: + pts = [(x0, y0), (x1, (y0 + y1) / 2), (x0, y1)] + else: + pts = [(x1, y0), (x0, (y0 + y1) / 2), (x1, y1)] + return QPolygonF([QPointF(x, y) for x, y in pts]) + + +def transport(name: str, color="black", size=ICON) -> QImage: + image, p = _canvas(size, size) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor(color)) + s = size / 128 + if name == "play": + p.drawPolygon(_tri(34 * s, 20 * s, 110 * s, 108 * s)) + elif name == "pause": + p.drawRect(QRectF(28 * s, 20 * s, 26 * s, 88 * s)) + p.drawRect(QRectF(74 * s, 20 * s, 26 * s, 88 * s)) + elif name == "next": + p.drawPolygon(_tri(18 * s, 28 * s, 86 * s, 100 * s)) + p.drawRect(QRectF(88 * s, 28 * s, 16 * s, 72 * s)) + elif name == "prev": + p.drawPolygon(_tri(42 * s, 28 * s, 110 * s, 100 * s, False)) + p.drawRect(QRectF(24 * s, 28 * s, 16 * s, 72 * s)) + p.end() + return image + + +def launcher() -> QImage: + size = 192 + image, p = _canvas(size, size) + path = QPainterPath() + path.addRoundedRect(QRectF(8, 8, size - 16, size - 16), 36, 36) + p.fillPath(path, QColor("black")) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QColor("white")) + p.drawPolygon(_tri(70, 50, 142, 96)) + p.drawPolygon(QPolygonF([QPointF(70, 96), QPointF(142, 96), + QPointF(70, 142)])) + p.end() + return image + + +def save(image: QImage, name: str, *dirs: Path): + for d in dirs: + d.mkdir(parents=True, exist_ok=True) + if not image.save(str(d / f"{name}.png"), "PNG"): + sys.exit(f"could not write {d / name}.png") + + +def main(): + app = QGuiApplication(sys.argv) # noqa: F841 — must outlive the painting + for name, label in MENU.items(): + save(tile(label), name, RES, SHIP) + for name in TRANSPORT: + save(transport(name), name, RES, SHIP) + save(launcher(), "ic_launcher", RES) + # Status-bar icons must be white-on-transparent; Android tints by alpha. + save(transport("play", "white", 96), "ic_notify", RES) + print(f"wrote {len(MENU) + len(TRANSPORT)} buttons to {RES} and {SHIP}") + + +if __name__ == "__main__": + main() diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 6f9af11..646d6a0 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/lintunes/android/__init__.py b/lintunes/android/__init__.py new file mode 100644 index 0000000..e2af142 --- /dev/null +++ b/lintunes/android/__init__.py @@ -0,0 +1,6 @@ +"""The andTunes APK LinTunes installs on the Rabbit (see andtunes/install.py). + +Built by ``andtunes/build.py --ship``, which also writes ``andTunes.json`` +with the version inside. A package only so `importlib.resources` can find +the APK in an installed copy, the way `export/templates` is found. +""" diff --git a/lintunes/android/andTunes.apk b/lintunes/android/andTunes.apk new file mode 100644 index 0000000000000000000000000000000000000000..82b79549000cf3dd925c9889c0f5c5e5d8c073a2 GIT binary patch literal 62871 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 Lkrl}J0OA1v6d>Y` literal 0 HcmV?d00001 diff --git a/lintunes/android/andTunes.json b/lintunes/android/andTunes.json new file mode 100644 index 0000000..b7b1950 --- /dev/null +++ b/lintunes/android/andTunes.json @@ -0,0 +1 @@ +{"version_name": "0.1.0", "version_code": 1} diff --git a/lintunes/andtunes/buttons/albums.png b/lintunes/andtunes/buttons/albums.png new file mode 100644 index 0000000000000000000000000000000000000000..1cb5bd0987fc3215100ee36f81aa3006411310af GIT binary patch literal 4282 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 literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/artists.png b/lintunes/andtunes/buttons/artists.png new file mode 100644 index 0000000000000000000000000000000000000000..f264249252ac005142d51f759d614f9be4833990 GIT binary patch literal 3589 zcmds4dpwiv{~uBgr4#9^P)^Sig%om5IW4hbBR12J^T>B&8AEx>SI$$8F|kB)iiOd( zM-F~7U#_y6zz-}ipKuKT)Q*Xh2l_x1j~ulJRB!_oGDl!6op1Udk= zvvLCRWgzuR>;>LNTffLaCVAJ+4FdwnwEa#ZPqk!D0F4{KR+i9v&lX05f}yjtjuqt> z)_-5IykakLE&aacJ(ZgCZRw|SPTfmPUQbtfTTIgp!=&cKOKOa_w@oGyZk(U{Q&Iyz z?%%YCw@yh3aa?{Bl`w4+(p-7%@Yx3koV3vaq%|+d0W{OS&eu3wn6UImBr&4W&x}Ja zUE|<=zvN{A^8$eyFYdoKed{ndNqxt%uQ7=w7cjc-_LqNwZFK@;pF*|DPbhzo zLWjOFVim;JlA-QZO}4P->wr6)71ahn^7iiM*tMHIAg6v!HiU@$&+fA)P*KEb_S4N_ z`OEl=_WTm^LTB*BONlgbVqs!JHj{E0FK7QMd3T#mMC7Ej7T!+)^OeE7d3XZo3QIHK zA-OKm_>qyAyd6GsS=8+U%&CctPV8<&iEby86Z&0G#2qSae=o=`a!f4km0RgIiW@DH zpw`IAPI`g4#7}z8@^OdqrVYW7v=7ijHQxhUrDqVi8F8pR$90qc-&b~D&9JlTHtKQl z^dxas(`_OZCt(#XTjm+faA)UCDXCRH>atQ7$1em1gSDDZccd{}BG=wHK~!pQedZ}U zoITbR9vN_Wy+^jQYXk(z)w8bi{USABc)j=j{Xav+5FQ?i)KF{dhuZY*)#O1jpDU;; z>5orlyRs7&O}yG9#=@B%td>ZOP0**Tjg1KXix=-z3069k>yQWa{?HLK_3J+Af%&2j zMmxh`PYp%Pqc>|*GVH3lE+3njl{J3KtWP?2u5bQp$pxo_>1QHwdzJKj{*$Hc@w2I_ zqQYJ|X7hOneP_d*^Z4;&sGD24D>71DH9X~{krQUF8>V*TW>{D)Yh;8Hx#p{HVBqT$ z%hnAZ(sd{>hRx2-DwyHxs~V@IP+#?s~au; zTDG>veo3NQCmq|W+MP<$A+nemsJ0R#hH;@+)d33U(ih2#$ST2HTMpzs)ONp7hzt2?Q3WIS?7-QOq z#-cCzhMeg%i@_d?-Pc;xElvRQzxqRoP$W_)$ld&Et32=uR$>K7&>teNjaVLw0H*C;bNhalj%O9{C~=SkE^Of2w{4+-HxavJ(%qJL zl)zLp!(W`H{76F3?I7ji9%sudDmeWX^MUIt(;Xft6pF6xWi&Th>)kQB&h95PD4X89 zn>OKHo3nJQ_SV^T?~G5@uMH=n`|EbQbm-5%O*}6v`|~a#y+-iSrBbY(n{J3ntm&T% z8SD4g@orE4+WJsh0E4)?W@~C{*8BA+O_&!H6zs5UjSEa8`!JE=;gn-K$k)Zi#W$w0 zSS-2e4eji2*v>yJ1b{4c$p6t)#anNJ7YAU0rsJ`@J5Pf6lko@Fx8_JMTL^C##@~%# zwmxR4!r<_VwYeV7y@EGyNq9Vr5dOw5~Tc4nW|A-qYE>^*<&VVP-OY~hKgRoD0XsP>oQC1ho z{@-0$=P(`~9<&jSH@GFFApi@5^+0Xy)}4|;5!R{}L;BcRyREbwyWU~Aq^N1wR2w-> zohgzv6Vkk3z#JONS@n*XYLmoq2V4cI%4)+MTKyE6-of~-q+7Y$1}R%>b3vnxVQ0`m zT+vP-x?~fruyX~t!0GmsSJl;!pPUezG^NUyq!{B8{fIhfkijh9;`+P+cK!x$B$WPQ zTlrFQhf>sM+%C%ebNclY5`MR<4FJ6{#QeDK>gL8Syf-Du8-$07)7Cd0=R>nb!MdxBgqkGJFd(2Pwp?V6j)ebtW@4oy_bq^YR4KDBY!Nv}?c1?XqqTsoK0|Nu>Z{^NrNRGa~K37nh{!m&zaEuy` zU!XAS(IW-~!9v6RorL}WIJf`3DKRPv+$ESjYGG352xT z(ozlf^hgcrs$(DkzJ=&Em+2`G-W-n+ERZ{z*`@iSBr6~cmg*d+VrM%SMjCW#WOQ;R z?#7z(MlrMwU_*1)?3sW%&~=a4m6x8a{M9a<@Xck^1b3Lf z%JU-;473EDPvZ9-3|wGQXq<-7iNd>|^3Np#sQbjlw_<@)0bp;;GYpFwUa`avCTt0S zn5_pqp$OM9p(bL1^&&r%X0$w=0_gZ-n-ZggVLAVc6e`Hyf4SfYdiag{Y#1PDikHUR zsn~!tVQ$V9T!Wq3)L@kdCt`7wotK|4MmNz?be^A@@{&)n8RK&~V~b)N>6; z;cLmG8vfgxE1GA{eC>UHbzpdSOW;aTGRE2oHWs?J+&(*12Izj|WnU8AzaL?y7qqIc z8yBnc-{P+R52Kjl8rL&ZBm+vMZ2UHzCBx<7mx+9zOpsW_q1%j_8zRD>h(M8aGM$Um z{aE5`)o1a>`Cy!ym6|@Hg~8d6&Xs_9Y@Bkh0PxWm1iILAbGAcc$ z8F1&vPheQ(omMSy{L$bRi^1*ryX+F2fu)l-tQ6nxxkWTqLnkHVvM;6^_1N)w_kJ9aI&u30a1Yq@a5r32 zI-lLf=`a+}e#SKF+1*v@fpX(AxC{#wI WDwXeH*f#tw09!j+Rb9FL;C}!?GSh1S literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/next.png b/lintunes/andtunes/buttons/next.png new file mode 100644 index 0000000000000000000000000000000000000000..ac0d2f46337df0a4a7aa29ec834336441278ac23 GIT binary patch literal 772 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSoCO|{#S9F5M?jcysy3fA0|V1G zPZ!6KiaBrZY|IUH6lr!*J?R6E0Enf8IH zOT0bfRc(vqwHh_KH=J%=a(>dh9sKDgrHo~k)4v2>QvR;Ut8rlKpNU`ME;)Zsv|hwe zU8MGM?GpBP&gBk_d{06ybuOuXci7y8vG0Vw=lVPI9h5~99NlClYaHn6F;NR_m@1j- zvxq@DxE4q{uCxM*JP47Vtl_XYL=s3oPLT(a%Tr{5g@K0pFmIX+OzzUUsPV_ zU3GkYbcV;bcal3CVMm{aCm*7)gS8uA6#*Di1WJNQ0CpPbxjq z35+zA-yOn@Om94=g3R18EeoV0pyEiUK`G;^eha~6ItMhA{aRjf8C>cRPMpQmWAVsk g#xjf)1rk`WcfXL*hTgpQ*Y1P3p00i_>zopr0H4$^p#T5? literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/now_playing.png b/lintunes/andtunes/buttons/now_playing.png new file mode 100644 index 0000000000000000000000000000000000000000..b6ab7614a3c0f666814dc57247a3dac34fd2c8cd GIT binary patch literal 6321 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 literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/playlists.png b/lintunes/andtunes/buttons/playlists.png new file mode 100644 index 0000000000000000000000000000000000000000..348f1de2a8f8bde10405172bc9ffcfd591b1cc6e GIT binary patch literal 4919 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 Q(@T)Nr>mdKI;Vst01fONHUIzs literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/search.png b/lintunes/andtunes/buttons/search.png new file mode 100644 index 0000000000000000000000000000000000000000..a3236e603cabcee2ad84857a1eff4b00b253e78d GIT binary patch literal 4556 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 literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/buttons/songs.png b/lintunes/andtunes/buttons/songs.png new file mode 100644 index 0000000000000000000000000000000000000000..1d8d85ece25a093e066644711dae421b4edc7036 GIT binary patch literal 4769 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 Y3c1K7rJX>4e@+lRZ6jpm4d;jd0T0;;L;wH) literal 0 HcmV?d00001 diff --git a/lintunes/andtunes/install.py b/lintunes/andtunes/install.py new file mode 100644 index 0000000..cdb3a79 --- /dev/null +++ b/lintunes/andtunes/install.py @@ -0,0 +1,180 @@ +"""Put the andTunes app on the Rabbit, from LinTunes. + +The APK is built by ``andtunes/build.py --ship`` and committed at +``lintunes/android/andTunes.apk`` — so the self-updater carries a new app to +the other machine with the same ``git pull`` that carries everything else. + +Two routes, best first: + +* **adb** (a runtime CLI tool, found with `shutil.which` — the + `ffmpeg_available()` pattern, never a pip dependency). ``adb install -r`` + upgrades in place, then all-files access is granted the same way the user + would in Settings. That grant works on the R1's userdebug ROM; if a ROM + refuses it, the app's own first-run screen asks instead, so a failed grant + is not a failed install. +* **MTP copy**, when there's no adb: the APK lands in ``Download/`` on the + device for a tap in the Files app. Never overwrites anything but that one + file. +""" + +import json +import re +import shutil +import subprocess +import threading +from importlib import resources +from pathlib import Path + +from PyQt6.QtCore import QObject, pyqtSignal + +from ..device_sync import CHUNK + +PACKAGE = "me.teafry.andtunes" +APK_NAME = "andTunes.apk" +MANUAL_DIR = "Download" +ADB_TIMEOUT = 120 + + +def adb_path() -> str | None: + return shutil.which("adb") + + +def bundled_apk() -> Path | None: + """The APK this LinTunes carries, or None in a checkout that has none.""" + try: + path = Path(str(resources.files("lintunes.android") / APK_NAME)) + except (ModuleNotFoundError, TypeError): + return None + return path if path.is_file() else None + + +def bundled_version() -> dict: + """``{"version_name", "version_code"}`` shipped beside the APK.""" + apk = bundled_apk() + if apk is None: + return {} + try: + return json.loads(apk.with_suffix(".json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def _run(cmd: list, timeout: int = 15) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +def rabbit_serial(adb: str) -> str | None: + """The adb serial of a connected Rabbit, or the only device if just one. + + `adb devices -l` names the model (``model:Rabbit_R1``). Anything else + plugged in alongside it is never picked by accident — with no Rabbit + named and more than one device, there is no answer. + """ + try: + out = _run([adb, "devices", "-l"]).stdout + except (OSError, subprocess.SubprocessError): + return None + ready = [] + for line in out.splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + ready.append((parts[0], line.lower())) + for serial, line in ready: + if "rabbit" in line: + return serial + return ready[0][0] if len(ready) == 1 else None + + +def installed_version(adb: str, serial: str) -> dict | None: + """What the device has installed, or None if andTunes isn't there.""" + try: + out = _run([adb, "-s", serial, "shell", "dumpsys", "package", PACKAGE]).stdout + except (OSError, subprocess.SubprocessError): + return None + code = re.search(r"versionCode=(\d+)", out) + name = re.search(r"versionName=(\S+)", out) + if not code: + return None + return {"version_code": int(code[1]), "version_name": name[1] if name else "?"} + + +def install_commands(adb: str, serial: str, apk: Path) -> list[list[str]]: + """The install, then the two grants the first-run screen would ask for.""" + base = [adb, "-s", serial] + return [ + base + ["install", "-r", str(apk)], + base + ["shell", "appops", "set", "--uid", PACKAGE, + "MANAGE_EXTERNAL_STORAGE", "allow"], + base + ["shell", "pm", "grant", PACKAGE, + "android.permission.POST_NOTIFICATIONS"], + ] + + +def copy_for_manual_install(device, apk: Path) -> Path: + """Copy the APK into ``Download/`` over MTP. Chunked, no copystat — + gvfs-MTP rejects it, same as every other write to the device.""" + dest = Path(device.root) / MANUAL_DIR / APK_NAME + dest.parent.mkdir(parents=True, exist_ok=True) + with open(apk, "rb") as src, open(dest, "wb") as dst: + while chunk := src.read(CHUNK): + dst.write(chunk) + return dest + + +class InstallWorker(QObject): + """Runs the install on a daemon thread; `adb install` takes seconds.""" + + finished = pyqtSignal(dict) + failed = pyqtSignal(str) + + def __init__(self, apk: Path, adb: str | None, serial: str | None, + device=None, parent=None): + super().__init__(parent) + self._apk, self._adb, self._serial, self._device = apk, adb, serial, device + self._busy = False + + def busy(self) -> bool: + return self._busy + + def start(self): + if self._busy: + return + self._busy = True + threading.Thread(target=self._run_guarded, daemon=True).start() + + def _run_guarded(self): + try: + self._run() + finally: + self._busy = False + + def _run(self): + if self._adb and self._serial: + install, *grants = install_commands(self._adb, self._serial, self._apk) + try: + result = _run(install, timeout=ADB_TIMEOUT) + except (OSError, subprocess.SubprocessError) as e: + self.failed.emit(f"adb install didn't finish: {e}") + return + if result.returncode != 0 or "Success" not in result.stdout: + why = (result.stderr or result.stdout).strip().splitlines() + self.failed.emit("adb install failed: " + + (why[-1] if why else f"exit {result.returncode}")) + return + granted = True + for cmd in grants: + try: + granted &= _run(cmd).returncode == 0 + except (OSError, subprocess.SubprocessError): + granted = False + self.finished.emit({"method": "adb", "granted": granted}) + return + if self._device is not None: + try: + dest = copy_for_manual_install(self._device, self._apk) + except OSError as e: + self.failed.emit(f"Couldn't copy the app to the {self._device.name}: {e}") + return + self.finished.emit({"method": "copied", "path": str(dest)}) + return + self.failed.emit("No Rabbit found over adb or USB file transfer.") diff --git a/lintunes/andtunes/sync.py b/lintunes/andtunes/sync.py index 676107d..9afb529 100644 --- a/lintunes/andtunes/sync.py +++ b/lintunes/andtunes/sync.py @@ -22,6 +22,7 @@ import json import os import threading from dataclasses import dataclass, field +from importlib import resources from pathlib import Path from PyQt6.QtCore import QObject, pyqtSignal @@ -31,8 +32,8 @@ from ..device_sync import CHUNK, build_m3u, track_display from . import art as art_mod from . import layout from .layout import ( - MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, assert_inside, - media_relpath, playlist_relpath, + BUTTONS_DIR, MANIFEST_NAME, PRESERVED_DIRS, album_key, art_relpath, + assert_inside, media_relpath, playlist_relpath, ) from .manifest import build_manifest @@ -71,6 +72,20 @@ class AndTunesPlan: bytes_freed: int = 0 +def default_buttons() -> list: + """The bundled button PNGs (rendered by andtunes/tools/make_buttons.py). + + Their names are a contract with the app, which draws + ``Buttons/.png`` in preference to its own copy. + """ + folder = resources.files("lintunes.andtunes") / "buttons" + try: + return sorted((p for p in folder.iterdir() if p.name.endswith(".png")), + key=lambda p: p.name) + except (FileNotFoundError, NotADirectoryError): + return [] + + def _relative_to_playlists(media_rel: str) -> str: """A media path as seen from inside the Playlists/ folder.""" return "../" + media_rel @@ -252,6 +267,7 @@ class AndTunesSyncWorker(QObject): self._copy_media() self._sync_art() self._write_index() + self._ship_buttons() self._prune_empty_dirs() self.finished.emit({ "device": device_name, @@ -401,6 +417,26 @@ class AndTunesSyncWorker(QObject): json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + def _ship_buttons(self): + """Default button artwork, only where the file is missing. + + `Buttons/` is the user's folder: a file already there — ours from an + earlier sync, or their own art — is never overwritten, and nothing in + it is ever deleted (the device scan skips it entirely). One stubborn + file costs a default image, not the sync. + """ + plan = self._plan + folder = assert_inside(plan.root, plan.root / BUTTONS_DIR) + for src in default_buttons(): + dest = folder / src.name + try: + if dest.exists(): + continue + folder.mkdir(parents=True, exist_ok=True) + dest.write_bytes(src.read_bytes()) + except OSError: + continue + def _prune_empty_dirs(self): """Drop folders an artist or album left behind when it went away. diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index 6ec563b..fbfa0de 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -11,6 +11,7 @@ from PyQt6.QtGui import QAction, QKeySequence from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import from lintunes.andtunes import layout as andtunes_layout +from lintunes.andtunes import install as andtunes_install from lintunes.andtunes import sync as andtunes_sync from lintunes.art_search import AlbumArtFetcher from lintunes.cast.controller import CastController @@ -162,6 +163,7 @@ class MainWindow(QMainWindow): self.statusBar().addPermanentWidget(self._sync_progress) self._sync_worker = None self._andtunes_worker = None + self._install_worker = None self._sync_device_name = "" # Export shares the status-bar progress widgets with device sync — # only one of the two may run at a time (each guards on the other). @@ -357,6 +359,9 @@ class MainWindow(QMainWindow): self._andtunes_action.setEnabled(False) self._add_action(connections_menu, "Rabbit Sync Settings…", "", self._show_device_sync_settings) + self._install_action = self._add_action( + connections_menu, "Install andTunes on Rabbit…", "", + self._install_andtunes) connections_menu.addSeparator() # The per-playlist sync stays until andTunes can actually play a # song: it is what feeds Auxio, and pulling it in the same round that @@ -406,6 +411,13 @@ class MainWindow(QMainWindow): # Whole-library sync doesn't care which view is open — only that a # device is plugged in and at least one playlist is ticked. self._andtunes_action.setEnabled(not busy and device is not None) + # Enabled whenever there's an APK to offer; the click finds the + # Rabbit (adb or MTP) and says so if it can't — cheaper than asking + # adb on every menu open. + self._install_action.setEnabled( + andtunes_install.bundled_apk() is not None + and not (self._install_worker is not None + and self._install_worker.busy())) if self._cast.is_connected(): self._cast_action.setText( @@ -657,6 +669,75 @@ class MainWindow(QMainWindow): msg += f", {summary['skipped']} skipped (no local file)" self.statusBar().showMessage(msg, 10000) + # ---- connections: install the andTunes app ---- + + def _install_andtunes(self): + if self._install_worker is not None and self._install_worker.busy(): + return + apk = andtunes_install.bundled_apk() + if apk is None: + return + adb = andtunes_install.adb_path() + serial = andtunes_install.rabbit_serial(adb) if adb else None + device = device_sync.find_device() + if serial is None and device is None: + QMessageBox.information( + self, "Rabbit not found", + "Plug the Rabbit in with USB debugging on, or in " + "File transfer mode, then try again.") + return + + ours = andtunes_install.bundled_version() + ours_name = ours.get("version_name", "?") + if serial: + have = andtunes_install.installed_version(adb, serial) + if have is None: + verb = "Install" + detail = f"Install andTunes {ours_name} on the Rabbit?" + elif have["version_code"] < ours.get("version_code", 0): + verb = "Update" + detail = (f"The Rabbit has andTunes {have['version_name']}; " + f"this LinTunes carries {ours_name}. Update it?") + else: + verb = "Reinstall" + detail = (f"The Rabbit already has andTunes " + f"{have['version_name']}. Reinstall {ours_name}?") + else: + verb = "Copy" + detail = ("adb isn't available, so the app will be copied to " + "Download/ on the Rabbit for you to open in the Files " + "app and install by hand. Copy it?") + box = QMessageBox(self) + box.setWindowTitle(f"{verb} andTunes") + box.setText(detail) + go = box.addButton(verb, QMessageBox.ButtonRole.AcceptRole) + box.addButton(QMessageBox.StandardButton.Cancel) + box.setDefaultButton(go) + box.exec() + if box.clickedButton() is not go: + return + + self._install_worker = andtunes_install.InstallWorker( + apk, adb, serial, device, self) + self._install_worker.finished.connect(self._on_install_finished) + self._install_worker.failed.connect( + lambda message: QMessageBox.warning(self, "andTunes", message)) + self.statusBar().showMessage("Installing andTunes on the Rabbit…") + self._install_worker.start() + + def _on_install_finished(self, result): + if result["method"] == "copied": + self.statusBar().clearMessage() + QMessageBox.information( + self, "andTunes copied", + f"Copied to {result['path']}.\n\nOn the Rabbit, open Files → " + "Download → andTunes.apk to install it.") + return + msg = "andTunes is installed on the Rabbit" + if not result.get("granted", True): + msg += " — open it once to allow file access" + self.statusBar().showMessage(msg, 8000) + # ---- export ---- def _refresh_export_action(self): diff --git a/setup.py b/setup.py index e417847..9982a72 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,13 @@ setup( packages=find_packages(), # The web-mix templates (player.js/css, the player sprite GIF and # the placeholder image) must ride along in an installed copy. - package_data={"lintunes.export": ["templates/*"]}, + # The andTunes APK LinTunes installs, and the default button artwork + # sync ships to the device. + package_data={ + "lintunes.export": ["templates/*"], + "lintunes.android": ["andTunes.apk", "andTunes.json"], + "lintunes.andtunes": ["buttons/*.png"], + }, install_requires=[ "PyQt6>=6.8.0", # QAudioBufferOutput (visualizer) needs Qt 6.8+ "mutagen>=1.46", diff --git a/tasks-done.md b/tasks-done.md index dc737aa..bc74b85 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,60 @@ ## Done +### Round 50 (2026-09-11) — andTunes plays music on the Rabbit (v0.19.0) + +The Android half of andTunes had been paused on a 1 GB toolchain download. +It turned out not to need one. + +- [x] **No Gradle, no Kotlin, no download.** Platform 33 + build-tools 34 were + already in `~/Android/Sdk`, and an app that links nothing needs five SDK + steps, not a build system. `andtunes/build.py` runs aapt2 → javac → R8 + → zipalign → apksigner. The APK is 61 KiB. trav's call, after asking + what Kotlin and Gradle even buy: nothing the app needs. +- [x] **andTunes 0.1.0, framework-only Java** (`andtunes/app/`): + - a six-tile menu that loads nothing + - a streaming `library.json` parse on a background thread, grouped into + artists and albums in one pass + - one `ListActivity` for playlists, artists (→ All songs + albums with + art thumbs), albums, songs (Shuffle all first) and search + - a Now playing bar on every list, with the playing song drawn inverted + - Now Playing: white, square art, slim scrubber, ◀ ⏯ ▶, shuffle, repeat + off/all/one, **no back button** + - a foreground `PlaybackService` with audio focus, becoming-noisy, + MediaSession and a MediaStyle notification + - end of queue pauses, missing files skip with a toast, and the last + queue and position come back on launch + Black on white throughout for the dim panel, and every size doubled + for the R1's override density. +- [x] **Cold start 313–338 ms** to the menu, measured with `am start -W` on + the R1 (target < 400; the first launch after install is 551 ms, + because of dex verification). +- [x] **The scroll wheel is DPAD, not volume.** `dumpsys input` shows + `och1970_holl_key` uses the stock `Generic.kl` (KEY_UP/DOWN → + DPAD_UP/DOWN). The app takes both DPAD and VOLUME codes in + `dispatchKeyEvent`, down and up. Lists scroll a row per detent; the + menu and Now Playing get volume. +- [x] **Replaceable buttons.** `andtunes/tools/make_buttons.py` renders the + defaults (menu tiles + ◀ ▶ ⏯). Sync copies them into the device's + `Buttons/` **only where the file is missing**, and the app prefers + whatever is there. +- [x] **Connections ▸ Install andTunes on Rabbit…** (`lintunes/andtunes/ + install.py`): adb install -r, then the all-files and notification + grants. The prompt says Install / Update / Reinstall based on the + version already on the device. With no adb, it copies the APK to + `Download/` over MTP. The APK is committed in `lintunes/android/` and + ships with the self-updater. +- [x] **The R1 now comes up as `mtp,adb`** whenever it's unlocked + (`svc usb setScreenUnlockedFunctions mtp`), so sync's gvfs mount and + adb are both there with no mode switching. +- [x] Found while building: R8 8.2 dies with an internal NPE on javac 21's + debug attributes for an anonymous class, so `proguard.pro` keeps none. +- [x] Verified with the **dev library** only: synced headless to the real + gvfs mount (21 tracks, 5 playlists, 4 covers). Then driven on the R1 + with `input tap`/`keyevent` + `screencap` through menu → songs → shuffle + → now playing, artists → artist → album thumbs, the wheel, search, and + resume after a force-stop. `tests/test_round50.py` covers button + shipping and the installer; the full suite passes. + ### Round 49 (2026-09-10) — Import from URL (v0.18.0) trav's `song` shell helper is `yt-dlp --extract-audio --audio-format mp3`, diff --git a/tests/test_round50.py b/tests/test_round50.py new file mode 100644 index 0000000..1d1b14c --- /dev/null +++ b/tests/test_round50.py @@ -0,0 +1,185 @@ +"""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"]