v0.19.0: andTunes plays music on the Rabbit

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
This commit is contained in:
2026-09-11 14:25:55 -05:00
co-authored by Claude Opus 5
parent 8188a5b389
commit 9483d8951b
51 changed files with 2963 additions and 98 deletions
+4
View File
@@ -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/
+18
View File
@@ -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
+39 -34
View File
@@ -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
+59 -60
View File
@@ -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/<name>.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/<name>.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-<installid>.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/<Playlist>/` folders.
- [ ] Retire `Sync Playlist to Rabbit (Auxio)` now that andTunes covers it.
(trav's R1 has no old `Music/<Playlist>/` folders, so no cleanup.)
## Parked
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.teafry.andtunes">
<!-- library.json isn't media, so READ_MEDIA_AUDIO can't open it: all-files
access is the whole reason this app can skip scanning. LinTunes grants
it over adb at install; the first-run screen is the fallback. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="andTunes"
android:icon="@drawable/ic_launcher"
android:theme="@style/AndTunes"
android:allowBackup="false">
<activity
android:name=".MenuActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ListActivity" android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".NowPlayingActivity" android:exported="false"
android:launchMode="singleTop" />
<service android:name=".PlaybackService" android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -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.
*
* <p>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);
}
}
@@ -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<Track> tracks = new ArrayList<>();
}
static final class Artist {
String name, sortKey;
final ArrayList<Album> albums = new ArrayList<>();
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
}
static final class Playlist {
String id, name;
final ArrayList<Track> tracks = new ArrayList<>(); // playing order
}
interface Listener { void onLibrary(Library library, String error); }
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
final ArrayList<Artist> artists = new ArrayList<>();
final ArrayList<Album> albums = new ArrayList<>();
final ArrayList<Playlist> playlists = new ArrayList<>();
final HashMap<Long, Track> byId = new HashMap<>();
final HashMap<String, Artist> artistsByName = new HashMap<>();
final HashMap<String, Album> 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<Listener> 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<Listener> 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<String, List<Long>> 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<String, List<Long>> ids)
throws IOException {
Playlist p = new Playlist();
ArrayList<Long> 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;
}
}
@@ -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<Row> rows = new ArrayList<>();
private final ArrayList<Library.Track> 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<Library.Track> 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;
}
}
}
@@ -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 <b>loads nothing</b> — 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();
}
}
@@ -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. <b>No back button</b> — 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);
}
}
@@ -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.
*
* <p>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.
*
* <p>Rules trav set: the end of a queue <b>pauses</b> (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<Listener> sListeners = new ArrayList<>();
private static ArrayList<Library.Track> 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<Library.Track> 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<Library.Track> tracks, int index, Boolean shuffle) {
if (tracks.isEmpty()) return;
ArrayList<Library.Track> 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<Library.Track> 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<Library.Track> 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<Library.Track> q = new ArrayList<>();
ArrayList<Integer> 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<Integer> 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;
}
}
@@ -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.
*
* <p>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<String, Bitmap> CACHE =
new LruCache<String, Bitmap>(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/<name>.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/<key>.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;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Black on white everywhere: the R1's panel is dim, and full contrast
is the only thing that reads on it. The window background is what
paints before the first frame, so it is white too. -->
<style name="AndTunes" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
<item name="android:colorBackground">@android:color/white</item>
<item name="android:textColorPrimary">@android:color/black</item>
<item name="android:colorAccent">@android:color/black</item>
<item name="android:colorControlActivated">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>
+155
View File
@@ -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()
+9
View File
@@ -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.
+135
View File
@@ -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/<name>.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()
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.18.0"
__version__ = "0.19.0"
+6
View File
@@ -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.
"""
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version_name": "0.1.0", "version_code": 1}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+180
View File
@@ -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.")
+38 -2
View File
@@ -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/<name>.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.
+81
View File
@@ -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):
+7 -1
View File
@@ -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",
+55
View File
@@ -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`,
+185
View File
@@ -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"]