diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md new file mode 100644 index 0000000..17656b7 --- /dev/null +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -0,0 +1,204 @@ +# 016 — What Android parity would actually take + +Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and +versioned, and publishes from CI. This is the assessment of what stands +between that and an Android app worth installing. + +**The headline: parity is the wrong target, and choosing it would be +the expensive mistake.** Four of the blockers below are not porting work +— they are the Android platform declining to support the model this app +is built on. The decision to make first is in "The fork in the road" at +the end; everything before it is evidence for that decision. + +Severity is what the app *does* today, verified against the source and +the generated manifest, not guessed. + +## A. It cannot work at all until these are fixed + +### A1. The app can read no music. (deepest) + +`build/android/app/src/main/AndroidManifest.xml` requests INTERNET, +VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the +two location permissions, CAMERA and the two FOREGROUND_SERVICE ones. +**There is no storage or media permission of any kind.** At +`targetSdk 35` that means the app can see its own private directory and +nothing else. + +Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it +grants access through **MediaStore**, not through the filesystem. This +app's entire model is absolute paths: `audio_files.file_path` is the +primary key of ownership, `AddLibrary(path)` takes a directory, the +scanner walks it with `os.ReadDir`, and every one of +`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand +paths to the player. Scoped storage does not offer a stable directory +to walk. + +The honest options are three, and they are not close in cost: + +- **MediaStore as the library source.** Query the content resolver, + keep MediaStore IDs (or content URIs) beside or instead of paths, and + open audio through a `ContentResolver` file descriptor. This is the + Android-native answer and it touches the schema, the scanner, the + player's file opening and every path-keyed query. +- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is + effectively barred from Google Play except for genuine file managers. + Viable *only* because we distribute through Obtainium — which is a + real point in its favour here, and worth stating plainly rather than + dismissing. +- **App-private storage only**, i.e. the user copies music into the + app's sandbox. Trivial to build, and nobody wants it. + +### A2. The first-run flow cannot complete. + +`first-run-wizard.ts` calls `DirectoryPicker()`, which is +`frontendutil.DirectoryPicker` → `app.Dialog.OpenFile(). +CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory +dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem +paths"**. So the one action the wizard exists to perform fails, and +`` intercepts all pointer events until a library +exists — so the app is not merely empty, it is inert. + +Whatever A1 resolves to decides this: a MediaStore library needs no +picker at all, and a SAF tree needs the picker to return a URI the +backend can use. + +### A3. MPRIS is compiled into the Android build. + +`mpris_linux.go` is `//go:build linux`, and **`android` implies +`linux`** (documented, and the reason it is in the APK). It will look +for a session bus that does not exist. It needs `//go:build linux && +!android`, and its Android counterpart is A4. + +This one is cheap and should be done regardless — it is a two-character +build-tag change plus whatever `mediacontrols.New` returns instead. + +### A4. Playback will be killed the moment the screen locks. + +The scaffold's `WailsForegroundService` is typed **`dataSync`** +(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`), +and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player +needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a +`MediaSession` for lock-screen and notification transport controls, +plus **audio focus** — pause on a phone call, duck for a notification, +pause on headphone unplug. None of that exists today. `oto` will happily +keep writing to a stream nobody can hear. + +This is the difference between "an app that plays audio" and "a music +player", and it is Java-side work in the scaffold plus a Go-side bridge. + +## B. It works, but wrongly + +### B1. The x86_64 half of the APK cannot run on any Android. + +Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw +`lstat` on linux/amd64, which Android's seccomp forbids, so the process +takes `SIGSYS` the first time it touches the database. arm64 is +structurally unaffected (no `lstat` syscall exists; it routes through +`fstatat`). + +So ~31 MB of the artifact is dead weight on *every* Android device, +including x86 Chromebooks. Options: drop `x86_64` from `abiFilters` +(smaller APK, no emulator target — which does not work anyway), or +carry it against a future modernc fix. **Dropping it is the honest +default**; it is also the only item in this plan that is a five-minute +change. + +### B2. The UI is a desktop shell. + +`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the +header subtitle wraps the title out of its bar. A phone is ~360–430 CSS +px wide. The sidebar collapses to icons below 900px, which is a +laptop-sized breakpoint, not a phone one. Beyond width: the app is built +on hover (the marquee's `hover` mode, tooltips), right-click context +menus, a keyboard shortcut layer with its own overlay and settings page, +multi-select with ctrl/shift, and a resizable-column track list. None of +those are gestures. + +This is not a stylesheet pass. It is a second front end for the views +worth having on a phone, sharing the stores and bindings — which the +architecture supports, since a view is already a lazily-loaded chunk +behind `VIEW_LOADERS`. + +### B3. Tag writing cannot reach the user's files. + +`tagwriter` rewrites tags in place, and autotag's whole purpose is +applying them to a folder. Under scoped storage that is impossible +outside the sandbox without a SAF write grant per tree. If A1 lands on +MediaStore, in-place tag writing needs `MediaStore` write requests and +user confirmation per file on Android 11+. + +Autotagging is arguably a desktop-only feature and saying so is a +legitimate answer. + +### B4. The Explore catalog is a ~0.6 GB download into app-private storage. + +It works — but with no awareness of a metered connection and no +accounting for a device where that is a meaningful fraction of free +space. At minimum it needs to be opt-in on mobile and to refuse a +metered network by default. `Android.NetworkJSON()` reports +`{connected,type}`, so the signal is available. + +## C. Inert, and fine + +Window geometry, menus and the system tray are documented no-ops on +mobile. The keyboard shortcut layer is harmless but its Settings page +is dead weight. `profiling` is already compiled out of production +builds. These cost nothing and need no work. + +## D. Unknown until it runs on a device + +**Nothing in section A or B has been observed on Android**, because the +x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64 +images on an x86_64 host. Everything above is read from the source, the +generated manifest and Wails' own documentation. The first real device +run will find things this list does not have, and the most likely +places are audio latency and buffering under `oto`/oboe, and SQLite +behaviour on app-private storage. + +## The fork in the road + +The four blockers in section A are all the same question wearing +different clothes: **is the Android app a librarian, or a player?** + +YellowJacket on the desktop is a *librarian*. It scans folders, +deduplicates covers, detects duplicate tracks, reconciles against +MusicBrainz, rewrites tags on disk, and manages downloads. That model +rests on owning a filesystem, which is precisely what Android declines +to give. + +Three coherent products, and only the first is "parity": + +1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE` + (Obtainium-only distribution, which we already have), a phone UI for + every view, and media-session playback. Largest scope by far; the + result is an app almost nobody has asked for on a phone. +2. **A player for music already on the phone.** MediaStore as the + source, no scanner, no autotag, no downloads; the library, queue, + playlists, favourites and Explore-as-browsing all still make sense. + This is a genuinely good Android app and it is *not* parity — it is + a subset with a different data source. +3. **A companion to the desktop app.** The phone browses and controls + the desktop's library over the network, or syncs a subset. Smallest + Android surface, and it leans on the thing that already works. + +**Option 2 is the recommendation** if the goal is an app people use; +option 3 if the goal is the least work for the most value. Option 1 is +the only one that answers "feature parity" literally, and it is the one +worth arguing hardest against. + +## What is worth doing regardless of that decision + +Cheap, independently useful, and each unblocks measurement: + +1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document + why. Five minutes. +2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the + Android build stops carrying a D-Bus client. Small. +3. **A device smoke run**, which needs someone's phone and the published + APK. Everything in D depends on it, and it is the single highest + information-per-minute action available. +4. **Make the first-run wizard fail legibly** rather than inertly (A2) + — the picker's error already routes through `describeError`, but the + wizard still blocks pointer events, so an Android user sees a dead + screen rather than a sentence. Even under option 3 this is the right + behaviour.