android: a playlist, out of the phone

The desktop has had Export since playlists v2 and the phone had no door onto
it for one honest reason: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`playlist_export` wrote with `std::fs::write`, which cannot take one. The
storage plugin grew a `create_document` whose Kotlin writes the bytes through
the ContentResolver — a plugin function called from Rust, not a command, so
the file never passes through the WebView and nothing was added to the ACL —
and `playlist_export` now builds the file above the platform fork with the
same `playlist_formats` writers, so the phone rebuilds no format and the two
exports cannot drift. The door is the playlist's own sheet, on the page and
on the shelf card, with the desktop's five profiles in the desktop's order; a
dismissed sheet says nothing, because nothing happened. The mock walk drives
it three ways — a file written where the sheet pointed, a sheet dismissed, a
refusal read back as a sentence — and the phone walk opens the real sheet and
dismisses it with BACK, writing nothing to the phone. The Kotlin is unbuilt:
nothing in this tree compiles it, though it is written against
tauri-android's own `PluginHandle`, `Invoke` and `JSObject` rather than
against a guess at them.
This commit is contained in:
KeelrLeek committed 2026-09-14 02:38:44 -05:00
1 parent 95c2bfeeba
commit 04c87e0db1
9 files changed
+390 -72

No files matched your search

+34 -12
View File
@@ -52,7 +52,10 @@ What the phone does differently at runtime:
- the audio output stream asks for an 8192-frame buffer (`open_output_stream`);
AAudio's default put it on the fast path with a 10 ms burst, where the mixer's
inline decode and file reads underran audibly;
- `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file` works;
- `pick_folder` answers "nothing picked" (desktop-only dialog); `save_file`
opens Android's create-document sheet, but answers a `content://` URI that
`std::fs` cannot open — which is why the playlist export goes through the
storage plugin's `create_document` instead;
- the window-glass command is a no-op (the ground is the artwork);
- the aligner and the separator report "not on this platform";
- the desktop's Win32 perf counters read 0 / None in the debug overlay.
@@ -459,6 +462,25 @@ describe / pin / duplicate / delete, `playlist_add_items`,
verdict, the stream rows are verified against the catalog while it is up
(the desktop's pass, verbatim), and Create swaps the review for the
playlist it made (`nav.replace`).
- **Exporting** (`playlist_export`, the playlist's own sheet and the shelf
card's): the desktop's five profiles, in the desktop's order and its own
words — M3U8, XSPF, PLS, CSV and the backup JSON. The file is built in
Rust by `playlist_formats`, ABOVE the platform fork, so the phone never
rebuilds a format and the two exports cannot drift. The doors differ
because the answers do: `tauri-plugin-dialog` does open Android's
create-document sheet, but it answers with a `content://` URI and
`std::fs::write` cannot take one, so on the phone the bytes go to
`tauri-plugin-storage`'s `create_document` and its Kotlin writes them
through the ContentResolver (`openOutputStream(uri, "wt")`), wherever the
sheet pointed. That is a plugin function called from Rust, not a command:
the file never passes through the WebView, and nothing was added to the
ACL. A dismissed sheet resolves nothing and the phone says nothing —
which is the desktop's `Ok(None)`. A row with no local file exports
exactly as it does on the desktop: named, with a comment where the
location would be (M3U8), carried without a location (XSPF, CSV, the
backup), or left out (PLS, which holds only real files). The locations
that ARE written are this phone's own paths, inside the app's folder —
the format behaving correctly, not a bug to rewrite.
- **The share sheet** (`crates/tauri-plugin-share`, `share.svelte.js`):
Share → SoulStream from any app. The plugin's `ShareActivity` (an
ACTION_SEND text/plain target, merged into the app's manifest from the
@@ -469,9 +491,9 @@ describe / pin / duplicate / delete, `playlist_add_items`,
anything else the pasted-tracklist door. The phone walk sends the intent
itself (`adb shell am start -a android.intent.action.SEND …`).
Not on the phone yet: the playlist-file door and export — both want a file
picker the storage plugin has still to grow — the listener door (it needs
yt-dlp, a desktop tool), drag reorder, and Share on SoulStream.
Not on the phone yet: the playlist-file door — it wants a file picker the
storage plugin has still to grow — the listener door (it needs yt-dlp, a
desktop tool), drag reorder, and Share on SoulStream.
## The first run
@@ -1111,7 +1133,10 @@ all at 915×411) and back → and last, on a reload of its own, a phone that
has just been installed (the first-run sheet and its two cards, its
Soulseek door, the notes on Home, on both Library tabs and on Search, the
preview from You, a folder picked from the tour and the note losing the row
it answered), then back to the provisioned device, asserts what each step should show — the Home
it answered), then back to the provisioned device, and after that the
playlist export door three ways (a file written where the sheet pointed, a
dismissed sheet that says nothing at all, and a refusal read back as a
sentence), asserts what each step should show — the Home
step holds the launch to a budget as well, reading off `__ss.boot` how many
commands the app issued before its first frame, how many of those left in the
shell's own turn, how long it took, and requiring round 12's art prewarm to
@@ -1134,7 +1159,7 @@ list, a null page), `offline(on)` takes the client down — everything the
backend funnels through `with_client()` answers "not connected to Soulseek",
the two connects answer the server string, and `soulseek:state` goes out — and
`clear()` puts all of it back. They reject with a plain string because that is
what Tauri rejects with. Nine mock steps drive them, and the phone walk carries
what Tauri rejects with. Twelve mock steps drive them, and the phone walk carries
one safe step of its own (`trouble-words`: eleven real backend strings through
`__ss.say` on the device, every output asserted free of Rust). A mock step can
also set the
@@ -1203,12 +1228,9 @@ symptom.
- **Off by design — external runtimes.** yt-dlp and deno, the vocal
separator, the lyric alignment models, and the listener door that needs
them.
- **Waiting on Rust, not on a picker.** Playlist EXPORT: `tauri-plugin-dialog`
does open Android's create-document sheet from Rust, but it answers with a
`content://` URI and `playlist_export` writes with `std::fs::write`, which
cannot take one. It needs a ContentResolver write, or the bytes handed to
an outbound share intent — and `crates/tauri-plugin-share` is inbound-only
today. Playlist-FILE import wants a `pick_file` in the storage plugin,
- **Waiting on a picker.** The storage plugin can WRITE a document now
(`create_document`, which the playlist export goes through) but it cannot
read one. Playlist-FILE import wants a `pick_file` in the storage plugin,
beside the `pick_folder` and `pick_image` it already has.
- **Still desktop-only, smaller.** Drag reorder (Move up / down / to the top
stand in), catalog track previews, the full discography page, and the