Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6216954f40 | ||
|
|
69bb722590 | ||
|
|
bb7f1f1b2d | ||
|
|
9f7661afab | ||
|
|
5ddb6d4d59 | ||
|
|
6f789687e4 | ||
|
|
17942fc04b | ||
|
|
8f46f9c663 | ||
|
|
54245a33b5 | ||
|
|
062e1845e0 | ||
|
|
f59a2d773e | ||
|
|
4d75395411 | ||
|
|
f50cf2018a | ||
|
|
3f56f274c5 | ||
|
|
515b3da71f | ||
|
|
e1bf456743 | ||
|
|
3a8ccd4640 | ||
|
|
a44b4422fd | ||
|
|
c36ec439ce | ||
|
|
da4abeb8c9 | ||
|
|
afcd3cc7f3 | ||
|
|
d42645d2a0 | ||
|
|
4ebc38d5f3 | ||
|
|
fbc6a0268b | ||
|
|
c1a2608bdd | ||
|
|
1f9b30c51f | ||
|
|
cdb861fcbd | ||
|
|
07dad65ca5 | ||
|
|
9c3273e6af | ||
|
|
9f0601027c | ||
|
|
867fd42972 | ||
|
|
33f32cb4c9 | ||
|
|
9bfbcfc96c | ||
|
|
0ce8c63cb6 | ||
|
|
a8adce087f | ||
|
|
537bfdd5af | ||
|
|
f2cde4ec7c | ||
|
|
1258b2b78d | ||
|
|
defd8c5e6d | ||
|
|
04c87e0db1 | ||
|
|
95c2bfeeba | ||
|
|
46cf6a98fe | ||
|
|
01bab007f7 | ||
|
|
052e15302b | ||
|
|
44eda8dff5 | ||
|
|
5738c2d7bf | ||
|
|
4fc6e9c630 | ||
|
|
9df162360e | ||
|
|
aff29aba5c | ||
|
|
1737c5e1b6 | ||
|
|
c737809325 | ||
|
|
24c6857847 | ||
|
|
e693dfb868 | ||
|
|
7eae0ee945 | ||
|
|
f792d7955d | ||
|
|
694a29eb0d | ||
|
|
3c8989dfa6 | ||
|
|
59be9f6c3e | ||
|
|
f8c3b90665 | ||
|
|
b50577fc81 | ||
|
|
d95c3f48d1 | ||
|
|
60303c8a95 | ||
|
|
991d52d0cc | ||
|
|
26928b706d | ||
|
|
6e39dc43f3 | ||
|
|
bb828ff2dc |
No files matched your search
@@ -23,3 +23,7 @@ branding/
|
|||||||
# the setup app frontend build (vite.setup.config.js)
|
# the setup app frontend build (vite.setup.config.js)
|
||||||
setup/dist/
|
setup/dist/
|
||||||
setup/src-tauri/gen/
|
setup/src-tauri/gen/
|
||||||
|
|
||||||
|
# the phone UI smoke test (tests/mobile/smoke.mjs) builds and captures here
|
||||||
|
dist-smoke/
|
||||||
|
tests/mobile/out/
|
||||||
+2641
File diff suppressed because it is too large.
Load diff
Generated
+236
-1
@@ -170,16 +170,19 @@ dependencies = [
|
|||||||
"flate2",
|
"flate2",
|
||||||
"freenet-adapter",
|
"freenet-adapter",
|
||||||
"image",
|
"image",
|
||||||
|
"jni 0.21.1",
|
||||||
"keyring",
|
"keyring",
|
||||||
"library",
|
"library",
|
||||||
"log",
|
"log",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"ndk-context",
|
||||||
"persona-state",
|
"persona-state",
|
||||||
"player",
|
"player",
|
||||||
"quick-xml 0.37.5",
|
"quick-xml 0.37.5",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
"realfft",
|
"realfft",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"rusqlite",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -191,6 +194,11 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tauri-plugin-media-session",
|
||||||
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-share",
|
||||||
|
"tauri-plugin-storage",
|
||||||
|
"tauri-plugin-transfer-notice",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -721,6 +729,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20"
|
name = "chacha20"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -732,6 +746,17 @@ dependencies = [
|
|||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chacha20poly1305"
|
name = "chacha20poly1305"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -739,7 +764,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aead",
|
"aead",
|
||||||
"chacha20",
|
"chacha20 0.9.1",
|
||||||
"cipher",
|
"cipher",
|
||||||
"poly1305",
|
"poly1305",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -1991,8 +2016,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2014,8 +2041,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2348,6 +2378,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2621,6 +2652,25 @@ version = "2.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-docker"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-wsl"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||||
|
dependencies = [
|
||||||
|
"is-docker",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -2978,6 +3028,12 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3560,6 +3616,17 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open"
|
||||||
|
version = "5.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"is-wsl",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl"
|
name = "openssl"
|
||||||
version = "0.10.81"
|
version = "0.10.81"
|
||||||
@@ -4026,6 +4093,62 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.10.2",
|
||||||
|
"rand_pcg",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
@@ -4068,6 +4191,17 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||||
|
dependencies = [
|
||||||
|
"chacha20 0.10.2",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4106,6 +4240,21 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_pcg"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -4245,6 +4394,8 @@ dependencies = [
|
|||||||
"native-tls",
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4252,6 +4403,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-native-tls",
|
||||||
|
"tokio-rustls",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -4259,6 +4411,7 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4462,6 +4615,7 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5659,6 +5813,68 @@ dependencies = [
|
|||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-media-session"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-opener"
|
||||||
|
version = "2.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91"
|
||||||
|
dependencies = [
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
"open",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"url",
|
||||||
|
"windows 0.61.3",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-share"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-transfer-notice"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -6645,6 +6861,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -6710,6 +6936,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ members = [
|
|||||||
"crates/audio-formats",
|
"crates/audio-formats",
|
||||||
"crates/freenet-adapter",
|
"crates/freenet-adapter",
|
||||||
"crates/persona-state",
|
"crates/persona-state",
|
||||||
|
"crates/tauri-plugin-media-session",
|
||||||
|
"crates/tauri-plugin-storage",
|
||||||
|
"crates/tauri-plugin-share",
|
||||||
|
"crates/tauri-plugin-transfer-notice",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ rust-version.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
image.workspace = true
|
image.workspace = true
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -15,3 +14,12 @@ thiserror.workspace = true
|
|||||||
log.workspace = true
|
log.workspace = true
|
||||||
# RFC-7231 Date parsing for preview-lease math (tiny, no deps of its own).
|
# RFC-7231 Date parsing for preview-lease math (tiny, no deps of its own).
|
||||||
httpdate = "1"
|
httpdate = "1"
|
||||||
|
|
||||||
|
# Desktop keeps the platform TLS; Android has no OpenSSL to build against and
|
||||||
|
# takes rustls with the bundled roots instead. The two tables are exclusive on
|
||||||
|
# purpose — an unconditional entry would unify default-tls back in.
|
||||||
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
@@ -471,6 +471,15 @@ fn open_output_stream(device_name: Option<&str>) -> Result<(rodio::OutputStream,
|
|||||||
let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into());
|
let actual_name = device.name().unwrap_or_else(|_| "Unknown device".into());
|
||||||
let builder = rodio::OutputStreamBuilder::from_device(device)
|
let builder = rodio::OutputStreamBuilder::from_device(device)
|
||||||
.map_err(|e| format!("could not configure {actual_name}: {e}"))?;
|
.map_err(|e| format!("could not configure {actual_name}: {e}"))?;
|
||||||
|
// Android: a music player's buffer, not the fast path's. AAudio's default
|
||||||
|
// lands the stream on the fast mixer with a ~10 ms burst, and the mixer's
|
||||||
|
// callback decodes and reads from storage inline — a 50 ms hiccup on a
|
||||||
|
// phone's flash or a busy core is an audible dropout there (the Pixel 6a
|
||||||
|
// logged runs of ~55 ms underruns, 2026-09-12). 8192 frames is ~170 ms of
|
||||||
|
// slack at 48 kHz, which nobody hears as latency on a record. Should the
|
||||||
|
// device refuse it, open_stream_or_fallback takes the default instead.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
let builder = builder.with_buffer_size(rodio::cpal::BufferSize::Fixed(8192));
|
||||||
let stream = builder
|
let stream = builder
|
||||||
.open_stream_or_fallback()
|
.open_stream_or_fallback()
|
||||||
.map_err(|e| format!("could not open {actual_name}: {e}"))?;
|
.map_err(|e| format!("could not open {actual_name}: {e}"))?;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "tauri-plugin-media-session"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SoulStream's media session on the phone: the lock-screen card, the notification's transport, audio focus, headset buttons — a foreground service that keeps playback alive in the background"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
# required by tauri-plugin's build script; must equal the crate name
|
||||||
|
links = "tauri-plugin-media-session"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-plugin = { version = "2", features = ["build"] }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/.tauri
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.soulstream.mediasession"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
// the plugin's own R8 keep rules, handed to whatever app links this
|
||||||
|
// module — inert unless that app minifies, which only the release
|
||||||
|
// build type does
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
// The session, the card, and a notification media3 posts and owns itself.
|
||||||
|
//
|
||||||
|
// 1.10.1 and NOT 1.11.0, which is newer (2026-08-05) and would break this
|
||||||
|
// build: media3-session-1.11.0's POM declares
|
||||||
|
// org.jetbrains.kotlin:kotlin-stdlib:2.2.10 at compile scope, this project
|
||||||
|
// compiles Kotlin with kotlin-gradle-plugin 1.9.25
|
||||||
|
// (src-tauri/gen/android/build.gradle.kts), and Gradle resolves to the
|
||||||
|
// HIGHEST version — so a 1.9 compiler would be handed Kotlin 2.2 metadata.
|
||||||
|
// Every media3 POM from 1.4.1 through 1.10.1 declares no kotlin-stdlib at
|
||||||
|
// all, and SimpleBasePlayer.java is byte for byte the same file at both
|
||||||
|
// tags, so nothing this plugin uses is newer than what is taken here.
|
||||||
|
//
|
||||||
|
// What rides along: media3-common at COMPILE scope, and with it
|
||||||
|
// com.google.guava:guava:33.3.1-android, also compile — that is where
|
||||||
|
// ListenableFuture, Futures and SettableFuture come from, which is why
|
||||||
|
// they are not named on a line of their own — plus
|
||||||
|
// androidx.annotation-experimental, which is androidx.annotation.OptIn.
|
||||||
|
// Then lifecycle-service 2.5.1, because MediaSessionService extends
|
||||||
|
// LifecycleService; media3-datasource, the default BitmapLoader that
|
||||||
|
// decodes the cover; and androidx.media:media:1.7.0 itself, still, at
|
||||||
|
// RUNTIME scope. The deprecated library has not left the APK and cannot —
|
||||||
|
// media3 uses its MediaBrowserServiceCompat. What changed is that none of
|
||||||
|
// OUR code compiles against it any more.
|
||||||
|
//
|
||||||
|
// media3 1.10.1's own minSdk is 23; this module's is 26. Guava adds about
|
||||||
|
// 2.8 MB of unminified classes to the debug APK, and minSdk 26 means
|
||||||
|
// native multidex, so there is no multiDexEnabled line to add.
|
||||||
|
implementation("androidx.media3:media3-session:1.10.1")
|
||||||
|
implementation("androidx.media3:media3-common:1.10.1")
|
||||||
|
implementation(project(":tauri-android"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Consumer R8/ProGuard rules for the media-session plugin. Inert unless the
|
||||||
|
# consuming app minifies, and only the `release` build type does, so the
|
||||||
|
# debug APK the deploy pipeline builds is untouched by this file.
|
||||||
|
#
|
||||||
|
# MediaSessionPlugin is found by the string
|
||||||
|
# "com/soulstream/mediasession/MediaSessionPlugin" (register_android_plugin →
|
||||||
|
# FindClass), constructed through "(Landroid/app/Activity;)V", and its two
|
||||||
|
# @Command methods — update and clear — are dispatched by NAME through
|
||||||
|
# PluginHandle's commands map. NowPlayingArgs is filled by Jackson field by
|
||||||
|
# field, and QueueItemArgs — one per entry of the queue window it carries —
|
||||||
|
# the same way. Rename any of it and the app still builds, installs and
|
||||||
|
# plays; the lock-screen card simply never appears.
|
||||||
|
#
|
||||||
|
# The other half of this plugin is in the app-wide file: the card's buttons
|
||||||
|
# come back as the plugin event "action", which the WebView subscribes to
|
||||||
|
# through Plugin.registerListener — declared on tauri's base class, which no
|
||||||
|
# rule of tauri's own names. See src-tauri/android/proguard-soulstream.pro.
|
||||||
|
#
|
||||||
|
# MediaService is declared in this module's AndroidManifest.xml, so AGP's
|
||||||
|
# own merged-manifest keep rules hold it by name, and SoulStreamPlayer,
|
||||||
|
# MediaHub and LibraryTree need no rule of their own — the only thing that
|
||||||
|
# reaches any of them is a direct reference from MediaService.
|
||||||
|
# androidx's MediaButtonReceiver went with the move to media3: the
|
||||||
|
# service's own filter routes media buttons now — through the
|
||||||
|
# MediaLibraryService action, which MediaSessionLegacyStub queries first —
|
||||||
|
# and two receivers would have fought over them.
|
||||||
|
-keep class com.soulstream.mediasession.MediaSessionPlugin { *; }
|
||||||
|
-keep class com.soulstream.mediasession.NowPlayingArgs { *; }
|
||||||
|
-keep class com.soulstream.mediasession.QueueItemArgs { *; }
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- the foreground service that carries the session while the app is in
|
||||||
|
the background (mediaPlayback is required from Android 14) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<!-- the card is a notification; asked for at runtime on Android 13+ -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- The app's own manifest lives in gitignored gen/ and declares no
|
||||||
|
android:appCategory, so the merger takes this one's: an application
|
||||||
|
attribute from a library merges up when the app does not set it.
|
||||||
|
API 26+, and minSdk here is 26. -->
|
||||||
|
<application android:appCategory="audio">
|
||||||
|
<service
|
||||||
|
android:name="com.soulstream.mediasession.MediaService"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<!-- Three actions, one component, and all three are safe.
|
||||||
|
Android Auto binds the LEGACY one
|
||||||
|
(android.media.browse.MediaBrowserService), which media3
|
||||||
|
still serves: MediaSessionService.onBind answers it with
|
||||||
|
the session's legacy browser binder. media3's own two are
|
||||||
|
how a MediaController and a SessionToken find this
|
||||||
|
service; SessionToken(Context, ComponentName) tests
|
||||||
|
MediaLibraryService FIRST, so the component is one
|
||||||
|
TYPE_LIBRARY_SERVICE token however many actions it wears,
|
||||||
|
and getAllServiceTokens collects into a Set.
|
||||||
|
|
||||||
|
The MediaSessionService action is KEPT rather than
|
||||||
|
replaced because the media-button path already verified on
|
||||||
|
this phone hangs off it: MediaSessionLegacyStub looks for
|
||||||
|
an ACTION_MEDIA_BUTTON receiver, finds none (there is no
|
||||||
|
MediaButtonReceiver any more, and media3's own is
|
||||||
|
deliberately not in its place — its docs make it
|
||||||
|
conditional on MediaSession.Callback.onPlaybackResumption(),
|
||||||
|
which this app cannot implement because playback lives in
|
||||||
|
Rust behind a WebView round trip), and falls to a service
|
||||||
|
declared under MediaLibraryService and then
|
||||||
|
MediaSessionService. Either way it lands here.
|
||||||
|
|
||||||
|
android:exported is true above for the same reason the
|
||||||
|
filters are here: a browse service Auto cannot bind is a
|
||||||
|
browse service that does not exist. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.media3.session.MediaLibraryService" />
|
||||||
|
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- media3's own media-button receiver, and the reason the app can
|
||||||
|
start COLD from a PLAY key: a Bluetooth head unit's play button,
|
||||||
|
the Quick Settings "resume" card after a reboot, and Android
|
||||||
|
Auto's autoplay on connection are all KEYCODE_MEDIA_PLAY arriving
|
||||||
|
at a process that does not exist. The receiver starts the
|
||||||
|
service in the foreground (MediaService.onStartCommand promotes
|
||||||
|
at once, see holdForeground) and media3 then asks
|
||||||
|
LibraryTree.onPlaybackResumption what to play. It forwards
|
||||||
|
ONLY play / play-pause / headset-hook into a dead app — a next or
|
||||||
|
a pause into nothing is dropped, not started. While the app is
|
||||||
|
alive the framework talks to the session directly and the
|
||||||
|
receiver is not involved. -->
|
||||||
|
<receiver
|
||||||
|
android:name="androidx.media3.session.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- What puts SoulStream in Android Auto's app list at all.
|
||||||
|
The resource is res/xml/automotive_app_desc.xml and the
|
||||||
|
reference is resolved at LINK time, so a missing file fails
|
||||||
|
the build rather than shipping a car-invisible app. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
|
<!-- The one colour pair Android Auto gives a media app, chosen at
|
||||||
|
build time. It cannot follow the artwork the way the phone's
|
||||||
|
accent does, so it is the brand's constant instead. See
|
||||||
|
res/values/car_theme.xml. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application.theme"
|
||||||
|
android:resource="@style/SoulStreamCarTheme" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+1712
File diff suppressed because it is too large.
Load diff
+181
@@ -0,0 +1,181 @@
|
|||||||
|
package com.soulstream.mediasession
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import app.tauri.annotation.Command
|
||||||
|
import app.tauri.annotation.InvokeArg
|
||||||
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import app.tauri.plugin.Plugin
|
||||||
|
|
||||||
|
/** What the card shows — the Rust `NowPlaying`, camelCase. */
|
||||||
|
@InvokeArg
|
||||||
|
class NowPlayingArgs {
|
||||||
|
var title: String? = null
|
||||||
|
var artist: String? = null
|
||||||
|
var album: String? = null
|
||||||
|
var playing: Boolean = false
|
||||||
|
var position: Double = 0.0
|
||||||
|
var duration: Double? = null
|
||||||
|
/** identifies the cover; the bytes only come along when it changes */
|
||||||
|
var artKey: String? = null
|
||||||
|
/** the cover, base64 image bytes */
|
||||||
|
var art: String? = null
|
||||||
|
var hasNext: Boolean = true
|
||||||
|
var hasPrev: Boolean = true
|
||||||
|
/** the bytes are still on their way (a cold fetch from the network);
|
||||||
|
* the car shows a spinner rather than a position. Never sent by the
|
||||||
|
* page — its own player folds buffering into `playing`. */
|
||||||
|
var buffering: Boolean = false
|
||||||
|
/** what the engine is doing while `buffering` — "Searching Soulseek…",
|
||||||
|
* "3 copies · connecting to <peer>…", "Streaming from <peer>…" — shown
|
||||||
|
* on the card's artist line until the bytes sound. A card that went
|
||||||
|
* to the next title and sat at 0:00 read as the player freezing
|
||||||
|
* (2026-09-18); this is the difference. */
|
||||||
|
var status: String? = null
|
||||||
|
/** the queue around the current item — it and up to ten either side.
|
||||||
|
* Since round forty-eight SoulStreamPlayer.getState() turns this into
|
||||||
|
* the media3 playlist the card is built from, and the index into it.
|
||||||
|
* Nullable because a non-null Kotlin property whose setter Jackson
|
||||||
|
* hands a null throws, and the only thing between here and the JS that
|
||||||
|
* builds it is two JSON hops. */
|
||||||
|
var queue: List<QueueItemArgs>? = null
|
||||||
|
/** where the current item sits in `queue` — not in the whole queue;
|
||||||
|
* null when the window is empty */
|
||||||
|
var queueIndex: Int? = null
|
||||||
|
/** where the window starts in the WHOLE queue. A stable media3 uid is
|
||||||
|
* built out of it, so an entry keeps its identity as the window slides
|
||||||
|
* past it; it is also half of the row -> playQueueAt() mapping Auto
|
||||||
|
* will want. Null when the window is empty. */
|
||||||
|
var queueBase: Int? = null
|
||||||
|
/** Repeat, in the APP's numbering: 0 off, 1 all, 2 one. media3 numbers
|
||||||
|
* them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and SoulStreamPlayer maps across
|
||||||
|
* rather than casting. */
|
||||||
|
var repeat: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of the queue window. Flat and small: it crosses the bridge on
|
||||||
|
* every update, so it holds what a media3 MediaItem is built from and no
|
||||||
|
* more. `artKey` names the cover the way NowPlayingArgs.artKey does; the
|
||||||
|
* bytes never ride along here. */
|
||||||
|
@InvokeArg
|
||||||
|
class QueueItemArgs {
|
||||||
|
var id: String? = null
|
||||||
|
var title: String? = null
|
||||||
|
var artist: String? = null
|
||||||
|
var album: String? = null
|
||||||
|
var duration: Double? = null
|
||||||
|
var artKey: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a row the car handed to the page did not play — the Rust
|
||||||
|
* `RemoteFailure`, camelCase. */
|
||||||
|
@InvokeArg
|
||||||
|
class RemoteFailureArgs {
|
||||||
|
var message: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bridge between the WebView and the service that owns the session.
|
||||||
|
* `update` mirrors the track on the bar (and starts the service the first
|
||||||
|
* time); `clear` takes the card down. The session's buttons come back as
|
||||||
|
* the plugin event `action` with `{ action, position }`.
|
||||||
|
*/
|
||||||
|
@TauriPlugin
|
||||||
|
class MediaSessionPlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
|
private var askedForNotifications = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
MediaHub.onAction = { action, position ->
|
||||||
|
val payload = JSObject()
|
||||||
|
payload.put("action", action)
|
||||||
|
payload.put("position", position)
|
||||||
|
trigger("action", payload)
|
||||||
|
}
|
||||||
|
/* The car asking the APP to play a row — any row, while the page is
|
||||||
|
up and owns the queue. Setting this is also what tells the media
|
||||||
|
service the app is warm — it is null whenever there is no WebView. */
|
||||||
|
android.util.Log.i("SoulStream", "auto plugin init — warm hand-off armed")
|
||||||
|
MediaHub.onPlay = { desc, startMs ->
|
||||||
|
val payload = JSObject()
|
||||||
|
payload.put("action", "playMediaId")
|
||||||
|
payload.put("desc", desc)
|
||||||
|
payload.put("startMs", startMs)
|
||||||
|
trigger("action", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page is going away — the app swiped out of recents, say — while
|
||||||
|
* the media service may keep this process alive for the car. From here
|
||||||
|
* there is nobody behind `trigger`, so the hand-off must stop believing
|
||||||
|
* the app is warm: a tap in the car would otherwise be posted to a
|
||||||
|
* WebView that no longer exists and answered by nothing. Null means the
|
||||||
|
* engine in this process plays what it can and the car is told about the
|
||||||
|
* rest, exactly as on a cold start. A new Activity builds a new plugin,
|
||||||
|
* whose init arms both again. */
|
||||||
|
override fun onDestroy() {
|
||||||
|
MediaHub.onPlay = null
|
||||||
|
MediaHub.onAction = null
|
||||||
|
android.util.Log.i("SoulStream", "auto plugin destroyed — hand-off disarmed")
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun update(invoke: Invoke) {
|
||||||
|
val args = invoke.parseArgs(NowPlayingArgs::class.java)
|
||||||
|
/* The window is on no screen anywhere — the card shows ONE track —
|
||||||
|
so logcat is the only place on the device the whole of it can be
|
||||||
|
read back, and tests/mobile/smoke.mjs reads it from exactly here.
|
||||||
|
One line per update, and an update is a track change, a transport
|
||||||
|
change or the thirty-second top-up, never a position tick.
|
||||||
|
`base` and `rep` are here for the same reason and a sharper one:
|
||||||
|
since round forty-eight they steer the media3 playlist and what
|
||||||
|
a controller is told about repeat, they appear on no screen, and a
|
||||||
|
camelCase key spelled snake_case arrives as nothing on either hop
|
||||||
|
of this bridge without an error anywhere. `at=` goes LAST because
|
||||||
|
a title may hold anything, spaces and `=` included. */
|
||||||
|
val window = args.queue ?: emptyList()
|
||||||
|
android.util.Log.i(
|
||||||
|
"SoulStream",
|
||||||
|
"media window n=" + window.size + " i=" + (args.queueIndex ?: -1) +
|
||||||
|
" art=" + window.count { !it.artKey.isNullOrEmpty() } +
|
||||||
|
" base=" + (args.queueBase ?: -1) + " rep=" + args.repeat +
|
||||||
|
" at=" + (window.getOrNull(args.queueIndex ?: -1)?.title ?: ""),
|
||||||
|
)
|
||||||
|
ensureNotificationPermission()
|
||||||
|
MediaHub.update(activity.applicationContext, args)
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun clear(invoke: Invoke) {
|
||||||
|
MediaHub.clear(activity.applicationContext)
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page could not play a row the car handed it. The session says
|
||||||
|
* why, on the car screen, instead of leaving the driver in silence. */
|
||||||
|
@Command
|
||||||
|
fun remoteFailed(invoke: Invoke) {
|
||||||
|
val args = invoke.parseArgs(RemoteFailureArgs::class.java)
|
||||||
|
MediaHub.fail(activity.applicationContext, args.message ?: "SoulStream could not play that")
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android 13+: the card is a notification and needs the permission; asked
|
||||||
|
* once, the first time something plays. Media controls in the shade and
|
||||||
|
* on the lock screen follow the session either way. */
|
||||||
|
private fun ensureNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
|
||||||
|
askedForNotifications = true
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) return
|
||||||
|
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4711)
|
||||||
|
}
|
||||||
|
}
|
||||||
+572
@@ -0,0 +1,572 @@
|
|||||||
|
package com.soulstream.mediasession
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.AudioAttributes
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.SimpleBasePlayer
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import com.google.common.util.concurrent.Futures
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
|
import com.google.common.util.concurrent.SettableFuture
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Player` media3 shows the world, over an engine it cannot see.
|
||||||
|
*
|
||||||
|
* rodio owns playback, in Rust, and Kotlin has never touched a sample — so
|
||||||
|
* there is no `ExoPlayer` here and there never will be. `SimpleBasePlayer`
|
||||||
|
* is media3's supported answer to exactly that shape: implement
|
||||||
|
* [getState] as a pure read of whatever the real engine last said, route
|
||||||
|
* every transport call into the engine, and call [invalidateState] when the
|
||||||
|
* engine answers. `MediaHub.state` is that "whatever it last said" — the
|
||||||
|
* last `NowPlayingArgs` the WebView pushed across the bridge — and
|
||||||
|
* `MediaHub.act` is the way back out to it.
|
||||||
|
*
|
||||||
|
* The opt-in is `androidx.annotation.OptIn`, imported by name because
|
||||||
|
* `kotlin.OptIn` is in every file's default scope and would otherwise win —
|
||||||
|
* and written `markerClass = [...]` because that is the attribute's real
|
||||||
|
* name in either reading of it. `@UnstableApi` is `RetentionPolicy.CLASS`
|
||||||
|
* plus androidx's own `RequiresOptIn`, which makes it a LINT error and not a
|
||||||
|
* compiler one: the debug build this project has always made never runs
|
||||||
|
* lint, `lintVitalArm64Release` would, and the annotation keeps that honest
|
||||||
|
* either way.
|
||||||
|
*/
|
||||||
|
@OptIn(markerClass = [UnstableApi::class])
|
||||||
|
class SoulStreamPlayer(private val ctx: Context) : SimpleBasePlayer(Looper.getMainLooper()) {
|
||||||
|
companion object {
|
||||||
|
/** How long a transport command may wait for the WebView before the
|
||||||
|
* card is allowed to snap back to the truth. See [awaitUpdate]. */
|
||||||
|
const val ACT_TIMEOUT_MS = 2500L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
/** Commands sent out to the WebView and not yet answered. Main thread only. */
|
||||||
|
private val pending = ArrayList<SettableFuture<Void>>()
|
||||||
|
private val expire = Runnable { settle() }
|
||||||
|
private var released = false
|
||||||
|
|
||||||
|
/* ── the state media3 reads ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
override fun getState(): SimpleBasePlayer.State {
|
||||||
|
/* Something asked for could not play, and the reason is what the
|
||||||
|
car should show rather than a blank player or the last track's
|
||||||
|
card. A player error is only legal in STATE_IDLE, and media3's
|
||||||
|
legacy stub turns exactly that into PlaybackStateCompat's
|
||||||
|
STATE_ERROR with the message — which Android Auto draws as a card
|
||||||
|
over the browse list. The playlist is kept (if there was one) so
|
||||||
|
the transport does not lose its shape; the next update clears it. */
|
||||||
|
MediaHub.error?.let { why ->
|
||||||
|
val err = androidx.media3.common.PlaybackException(
|
||||||
|
why, null, androidx.media3.common.PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
|
||||||
|
)
|
||||||
|
return SimpleBasePlayer.State.Builder()
|
||||||
|
.setAvailableCommands(commands(null))
|
||||||
|
.setPlaybackState(Player.STATE_IDLE)
|
||||||
|
.setPlayerError(err)
|
||||||
|
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
val np = MediaHub.state
|
||||||
|
?: return SimpleBasePlayer.State.Builder()
|
||||||
|
.setAvailableCommands(commands(null))
|
||||||
|
.setPlaybackState(Player.STATE_IDLE)
|
||||||
|
.setPlaylist(emptyList<SimpleBasePlayer.MediaItemData>())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/* THE PLAYLIST MUST NEVER BE EMPTY WHILE A TRACK EXISTS.
|
||||||
|
MediaNotificationManager.shouldShowNotification returns false the
|
||||||
|
moment `controller.getCurrentTimeline().isEmpty()`, and it says
|
||||||
|
nothing anywhere when it does — the card simply never appears.
|
||||||
|
Today's frontend cannot send an empty window with a track on it:
|
||||||
|
media.svelte.js builds `win = []` only when `player.qi < 0`, and
|
||||||
|
`player.track` is `queue[qi]`, so that same state sends `clear`
|
||||||
|
instead. The one-item fallback is here because the failure it
|
||||||
|
guards is invisible and the guard is three lines: a frontend older
|
||||||
|
or newer than this plugin, or a `queue` that did not survive the
|
||||||
|
two JSON hops, would otherwise take the card down silently. */
|
||||||
|
val window = np.queue ?: emptyList()
|
||||||
|
var at = np.queueIndex ?: -1
|
||||||
|
val items = ArrayList<SimpleBasePlayer.MediaItemData>()
|
||||||
|
if (window.isEmpty() || at < 0 || at >= window.size) {
|
||||||
|
at = 0
|
||||||
|
items.add(item("solo", null, np.title, np.artist, np.album, np.duration, null, true))
|
||||||
|
} else {
|
||||||
|
for (i in window.indices) {
|
||||||
|
val q = window[i]
|
||||||
|
/* A STABLE uid. `queueBase` is where the window starts in the
|
||||||
|
whole queue, so an entry keeps its identity as the window
|
||||||
|
slides past it and media3 reports one media-item transition
|
||||||
|
per track change rather than re-labelling all twenty-one.
|
||||||
|
Without it (an older frontend across the bridge) fall back
|
||||||
|
to the slot, which is unique within one playlist — that is
|
||||||
|
all setPlaylist demands — at the cost of a rebuild. */
|
||||||
|
val base = np.queueBase
|
||||||
|
val uid = if (base != null) "q" + (base + i) else "w" + i + ":" + (q.id ?: "")
|
||||||
|
items.add(item(uid, q.id, q.title, q.artist, q.album, q.duration, q.artKey, i == at))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* THE POSITION COMES FROM THE ENGINE, not from the card.
|
||||||
|
`np.position` is whatever the state was BUILT with, and on the
|
||||||
|
headless path that is zero for the life of the track — nothing
|
||||||
|
writes it again. media3 extrapolates while playing, so the bar
|
||||||
|
moved and looked correct, but every rebuild snapped back to zero:
|
||||||
|
pausing read as the track restarting, and a seek that genuinely
|
||||||
|
happened showed as nothing. Asking the engine costs one JNI call
|
||||||
|
per rebuild and is always true. */
|
||||||
|
var posMs = if (np.position > 0) (np.position * 1000).toLong() else 0L
|
||||||
|
var sounding = np.playing
|
||||||
|
var buffering = false
|
||||||
|
if (headless()) {
|
||||||
|
posMs = AutoNative.positionMs()
|
||||||
|
sounding = AutoNative.sounding()
|
||||||
|
/* a cold fetch whose first bytes are still on their way: the car
|
||||||
|
draws a spinner, and media3 does not extrapolate a position
|
||||||
|
that is not moving */
|
||||||
|
buffering = np.buffering && !sounding
|
||||||
|
}
|
||||||
|
return SimpleBasePlayer.State.Builder()
|
||||||
|
.setAvailableCommands(commands(np))
|
||||||
|
/* STATE_READY even when paused. shouldShowNotification treats
|
||||||
|
STATE_IDLE as "nothing to show" unless it has been prepared and
|
||||||
|
stopped, so a paused player reporting IDLE loses its card.
|
||||||
|
The page's own player folds buffering into `playing`; only the
|
||||||
|
headless fetch reports BUFFERING, above. */
|
||||||
|
.setPlaybackState(if (buffering) Player.STATE_BUFFERING else Player.STATE_READY)
|
||||||
|
.setPlayWhenReady(sounding || buffering, Player.PLAY_WHEN_READY_CHANGE_REASON_REMOTE)
|
||||||
|
.setPlaylist(items)
|
||||||
|
.setCurrentMediaItemIndex(at)
|
||||||
|
/* The long overload, not a PositionSupplier: State's own
|
||||||
|
constructor turns it into PositionSupplier.getExtrapolating
|
||||||
|
when playWhenReady and STATE_READY, and getConstant otherwise —
|
||||||
|
which is precisely "the card ticks while playing and holds
|
||||||
|
while paused", for free and in one place. */
|
||||||
|
.setContentPositionMs(posMs)
|
||||||
|
/* The app's numbering is 0 off · 1 ALL · 2 ONE; media3's is
|
||||||
|
0 OFF · 1 ONE · 2 ALL. They are SWAPPED, and mapping them by
|
||||||
|
value would silently turn repeat-all into repeat-one. It is
|
||||||
|
mirrored at all so the player's state is not a lie to whatever
|
||||||
|
reads it — the system's own media controls now, Android Auto
|
||||||
|
later — and so media3's view of "is there a next item" agrees
|
||||||
|
with the hasNext the next button is advertised on. The BUTTON
|
||||||
|
does not hang on it: BasePlayer forwards a seek it cannot
|
||||||
|
resolve rather than dropping it (ignoreSeek -> handleSeek with
|
||||||
|
C.INDEX_UNSET), so an advertised command always arrives. */
|
||||||
|
.setRepeatMode(
|
||||||
|
when (np.repeat) {
|
||||||
|
1 -> Player.REPEAT_MODE_ALL
|
||||||
|
2 -> Player.REPEAT_MODE_ONE
|
||||||
|
else -> Player.REPEAT_MODE_OFF
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setAudioAttributes(
|
||||||
|
AudioAttributes.Builder()
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One row of the window as media3 wants it. `current` is the one whose
|
||||||
|
* cover rides along as bytes; the rest name a file and media3's own
|
||||||
|
* BitmapLoader reads it if anything ever asks. */
|
||||||
|
private fun item(
|
||||||
|
uid: String,
|
||||||
|
id: String?,
|
||||||
|
title: String?,
|
||||||
|
artist: String?,
|
||||||
|
album: String?,
|
||||||
|
duration: Double?,
|
||||||
|
artKey: String?,
|
||||||
|
current: Boolean,
|
||||||
|
): SimpleBasePlayer.MediaItemData {
|
||||||
|
/* while the engine is finding the current row's bytes, its status
|
||||||
|
line rides the artist line — the one line the car shows under
|
||||||
|
the title */
|
||||||
|
val status = if (current) MediaHub.state?.let { s -> if (s.buffering) s.status else null } else null
|
||||||
|
val md = MediaMetadata.Builder()
|
||||||
|
.setTitle(if (current) (title ?: "SoulStream") else (title ?: ""))
|
||||||
|
.setArtist(status?.takeIf { it.isNotEmpty() } ?: (artist ?: ""))
|
||||||
|
.setAlbumTitle(album ?: "")
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setIsPlayable(true)
|
||||||
|
if (current) {
|
||||||
|
/* The cover, or the app's own icon. `artBytes` is cleared the
|
||||||
|
moment `artKey` changes and only refilled once the frontend has
|
||||||
|
decoded one — and a track with no cover never refills it — so
|
||||||
|
without the fallback every track change has a window where the
|
||||||
|
lock screen shows a grey hole. media3 decodes these bytes
|
||||||
|
itself (CacheBitmapLoader over DataSourceBitmapLoader), which
|
||||||
|
is one BitmapFactory call this file no longer makes. */
|
||||||
|
val bytes = MediaHub.artBytes
|
||||||
|
val remote = MediaHub.artKey?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
||||||
|
if (bytes != null) {
|
||||||
|
md.setArtworkData(bytes, MediaMetadata.PICTURE_TYPE_FRONT_COVER)
|
||||||
|
} else if (remote != null) {
|
||||||
|
/* A streamed record's cover is a URL and no bytes ever arrive
|
||||||
|
for it on the headless path — there is no page to decode
|
||||||
|
one. media3's own loader fetches a URI, so the car gets
|
||||||
|
the sleeve and the colour it washes the screen with,
|
||||||
|
instead of the app icon's blue. */
|
||||||
|
md.setArtworkUri(Uri.parse(remote))
|
||||||
|
} else {
|
||||||
|
MediaHub.appIcon(ctx)?.let { md.setArtworkData(it, MediaMetadata.PICTURE_TYPE_FRONT_COVER) }
|
||||||
|
}
|
||||||
|
} else if (!artKey.isNullOrEmpty()) {
|
||||||
|
/* A LOCAL path only. A catalog record's art key is an http URL,
|
||||||
|
and media3's default loader would happily fetch it — twenty
|
||||||
|
times, off a queue nothing is looking at. */
|
||||||
|
val f = File(artKey)
|
||||||
|
if (f.isAbsolute) md.setArtworkUri(Uri.fromFile(f))
|
||||||
|
}
|
||||||
|
val meta = md.build()
|
||||||
|
val durUs = if (duration != null && duration > 0) (duration * 1_000_000).toLong() else C.TIME_UNSET
|
||||||
|
return SimpleBasePlayer.MediaItemData.Builder(uid)
|
||||||
|
.setMediaItem(MediaItem.Builder().setMediaId(id ?: uid).setMediaMetadata(meta).build())
|
||||||
|
.setMediaMetadata(meta)
|
||||||
|
.setIsSeekable(true)
|
||||||
|
.setIsDynamic(false)
|
||||||
|
.setDurationUs(durUs)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the card, the lock screen and a headset are allowed to ask for.
|
||||||
|
*
|
||||||
|
* COMMAND_GET_TIMELINE and COMMAND_GET_CURRENT_MEDIA_ITEM go together
|
||||||
|
* and neither is optional. The media notification controller reads the
|
||||||
|
* playlist THROUGH the session, and PlayerInfo.filterByAvailableCommands
|
||||||
|
* cuts what it may see down to what its commands allow: one window
|
||||||
|
* without COMMAND_GET_TIMELINE, and Timeline.EMPTY without either. An
|
||||||
|
* empty timeline on the far side is shouldShowNotification refusing the
|
||||||
|
* card — the same silent failure as an empty playlist, from the other
|
||||||
|
* direction.
|
||||||
|
*
|
||||||
|
* Both spellings of next and previous, because
|
||||||
|
* DefaultMediaNotificationProvider.getMediaButtons SHOWS the button on
|
||||||
|
* containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) and
|
||||||
|
* then sets the button's command to the _MEDIA_ITEM one: advertise only
|
||||||
|
* the plain one and the button appears and does nothing.
|
||||||
|
*
|
||||||
|
* COMMAND_SET_MEDIA_ITEM arrived with the round that plays from the car:
|
||||||
|
* it is what lets a controller hand us a media id at all, and without it
|
||||||
|
* a browse row is drawn as playable and does nothing when tapped. It is
|
||||||
|
* safe to advertise unconditionally because [handleSetMediaItems]
|
||||||
|
* resolves the id against the library itself rather than trusting a URI.
|
||||||
|
*
|
||||||
|
* Still not here, on purpose: COMMAND_CHANGE_MEDIA_ITEMS and
|
||||||
|
* COMMAND_SEEK_TO_MEDIA_ITEM. Tapping a row of the QUEUE is a different
|
||||||
|
* thing from tapping a row of the library — it needs a window-index →
|
||||||
|
* queue-index mapping, and `queueBase` is only half of that. */
|
||||||
|
private fun commands(np: NowPlayingArgs?): Player.Commands {
|
||||||
|
val b = Player.Commands.Builder()
|
||||||
|
.add(Player.COMMAND_PLAY_PAUSE)
|
||||||
|
.add(Player.COMMAND_PREPARE)
|
||||||
|
.add(Player.COMMAND_STOP)
|
||||||
|
.add(Player.COMMAND_RELEASE)
|
||||||
|
.add(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM)
|
||||||
|
.add(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)
|
||||||
|
.add(Player.COMMAND_GET_CURRENT_MEDIA_ITEM)
|
||||||
|
.add(Player.COMMAND_GET_TIMELINE)
|
||||||
|
.add(Player.COMMAND_GET_METADATA)
|
||||||
|
.add(Player.COMMAND_GET_AUDIO_ATTRIBUTES)
|
||||||
|
.add(Player.COMMAND_SET_AUDIO_ATTRIBUTES)
|
||||||
|
.add(Player.COMMAND_SET_MEDIA_ITEM)
|
||||||
|
if (np != null) {
|
||||||
|
b.addIf(Player.COMMAND_SEEK_TO_NEXT, np.hasNext)
|
||||||
|
b.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, np.hasNext)
|
||||||
|
b.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||||
|
b.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||||
|
}
|
||||||
|
return b.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the transport, back out to the WebView ──────────────────────── */
|
||||||
|
|
||||||
|
override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> {
|
||||||
|
android.util.Log.i(
|
||||||
|
"SoulStream",
|
||||||
|
"auto playWhenReady=" + playWhenReady + " headless=" + headless(),
|
||||||
|
)
|
||||||
|
if (headless() || !MediaHub.warm()) {
|
||||||
|
/* the engine's own queue, or a page that is gone (the app swiped
|
||||||
|
away under live audio): pause and resume belong to the engine
|
||||||
|
either way, and a message to a WebView that no longer exists
|
||||||
|
is the button doing nothing */
|
||||||
|
AutoNative.transport(if (playWhenReady) "resume" else "pause")
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
MediaHub.act(if (playWhenReady) "play" else "pause")
|
||||||
|
return awaitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the engine in this process is the thing playing and there is
|
||||||
|
* no WebView to send a transport command to.
|
||||||
|
*
|
||||||
|
* Every control here used to be a round trip out to the JS. In a car that
|
||||||
|
* started a cold process there is no JS, so pause, seek, next and previous
|
||||||
|
* were all messages to nobody — the buttons drew, moved, and did nothing.
|
||||||
|
* `driving()` is true only once the headless queue holds something, so a
|
||||||
|
* normal phone session is untouched and still goes through the WebView,
|
||||||
|
* which owns shuffle, repeat, `alts` failover and the real queue.
|
||||||
|
*/
|
||||||
|
private fun headless(): Boolean =
|
||||||
|
try {
|
||||||
|
AutoNative.driving()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nothing to prepare: the Rust side sources a track on its own, and
|
||||||
|
* `getState` never reports STATE_IDLE while there is one. Answered
|
||||||
|
* rather than left to throw, because the default body of every
|
||||||
|
* `handle*` is `throw IllegalStateException`. */
|
||||||
|
override fun handlePrepare(): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row tapped in the car.
|
||||||
|
*
|
||||||
|
* The id is resolved against the library IN RUST, not from a URI on the
|
||||||
|
* item: the browse tree hands out `ss:track:<id>` and nothing else, so
|
||||||
|
* there is no file path to trust and no way for a controller to name a
|
||||||
|
* file we did not publish.
|
||||||
|
*
|
||||||
|
* It runs off the caller's thread because the first tap in a cold process
|
||||||
|
* does two slow things — opens the database and starts the audio engine —
|
||||||
|
* and this is called on the application thread, where ten seconds is an
|
||||||
|
* ANR. The future resolving is what tells media3 the command took.
|
||||||
|
*
|
||||||
|
* `startIndex` can be `INDEX_UNSET`; that means "play the list from the
|
||||||
|
* top", which for a single tapped row is the row itself.
|
||||||
|
*/
|
||||||
|
override fun handleSetMediaItems(
|
||||||
|
mediaItems: MutableList<MediaItem>,
|
||||||
|
startIndex: Int,
|
||||||
|
startPositionMs: Long,
|
||||||
|
): ListenableFuture<*> {
|
||||||
|
val i = if (startIndex == androidx.media3.common.C.INDEX_UNSET) 0 else startIndex
|
||||||
|
val id = mediaItems.getOrNull(i)?.mediaId
|
||||||
|
if (id.isNullOrEmpty()) return Futures.immediateVoidFuture()
|
||||||
|
// The position media3 asks for: a resumption carries the saved one, a
|
||||||
|
// tapped row carries TIME_UNSET. Below zero tells Rust "where it was
|
||||||
|
// left", which only the resume row has an answer to.
|
||||||
|
val startMs = if (startPositionMs == C.TIME_UNSET || startPositionMs < 0) -1L else startPositionMs
|
||||||
|
return Futures.submit<Unit>(
|
||||||
|
{
|
||||||
|
// THE APP IS UP: the page owns the queue, so the tap is the
|
||||||
|
// page's — whatever the row is. Starting it on the engine
|
||||||
|
// underneath the page instead (which is what this did for a
|
||||||
|
// library album while the page had a queue of its own) left
|
||||||
|
// two drivers on one engine: the bar naming one track while
|
||||||
|
// another played, and the next boundary decided by whichever
|
||||||
|
// got there first. The page asks for focus itself when it
|
||||||
|
// plays, and reports back through remote_failed if it cannot.
|
||||||
|
if (MediaHub.warm()) {
|
||||||
|
val desc = AutoNative.describe(ctx.dataDir.absolutePath, id)
|
||||||
|
android.util.Log.i("SoulStream", "auto hand-off to the app: $id -> $desc")
|
||||||
|
MediaHub.play(desc, startMs)
|
||||||
|
return@submit
|
||||||
|
}
|
||||||
|
// FOCUS FIRST, and it is not optional. Without it the track
|
||||||
|
// opens into a port the framework keeps muted: the first run
|
||||||
|
// of this path showed a live AAudio track on our uid with
|
||||||
|
// PortMuted=true and the gain at -inf — decoding perfectly,
|
||||||
|
// audible to nobody. On the WebView path the JS asks for
|
||||||
|
// focus; a car tapping a cold process has no JS to do it.
|
||||||
|
MediaHub.requestFocus(ctx.applicationContext)
|
||||||
|
val reply = AutoNative.play(ctx.dataDir.absolutePath, id, startMs)
|
||||||
|
val o = try { org.json.JSONObject(reply) } catch (e: Throwable) { org.json.JSONObject() }
|
||||||
|
val queued = o.optInt("queued", 0)
|
||||||
|
android.util.Log.i("SoulStream", "auto play $id queued=$queued start=$startMs")
|
||||||
|
if (queued == 0) {
|
||||||
|
// Nothing on this phone can play it, and the process is
|
||||||
|
// cold: the focus goes back rather than being held for
|
||||||
|
// silence — and the car is TOLD, which it never was
|
||||||
|
// before. A streamed row is fetched from the network by
|
||||||
|
// this process now (headless_slsk.rs); `error` is its
|
||||||
|
// reason when that could not even start.
|
||||||
|
MediaHub.dropFocus(ctx.applicationContext)
|
||||||
|
val why = if (o.isNull("error")) null else o.optString("error").ifEmpty { null }
|
||||||
|
if (why != null) {
|
||||||
|
android.util.Log.i("SoulStream", "auto cold fetch refused: $why")
|
||||||
|
MediaHub.fail(ctx.applicationContext, why)
|
||||||
|
} else if (o.optBoolean("remote", false)) {
|
||||||
|
android.util.Log.i("SoulStream", "auto cold, cannot resolve: $id")
|
||||||
|
MediaHub.fail(
|
||||||
|
ctx.applicationContext,
|
||||||
|
"This track streams from the network. Open SoulStream on your phone to play it here.",
|
||||||
|
)
|
||||||
|
} else if (id == "ss:resume") {
|
||||||
|
MediaHub.fail(ctx.applicationContext, "Nothing to continue yet.")
|
||||||
|
} else {
|
||||||
|
MediaHub.fail(ctx.applicationContext, "That track is not on this phone any more.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Write the card ourselves. `getState()` mirrors
|
||||||
|
// MediaHub.state, and the only thing that ever fills it is
|
||||||
|
// the WebView — so a cold car tap played audio while the
|
||||||
|
// session still said STATE_NONE with no title, which is a
|
||||||
|
// head unit showing a blank player over music. The engine
|
||||||
|
// knows what it started; this is that, in the shape the
|
||||||
|
// card already reads.
|
||||||
|
val np = NowPlayingArgs()
|
||||||
|
np.title = o.optString("title").ifEmpty { null }
|
||||||
|
np.artist = o.optString("artist").ifEmpty { null }
|
||||||
|
np.album = o.optString("album").ifEmpty { null }
|
||||||
|
np.duration = if (o.isNull("duration")) null else o.optDouble("duration")
|
||||||
|
/* a resumed track starts where it was left; the engine's
|
||||||
|
own position takes over from the first tick */
|
||||||
|
np.position = o.optLong("positionMs", 0L) / 1000.0
|
||||||
|
np.playing = true
|
||||||
|
/* a cold fetch: the title now, a spinner until the first
|
||||||
|
bytes play (headlessState turns it off) */
|
||||||
|
np.buffering = o.optBoolean("resolving", false)
|
||||||
|
/* the engine narrated inside the call that just returned
|
||||||
|
(headlessState ran before this line): keep its line,
|
||||||
|
or the first one, rather than the artist for a beat */
|
||||||
|
np.status = if (np.buffering) (MediaHub.state?.status ?: "Finding a copy…") else null
|
||||||
|
np.hasNext = o.optBoolean("hasNext", queued > 1)
|
||||||
|
np.hasPrev = false
|
||||||
|
// The cover, which is also the colour: Android Auto washes
|
||||||
|
// the playback screen with whatever artwork it is handed,
|
||||||
|
// so shipping no art tinted every record with the app
|
||||||
|
// icon's blue.
|
||||||
|
val art = if (o.isNull("art")) null else o.optString("art").ifEmpty { null }
|
||||||
|
np.artKey = art
|
||||||
|
np.art = AutoNative.artB64(art)
|
||||||
|
MediaHub.update(ctx.applicationContext, np)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playIo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One thread, for the tap that opens a database and starts an engine. */
|
||||||
|
private val playIo =
|
||||||
|
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
|
||||||
|
Thread(r, "soulstream-auto-play").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleStop(): ListenableFuture<*> {
|
||||||
|
if (headless() || !MediaHub.warm()) {
|
||||||
|
AutoNative.transport("stop")
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
MediaHub.act("stop")
|
||||||
|
return awaitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleRelease(): ListenableFuture<*> {
|
||||||
|
released = true
|
||||||
|
main.removeCallbacks(expire)
|
||||||
|
val batch = ArrayList(pending)
|
||||||
|
pending.clear()
|
||||||
|
/* posted, not completed here: finishing a future re-enters media3's
|
||||||
|
own listener, and this one is already inside a player call */
|
||||||
|
main.post { for (f in batch) f.set(null) }
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A no-op that must exist. `setAudioAttributes(attrs, handleAudioFocus)`
|
||||||
|
* is an ExoPlayer convenience: on SimpleBasePlayer it lands HERE, whose
|
||||||
|
* default body throws, and media3 does none of the focus work for a
|
||||||
|
* custom player — `AudioFocusManager` is inside media3-exoplayer and is
|
||||||
|
* not public API. Focus is still ours, requested and abandoned by hand
|
||||||
|
* in MediaService, exactly as it was before the migration. */
|
||||||
|
override fun handleSetAudioAttributes(
|
||||||
|
audioAttributes: AudioAttributes,
|
||||||
|
handleAudioFocus: Boolean,
|
||||||
|
): ListenableFuture<*> = Futures.immediateVoidFuture()
|
||||||
|
|
||||||
|
/** Every seek, next and previous arrives here — there is no
|
||||||
|
* `handleSeekToNext` and never was — distinguished by the command that
|
||||||
|
* triggered it. The index is ignored on purpose: the app's own `next()`
|
||||||
|
* and `prev()` know about repeat, about the five-second restart rule and
|
||||||
|
* about the end of the queue, and the window is only ever a view.
|
||||||
|
*
|
||||||
|
* It arrives even when media3 has nothing to seek TO: `BasePlayer`
|
||||||
|
* forwards such a press rather than dropping it, as
|
||||||
|
* `ignoreSeek` -> `handleSeek(C.INDEX_UNSET, C.TIME_UNSET, command)`.
|
||||||
|
* Which is why the previous button still works at the very head of a
|
||||||
|
* queue, where the timeline has no previous item but the app's own
|
||||||
|
* `prev()` seeks to zero. The gate is the COMMAND being advertised,
|
||||||
|
* and nothing else. */
|
||||||
|
override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> {
|
||||||
|
val secs = if (positionMs == C.TIME_UNSET) 0.0 else positionMs / 1000.0
|
||||||
|
if (headless() || !MediaHub.warm()) {
|
||||||
|
when (seekCommand) {
|
||||||
|
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
|
||||||
|
AutoNative.transport("next")
|
||||||
|
Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
|
||||||
|
AutoNative.transport("prev")
|
||||||
|
else -> AutoNative.seek(secs)
|
||||||
|
}
|
||||||
|
return Futures.immediateVoidFuture()
|
||||||
|
}
|
||||||
|
when (seekCommand) {
|
||||||
|
Player.COMMAND_SEEK_TO_NEXT, Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaHub.act("next")
|
||||||
|
Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaHub.act("prev")
|
||||||
|
else -> MediaHub.act("seek", secs)
|
||||||
|
}
|
||||||
|
return awaitUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── the round trip ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A transport command is a round trip: out through `Plugin.trigger` to
|
||||||
|
* the WebView, into the player, down to Rust, and back as the next
|
||||||
|
* `update`. Answering media3 immediately would have it call [getState]
|
||||||
|
* on the spot — which still reports the OLD state — and the lock screen
|
||||||
|
* flips back before it flips forward.
|
||||||
|
*
|
||||||
|
* So hand it a future nobody has completed. While one is outstanding
|
||||||
|
* media3 shows its own suggested placeholder (play-pause flipped, the
|
||||||
|
* seek applied), which is exactly the optimistic answer we want, and
|
||||||
|
* [invalidateState] deliberately does nothing until the last one
|
||||||
|
* resolves. `MediaHub.update` resolves them; [expire] resolves them
|
||||||
|
* anyway after [ACT_TIMEOUT_MS], because a future that is never
|
||||||
|
* completed does not degrade the card — it FREEZES it.
|
||||||
|
*/
|
||||||
|
private fun awaitUpdate(): ListenableFuture<*> {
|
||||||
|
if (released) return Futures.immediateVoidFuture()
|
||||||
|
val f = SettableFuture.create<Void>()
|
||||||
|
pending.add(f)
|
||||||
|
main.removeCallbacks(expire)
|
||||||
|
main.postDelayed(expire, ACT_TIMEOUT_MS)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The engine answered (or stopped answering). Main thread only — every
|
||||||
|
* caller is `MediaHub`'s own post to the main looper, or [expire]. */
|
||||||
|
fun settle() {
|
||||||
|
if (pending.isEmpty()) return
|
||||||
|
main.removeCallbacks(expire)
|
||||||
|
val batch = ArrayList(pending)
|
||||||
|
pending.clear()
|
||||||
|
/* completing these re-reads getState() on this same thread, inline */
|
||||||
|
for (f in batch) f.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `invalidateState` is `protected final`; this is the door MediaHub
|
||||||
|
* uses. A no-op once released, and a no-op inside media3 anyway while
|
||||||
|
* anything is still pending. */
|
||||||
|
fun refresh() {
|
||||||
|
if (released) return
|
||||||
|
invalidateState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markReleased() {
|
||||||
|
released = true
|
||||||
|
main.removeCallbacks(expire)
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Shuffle, for the car's playback actions. White and flat on purpose: Android
|
||||||
|
Auto tints these itself and draws them at one size, and anything with its own
|
||||||
|
colour or gradient comes out wrong against the car's day/night switch. The
|
||||||
|
app's own animated glyphs must never reach this surface — SA-1 in the quality
|
||||||
|
checklist forbids animation on a car screen.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="#FFFFFFFF">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5V4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20H20v-5.5l-2.04,2.04 -3.13,-3.13z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp" android:height="36dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55 -2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4V7h4V3h-6zM10,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp" android:height="36dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp" android:height="36dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- A root tab's icon. Flat white; Android Auto tints these for day/night. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="36dp" android:height="36dp"
|
||||||
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:fillColor="#FFFFFFFF" android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
|
||||||
|
</vector>
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- the status-bar glyph: the falcon, white on transparent, as the shade
|
||||||
|
wants — Android composites a notification's small icon from its ALPHA
|
||||||
|
CHANNEL and tints it itself, so a coloured export renders as a white blob
|
||||||
|
and only a screenshot can tell the difference.
|
||||||
|
|
||||||
|
The path is the logo kit's monochrome layer verbatim
|
||||||
|
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
|
||||||
|
drawn in its own large source space. An Android path element has no
|
||||||
|
transform attribute, so the SVG's
|
||||||
|
translate(9.814 9.814) scale(0.0863) translate(-40.740741 20.370370)
|
||||||
|
is folded into the group below and re-framed for a 24dp box: the falcon
|
||||||
|
lands x 4.91 to 19.09, y 2 to 22. It is a narrow mark (0.709:1), so the
|
||||||
|
side margins are generous and the top and bottom are not, which is what a
|
||||||
|
status bar wants. The transfers notice carries the same falcon. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:translateX="-1.6606"
|
||||||
|
android:translateY="-0.7287"
|
||||||
|
android:scaleX="0.026185"
|
||||||
|
android:scaleY="0.026185">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.833 C 535.533Line truncated
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
Named by `com.google.android.gms.car.application.theme` in this module's
|
||||||
|
AndroidManifest.xml. Android Auto reads the colour attributes out of it
|
||||||
|
at connection time; nothing else in the app uses this style, and the car
|
||||||
|
never sees the phone's theme.
|
||||||
|
|
||||||
|
The parent is a framework theme on purpose: this is resolved inside the
|
||||||
|
car's process against the platform, so an AppCompat or Material parent is
|
||||||
|
not guaranteed to be there.
|
||||||
|
-->
|
||||||
|
<style name="SoulStreamCarTheme" parent="android:Theme.DeviceDefault">
|
||||||
|
<item name="android:colorPrimary">@color/ss_car_ground</item>
|
||||||
|
<item name="android:colorPrimaryDark">@color/ss_car_ground</item>
|
||||||
|
<item name="android:colorAccent">@color/ss_car_accent</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
The falcon's teal, from the desktop icon.
|
||||||
|
|
||||||
|
Android Auto allows a media app exactly ONE colour pair, chosen at build
|
||||||
|
time, and it cannot follow the artwork — so where the app derives an
|
||||||
|
accent per cover, the car has to have a constant, and the constant is the
|
||||||
|
brand. It tints the FAB and the ring around play/pause, and nothing else.
|
||||||
|
-->
|
||||||
|
<color name="ss_car_accent">#31C0CB</color>
|
||||||
|
<!-- The app's own ground, so the car's chrome sits on SoulStream's dark
|
||||||
|
rather than the framework's default grey. -->
|
||||||
|
<color name="ss_car_ground">#05080B</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- The channel the card sits on. Its ID stays literally "playback":
|
||||||
|
tests/mobile/smoke.mjs:2448 finds the card by that id, and the
|
||||||
|
channel already on the phone — IMPORTANCE_LOW, lockscreen
|
||||||
|
PUBLIC, no badge — is left exactly as it is. -->
|
||||||
|
<string name="ss_playback_channel">Playback</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- What Android Auto reads to decide this is a media app it can host.
|
||||||
|
Named by the <meta-data android:name="com.google.android.gms.car.
|
||||||
|
application"> in this module's AndroidManifest.xml; "media" is the
|
||||||
|
whole declaration (the other value, "template", is for a car app
|
||||||
|
built on the Car App Library, which this is not). -->
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media" />
|
||||||
|
</automotiveApp>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// registerListener / removeListener are the mobile plugin runtime's own
|
||||||
|
// commands, the ones `addPluginListener` invokes to hear the `action`
|
||||||
|
// event. They are commands like any other to the ACL: without them here the
|
||||||
|
// listener was refused ("media-session.registerListener not allowed") and
|
||||||
|
// the card's buttons went nowhere.
|
||||||
|
// `remote_failed` is the RUST command's name, which is what the ACL keys on
|
||||||
|
// (the invoke is `plugin:media-session|remote_failed`); the Kotlin side's
|
||||||
|
// `remoteFailed` is reached through run_mobile_plugin and never appears here.
|
||||||
|
const COMMANDS: &[&str] = &["update", "clear", "remote_failed", "registerListener", "removeListener"];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Registers the command permissions (permissions/autogenerated) and, on
|
||||||
|
// Android, hands the Gradle module under android/ to tauri-build, which
|
||||||
|
// includes it in the app project (tauri.settings.gradle).
|
||||||
|
tauri_plugin::Builder::new(COMMANDS)
|
||||||
|
.android_path("android")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-clear"
|
||||||
|
description = "Enables the clear command without any pre-configured scope."
|
||||||
|
commands.allow = ["clear"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-clear"
|
||||||
|
description = "Denies the clear command without any pre-configured scope."
|
||||||
|
commands.deny = ["clear"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-registerListener"
|
||||||
|
description = "Enables the registerListener command without any pre-configured scope."
|
||||||
|
commands.allow = ["registerListener"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-registerListener"
|
||||||
|
description = "Denies the registerListener command without any pre-configured scope."
|
||||||
|
commands.deny = ["registerListener"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-remote-failed"
|
||||||
|
description = "Enables the remote_failed command without any pre-configured scope."
|
||||||
|
commands.allow = ["remote_failed"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-remote-failed"
|
||||||
|
description = "Denies the remote_failed command without any pre-configured scope."
|
||||||
|
commands.deny = ["remote_failed"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-removeListener"
|
||||||
|
description = "Enables the removeListener command without any pre-configured scope."
|
||||||
|
commands.allow = ["removeListener"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-removeListener"
|
||||||
|
description = "Denies the removeListener command without any pre-configured scope."
|
||||||
|
commands.deny = ["removeListener"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-update"
|
||||||
|
description = "Enables the update command without any pre-configured scope."
|
||||||
|
commands.allow = ["update"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-update"
|
||||||
|
description = "Denies the update command without any pre-configured scope."
|
||||||
|
commands.deny = ["update"]
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
## Default Permission
|
||||||
|
|
||||||
|
The phone's media session: the lock-screen card, the notification's
|
||||||
|
transport, audio focus. `update` mirrors the track on the bar, `clear`
|
||||||
|
takes the card down, and the two listener commands are what lets the page
|
||||||
|
hear the card's buttons (`addPluginListener`).
|
||||||
|
|
||||||
|
#### This default permission set includes the following:
|
||||||
|
|
||||||
|
- `allow-update`
|
||||||
|
- `allow-clear`
|
||||||
|
- `allow-remote-failed`
|
||||||
|
- `allow-registerListener`
|
||||||
|
- `allow-removeListener`
|
||||||
|
|
||||||
|
## Permission Table
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:allow-clear`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the clear command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:deny-clear`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the clear command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:allow-registerListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the registerListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:deny-registerListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the registerListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:allow-remote-failed`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the remote_failed command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:deny-remote-failed`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the remote_failed command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:allow-removeListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the removeListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:deny-removeListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the removeListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:allow-update`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the update command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`media-session:deny-update`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the update command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"$schema" = "schemas/schema.json"
|
||||||
|
|
||||||
|
[default]
|
||||||
|
description = """
|
||||||
|
The phone's media session: the lock-screen card, the notification's
|
||||||
|
transport, audio focus. `update` mirrors the track on the bar, `clear`
|
||||||
|
takes the card down, and the two listener commands are what lets the page
|
||||||
|
hear the card's buttons (`addPluginListener`).
|
||||||
|
"""
|
||||||
|
permissions = ["allow-update", "allow-clear", "allow-remote-failed", "allow-registerListener", "allow-removeListener"]
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "PermissionFile",
|
||||||
|
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"default": {
|
||||||
|
"description": "The default permission set for the plugin",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/DefaultPermission"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"description": "A list of permissions sets defined",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionSet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"description": "A list of inlined permissions",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Permission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"DefaultPermission": {
|
||||||
|
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionSet": {
|
||||||
|
"description": "A set of direct permissions grouped together under a new name.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"identifier",
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionKind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Permission": {
|
||||||
|
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"description": "Allowed or denied commands when using this permission.",
|
||||||
|
"default": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Commands"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "Allowed or denied scoped when using this permission.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Scopes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Commands": {
|
||||||
|
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Allowed command.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Denied command, which takes priority.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scopes": {
|
||||||
|
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Data that defines what is allowed by the scope.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Value": {
|
||||||
|
"description": "All supported ACL values.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents a null JSON value.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`bool`].",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a valid ACL [`Number`].",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`String`].",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a list of other [`Value`]s.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Number": {
|
||||||
|
"description": "A valid ACL number.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents an [`i64`].",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`f64`].",
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Target": {
|
||||||
|
"description": "Platform target.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "MacOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"macOS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Windows.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Linux.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Android.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "iOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"iOS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PermissionKind": {
|
||||||
|
"type": "string",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "Enables the clear command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-clear",
|
||||||
|
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the clear command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-clear",
|
||||||
|
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the registerListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-registerListener",
|
||||||
|
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the registerListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-registerListener",
|
||||||
|
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the remote_failed command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-remote-failed",
|
||||||
|
"markdownDescription": "Enables the remote_failed command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the remote_failed command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-remote-failed",
|
||||||
|
"markdownDescription": "Denies the remote_failed command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the removeListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-removeListener",
|
||||||
|
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the removeListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-removeListener",
|
||||||
|
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the update command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-update",
|
||||||
|
"markdownDescription": "Enables the update command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the update command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-update",
|
||||||
|
"markdownDescription": "Denies the update command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "default",
|
||||||
|
"markdownDescription": "The phone's media session: the lock-screen card, the notification's\ntransport, audio focus. `update` mirrors the track on the bar, `clear`\ntakes the card down, and the two listener commands are what lets the page\nhear the card's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-remote-failed`\n- `allow-registerListener`\n- `allow-removeListener`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! The media session on the phone.
|
||||||
|
//!
|
||||||
|
//! Android shows a playing app on the lock screen and in the shade through a
|
||||||
|
//! `MediaSession` carried by a foreground service, and it is that service
|
||||||
|
//! that keeps the process alive — and un-frozen — once the app is in the
|
||||||
|
//! background. The audio itself stays where it is, on the Rust audio thread;
|
||||||
|
//! the Kotlin side only mirrors what is playing and hands the card's
|
||||||
|
//! buttons, the headset's buttons and audio-focus changes back as events.
|
||||||
|
//!
|
||||||
|
//! Two commands, both fed by the phone UI (src/mobile/media.svelte.js):
|
||||||
|
//! `update` with the track on the bar, `clear` when the bar empties. The
|
||||||
|
//! session's actions arrive as the plugin event `action`
|
||||||
|
//! (`addPluginListener("media-session", "action", …)`).
|
||||||
|
//!
|
||||||
|
//! On the desktop the plugin is registered too, so the permission set
|
||||||
|
//! resolves on every platform, but its commands answer "not here": the
|
||||||
|
//! desktop has its own card (src-tauri/src/smtc.rs).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What the card shows. `art` carries the cover's bytes (base64) only when
|
||||||
|
/// `art_key` changed since the last update — the card keeps the bitmap.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NowPlaying {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub playing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub position: f64,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub art_key: Option<String>,
|
||||||
|
pub art: Option<String>,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub has_next: bool,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub has_prev: bool,
|
||||||
|
/// The queue around what is playing: the current item and up to ten
|
||||||
|
/// either side, rebuilt on every update.
|
||||||
|
///
|
||||||
|
/// Round forty-eight is where this BECAME the card. `SoulStreamPlayer`
|
||||||
|
/// is a media3 `SimpleBasePlayer`, its `getState()` wants a playlist and
|
||||||
|
/// an index into one, and this window and `queue_index` are exactly that
|
||||||
|
/// — so what the lock screen shows is built out of these fields rather
|
||||||
|
/// than beside them.
|
||||||
|
///
|
||||||
|
/// A CAP, not a length: a two-thousand-track queue crosses the bridge as
|
||||||
|
/// twenty-one entries exactly like a twenty-one-track one does.
|
||||||
|
#[serde(default)]
|
||||||
|
pub queue: Vec<QueueItem>,
|
||||||
|
/// Where the current item sits IN `queue` — not in the whole queue.
|
||||||
|
/// `None` when the window is empty.
|
||||||
|
pub queue_index: Option<u32>,
|
||||||
|
/// Where `queue[0]` sits in the WHOLE queue. A media3 `MediaItemData`
|
||||||
|
/// uid is built from it, so an entry keeps its identity as the window
|
||||||
|
/// slides past it and a track change is ONE media-item transition
|
||||||
|
/// rather than twenty-one re-labelled rows; it is also half of the
|
||||||
|
/// mapping Android Auto needs to turn a tapped queue row back into
|
||||||
|
/// `playQueueAt()`. `None` when the window is empty.
|
||||||
|
pub queue_base: Option<u32>,
|
||||||
|
/// Repeat, in the app's own numbering: 0 off, 1 all, 2 one. media3
|
||||||
|
/// numbers them 0 OFF, 1 ONE, 2 ALL — SWAPPED — and the Kotlin side
|
||||||
|
/// maps across rather than casting, which would turn repeat-all into
|
||||||
|
/// repeat-one. It crosses at all so the player's state is not a lie to
|
||||||
|
/// whatever reads it — the system's own media controls now, Android
|
||||||
|
/// Auto later — and so media3's view of "is there a next item" agrees
|
||||||
|
/// with the `has_next` the card's next button is advertised on.
|
||||||
|
#[serde(default)]
|
||||||
|
pub repeat: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry of the queue window. Deliberately flat and small: this crosses
|
||||||
|
/// two hops of IPC on every update — the WebView to Rust, Rust to Kotlin —
|
||||||
|
/// so it carries what a media3 `MediaItem` is built from and nothing else.
|
||||||
|
/// `art_key` is the same kind of key `NowPlaying::art_key` is, the cover's
|
||||||
|
/// path, so whatever decodes a bitmap for the card can key the window's
|
||||||
|
/// covers off the same thing; no bytes ride along here.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QueueItem {
|
||||||
|
/// The track's own id as a string — a library track's is a number, a
|
||||||
|
/// catalog track's is `cat:<n>`, a playlist row's is `pl<n>`. NOT a
|
||||||
|
/// unique key: one queue may hold the same track twice, and a window
|
||||||
|
/// entry is identified by where it sits, not by this.
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub artist: Option<String>,
|
||||||
|
pub album: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub art_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
|
pub struct MediaSession<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: NowPlaying) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let session = app.state::<MediaSession<R>>();
|
||||||
|
session
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("update", state)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = (app, state);
|
||||||
|
Err("the media session is a phone feature".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let session = app.state::<MediaSession<R>>();
|
||||||
|
session
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("clear", ())
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Err("the media session is a phone feature".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the page sends when a row the CAR handed it could not be played:
|
||||||
|
/// the resolver found nothing, or the network is signed out. The session
|
||||||
|
/// turns it into an error state the head unit can show, with the reason,
|
||||||
|
/// instead of the silence a driver was getting.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RemoteFailure {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn remote_failed<R: Runtime>(app: tauri::AppHandle<R>, failure: RemoteFailure) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let session = app.state::<MediaSession<R>>();
|
||||||
|
session
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("remoteFailed", failure)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = (app, failure);
|
||||||
|
Err("the media session is a phone feature".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("media-session")
|
||||||
|
.invoke_handler(tauri::generate_handler![update, clear, remote_failed])
|
||||||
|
.setup(|app, api| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let handle =
|
||||||
|
api.register_android_plugin("com.soulstream.mediasession", "MediaSessionPlugin")?;
|
||||||
|
app.manage(MediaSession(handle));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let _ = (app, api);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "tauri-plugin-share"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SoulStream on the phone's share sheet: a link or a tracklist sent from another app lands in the playlist import"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
# required by tauri-plugin's build script; must equal the crate name
|
||||||
|
links = "tauri-plugin-share"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-plugin = { version = "2", features = ["build"] }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/.tauri
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.soulstream.share"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
// the plugin's own R8 keep rules, handed to whatever app links this
|
||||||
|
// module — inert unless that app minifies, which only the release
|
||||||
|
// build type does
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation(project(":tauri-android"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Consumer R8/ProGuard rules for the share plugin. Inert unless the consuming
|
||||||
|
# app minifies, and only the `release` build type does, so the debug APK the
|
||||||
|
# deploy pipeline builds is untouched by this file.
|
||||||
|
#
|
||||||
|
# SharePlugin is found by the string "com/soulstream/share/SharePlugin"
|
||||||
|
# (register_android_plugin → FindClass), constructed through
|
||||||
|
# "(Landroid/app/Activity;)V", and its one @Command method — pendingShare —
|
||||||
|
# is dispatched by NAME through PluginHandle's commands map. It takes no
|
||||||
|
# arguments, so there is no @InvokeArg class here. Rename it and text shared
|
||||||
|
# to SoulStream from another app arrives nowhere: the activity still runs and
|
||||||
|
# still brings the app to the front, and the WebView is never handed what it
|
||||||
|
# carried.
|
||||||
|
#
|
||||||
|
# ShareActivity is declared in this module's AndroidManifest.xml, so AGP's
|
||||||
|
# own merged-manifest keep rules hold it by name. Not repeated here: one
|
||||||
|
# place decides it.
|
||||||
|
-keep class com.soulstream.share.SharePlugin { *; }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<!-- SoulStream on the share sheet: plain text — a playlist link, a
|
||||||
|
pasted tracklist — from any app. The activity keeps the text
|
||||||
|
for the WebView (pending_share), brings the app to the front and
|
||||||
|
is gone; it never shows a screen of its own. -->
|
||||||
|
<activity
|
||||||
|
android:name="com.soulstream.share.ShareActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.soulstream.share
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The share sheet's target. Takes the text (Spotify sends the link inside a
|
||||||
|
* sentence; the WebView finds the URL in it), keeps it for the plugin,
|
||||||
|
* brings the app's own task to the front through its launcher intent — the
|
||||||
|
* running one if there is one, so the page and its player are untouched —
|
||||||
|
* and finishes without ever drawing.
|
||||||
|
*/
|
||||||
|
class ShareActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val text = intent?.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
?: intent?.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
if (!text.isNullOrBlank()) SharePlugin.pending = text
|
||||||
|
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
|
if (launch != null) {
|
||||||
|
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
startActivity(launch)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.soulstream.share
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import app.tauri.annotation.Command
|
||||||
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import app.tauri.plugin.Plugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What another app shared to SoulStream, held until the WebView asks.
|
||||||
|
* ShareActivity puts it here (the two live in one process, so a static
|
||||||
|
* holds across the hand-off, a cold start included); `pendingShare` gives
|
||||||
|
* it out once — the second ask answers nothing.
|
||||||
|
*/
|
||||||
|
@TauriPlugin
|
||||||
|
class SharePlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
var pending: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun pendingShare(invoke: Invoke) {
|
||||||
|
val out = JSObject()
|
||||||
|
val text = pending
|
||||||
|
pending = null
|
||||||
|
if (text != null) out.put("text", text)
|
||||||
|
invoke.resolve(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// The one command, as the Rust function is named; the ACL slugs it
|
||||||
|
// (`allow-pending-share`) into permissions/autogenerated.
|
||||||
|
const COMMANDS: &[&str] = &["pending_share"];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Registers the command permission and, on Android, hands the Gradle
|
||||||
|
// module under android/ to tauri-build (only with
|
||||||
|
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md). The module carries
|
||||||
|
// the ShareActivity and its intent filter, merged into the app's manifest.
|
||||||
|
tauri_plugin::Builder::new(COMMANDS)
|
||||||
|
.android_path("android")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-pending-share"
|
||||||
|
description = "Enables the pending_share command without any pre-configured scope."
|
||||||
|
commands.allow = ["pending_share"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-pending-share"
|
||||||
|
description = "Denies the pending_share command without any pre-configured scope."
|
||||||
|
commands.deny = ["pending_share"]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## Default Permission
|
||||||
|
|
||||||
|
The share sheet: what another app sent to SoulStream (a playlist link, a
|
||||||
|
pasted tracklist), read once by the import.
|
||||||
|
|
||||||
|
#### This default permission set includes the following:
|
||||||
|
|
||||||
|
- `allow-pending-share`
|
||||||
|
|
||||||
|
## Permission Table
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`share:allow-pending-share`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the pending_share command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`share:deny-pending-share`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the pending_share command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"$schema" = "schemas/schema.json"
|
||||||
|
|
||||||
|
[default]
|
||||||
|
description = """
|
||||||
|
The share sheet: what another app sent to SoulStream (a playlist link, a
|
||||||
|
pasted tracklist), read once by the import.
|
||||||
|
"""
|
||||||
|
permissions = ["allow-pending-share"]
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "PermissionFile",
|
||||||
|
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"default": {
|
||||||
|
"description": "The default permission set for the plugin",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/DefaultPermission"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"description": "A list of permissions sets defined",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionSet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"description": "A list of inlined permissions",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Permission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"DefaultPermission": {
|
||||||
|
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionSet": {
|
||||||
|
"description": "A set of direct permissions grouped together under a new name.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"identifier",
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionKind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Permission": {
|
||||||
|
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"description": "Allowed or denied commands when using this permission.",
|
||||||
|
"default": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Commands"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "Allowed or denied scoped when using this permission.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Scopes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Commands": {
|
||||||
|
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Allowed command.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Denied command, which takes priority.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scopes": {
|
||||||
|
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Data that defines what is allowed by the scope.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Value": {
|
||||||
|
"description": "All supported ACL values.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents a null JSON value.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`bool`].",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a valid ACL [`Number`].",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`String`].",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a list of other [`Value`]s.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Number": {
|
||||||
|
"description": "A valid ACL number.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents an [`i64`].",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`f64`].",
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Target": {
|
||||||
|
"description": "Platform target.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "MacOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"macOS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Windows.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Linux.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Android.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "iOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"iOS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PermissionKind": {
|
||||||
|
"type": "string",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "Enables the pending_share command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-pending-share",
|
||||||
|
"markdownDescription": "Enables the pending_share command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the pending_share command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-pending-share",
|
||||||
|
"markdownDescription": "Denies the pending_share command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "default",
|
||||||
|
"markdownDescription": "The share sheet: what another app sent to SoulStream (a playlist link, a\npasted tracklist), read once by the import.\n\n#### This default permission set includes:\n\n- `allow-pending-share`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//! The share sheet, on the phone.
|
||||||
|
//!
|
||||||
|
//! Another app's Share → SoulStream (a Spotify playlist, a YouTube set, a
|
||||||
|
//! pasted tracklist) starts the plugin's `ShareActivity`, which keeps the
|
||||||
|
//! text and brings the app to the front. The WebView asks for it with
|
||||||
|
//! `pending_share` → `{ text }` — at boot, and whenever the app comes back
|
||||||
|
//! to the front — and hands it to the playlist import. Read once: the
|
||||||
|
//! second ask answers nothing. On the desktop the plugin is registered so
|
||||||
|
//! the permission set resolves, and the command answers nothing.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Shared {
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
|
pub struct Share<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn pending_share<R: Runtime>(app: tauri::AppHandle<R>) -> Result<Shared, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let share = app.state::<Share<R>>();
|
||||||
|
share
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<Shared>("pendingShare", ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Ok(Shared::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("share")
|
||||||
|
.invoke_handler(tauri::generate_handler![pending_share])
|
||||||
|
.setup(|app, api| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let handle = api.register_android_plugin("com.soulstream.share", "SharePlugin")?;
|
||||||
|
app.manage(Share(handle));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let _ = (app, api);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "tauri-plugin-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SoulStream's storage on the phone: the folder picker (a Storage Access Framework tree, resolved to a path when the volume allows), and Android's all-files access — checked and requested — so the library can live outside the app's private folder"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
# required by tauri-plugin's build script; must equal the crate name
|
||||||
|
links = "tauri-plugin-storage"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-plugin = { version = "2", features = ["build"] }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/.tauri
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.soulstream.storage"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
// the plugin's own R8 keep rules, handed to whatever app links this
|
||||||
|
// module — inert unless that app minifies, which only the release
|
||||||
|
// build type does
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
// the picker's ActivityResult
|
||||||
|
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||||
|
implementation(project(":tauri-android"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Consumer R8/ProGuard rules for the storage plugin. Gradle hands a consumer
|
||||||
|
# file to whatever app links this module, and it is INERT unless that app
|
||||||
|
# minifies: src-tauri/gen/android/app/build.gradle.kts sets
|
||||||
|
# isMinifyEnabled = true for the `release` build type and false for `debug`,
|
||||||
|
# and the debug type names no proguard files at all. The deploy pipeline
|
||||||
|
# builds debug, so nothing in this file reaches it.
|
||||||
|
#
|
||||||
|
# Nothing below is called from Kotlin, which is the whole problem. Tauri
|
||||||
|
# reaches this plugin by STRING, at three layers:
|
||||||
|
#
|
||||||
|
# 1. register_android_plugin("com.soulstream.storage", "StoragePlugin") is
|
||||||
|
# turned into "com/soulstream/storage/StoragePlugin" and handed to
|
||||||
|
# FindClass; the instance is built through the JNI signature
|
||||||
|
# "(Landroid/app/Activity;)V" (tauri/src/plugin/mobile.rs).
|
||||||
|
# 2. PluginHandle.indexMethods() files every @Command under
|
||||||
|
# commands[method.name] and every @ActivityCallback under
|
||||||
|
# startActivityCallbackMethods[method.name]; invoke() looks up the name
|
||||||
|
# Rust asked for and throws "No command pickFolder found for plugin ..."
|
||||||
|
# when it is not there.
|
||||||
|
# 3. Jackson fills the @InvokeArg classes FIELD by field — PluginManager
|
||||||
|
# sets FIELD visibility to ANY — so field names weigh the same as
|
||||||
|
# method names.
|
||||||
|
#
|
||||||
|
# Six commands (pickFolder, allFilesAccess, requestAllFilesAccess, pickImage,
|
||||||
|
# pickFile, createDocument) and four activity callbacks (folderPicked,
|
||||||
|
# imagePicked, filePicked, documentCreated). createDocument is the easy one
|
||||||
|
# to miss by hand: it has no JS-facing command at all, it is called from Rust
|
||||||
|
# by playlist export.
|
||||||
|
#
|
||||||
|
# Tauri's own consumer rules already match all of this by annotation
|
||||||
|
# (-keep @TauriPlugin public class * { @Command public <methods>; ... }).
|
||||||
|
# These name it outright anyway: theirs are generic, ours are specific, the
|
||||||
|
# failure is silent and user-facing, and nobody has built a release APK of
|
||||||
|
# this app yet to find out which of us was needed. Narrow them once a release
|
||||||
|
# build has actually been smoke-tested command by command.
|
||||||
|
-keep class com.soulstream.storage.StoragePlugin { *; }
|
||||||
|
-keep class com.soulstream.storage.CreateDocumentArgs { *; }
|
||||||
|
-keep class com.soulstream.storage.PickFileArgs { *; }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- A library folder in shared storage (the phone's Music folder): the
|
||||||
|
Rust scanner and the download pipeline work on real paths, which
|
||||||
|
under scoped storage needs all-files access. Declared here; granted
|
||||||
|
only by the user, on the system screen `request_all_files_access`
|
||||||
|
opens. Nothing is read outside the folder the user picked. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<!-- reading media by path on Android 13+, and the older storage read on
|
||||||
|
Android 12 and below -->
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
</manifest>
|
||||||
+394
@@ -0,0 +1,394 @@
|
|||||||
|
package com.soulstream.storage
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import app.tauri.annotation.ActivityCallback
|
||||||
|
import app.tauri.annotation.Command
|
||||||
|
import app.tauri.annotation.InvokeArg
|
||||||
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import app.tauri.plugin.Plugin
|
||||||
|
|
||||||
|
/** What `createDocument` writes and where it offers to put it — the Rust
|
||||||
|
* `CreateDocumentArgs`, camelCase. */
|
||||||
|
@InvokeArg
|
||||||
|
class CreateDocumentArgs {
|
||||||
|
/** the name the sheet opens with, its extension included */
|
||||||
|
var name: String? = null
|
||||||
|
/** the type Android files it under, and picks the sheet's apps by */
|
||||||
|
var mime: String? = null
|
||||||
|
/** the whole file, built in Rust — the WebView never sees it */
|
||||||
|
var text: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a pick may offer and what it may copy. The filter is the document
|
||||||
|
* sheet's own, comma-separated ("audio/mpeg,audio/flac"); blank offers
|
||||||
|
* every type. `maxCopyBytes` is how big a document may be COPIED into the
|
||||||
|
* app's cache when it has no path of its own on this phone — 0 refuses the
|
||||||
|
* copy, which is what an audio-sized pick wants. Both are plain scalars on
|
||||||
|
* purpose: String / Int / Boolean is what the other plugins' args prove. */
|
||||||
|
@InvokeArg
|
||||||
|
class PickFileArgs {
|
||||||
|
var mimeTypes: String? = null
|
||||||
|
var maxCopyBytes: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The copy walked past what the caller allowed. Its own exception, so the
|
||||||
|
* partial file can be deleted and the caller told in megabytes rather than
|
||||||
|
* in "could not be read". */
|
||||||
|
private class TooBig(message: String) : Exception(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone's storage for the library: a folder picked through Android's
|
||||||
|
* document tree picker (its content URI kept with a persistable grant, and
|
||||||
|
* resolved to a real path when the tree is on a volume that has one), and
|
||||||
|
* all-files access — the permission that lets the Rust side read and write
|
||||||
|
* that path under scoped storage — checked, and requested on the system
|
||||||
|
* screen that grants it.
|
||||||
|
*/
|
||||||
|
@TauriPlugin
|
||||||
|
class StoragePlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun pickFolder(invoke: Invoke) {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||||
|
intent.addFlags(
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
startActivityForResult(invoke, intent, "folderPicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ActivityCallback
|
||||||
|
fun folderPicked(invoke: Invoke, result: ActivityResult) {
|
||||||
|
when (result.resultCode) {
|
||||||
|
Activity.RESULT_OK -> {
|
||||||
|
val uri = result.data?.data
|
||||||
|
if (uri == null) { invoke.reject("no folder came back"); return }
|
||||||
|
try {
|
||||||
|
activity.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { /* the grant is a courtesy; the path is what counts */ }
|
||||||
|
val out = JSObject()
|
||||||
|
out.put("uri", uri.toString())
|
||||||
|
out.put("path", pathOf(uri))
|
||||||
|
invoke.resolve(out)
|
||||||
|
}
|
||||||
|
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
|
||||||
|
else -> invoke.reject("the picker failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "primary:Music/Flac" → /storage/emulated/0/Music/Flac; an SD card's
|
||||||
|
* "1234-ABCD:Music" → /storage/1234-ABCD/Music; anything else (a cloud
|
||||||
|
* provider's tree) has no path. */
|
||||||
|
private fun pathOf(uri: Uri): String? {
|
||||||
|
val id = try { DocumentsContract.getTreeDocumentId(uri) } catch (_: Exception) { return null }
|
||||||
|
val i = id.indexOf(':')
|
||||||
|
if (i < 0) return null
|
||||||
|
val volume = id.substring(0, i)
|
||||||
|
val rel = id.substring(i + 1).trimEnd('/')
|
||||||
|
if (uri.authority != "com.android.externalstorage.documents") return null
|
||||||
|
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
|
||||||
|
return if (rel.isEmpty()) root else "$root/$rel"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun allFilesAccess(invoke: Invoke) {
|
||||||
|
val out = JSObject()
|
||||||
|
out.put("granted", Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager())
|
||||||
|
invoke.resolve(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun requestAllFilesAccess(invoke: Invoke) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val intent = Intent(
|
||||||
|
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
Uri.parse("package:" + activity.packageName)
|
||||||
|
)
|
||||||
|
activity.startActivity(intent)
|
||||||
|
}
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A file written wherever the user points it: Android's create-document
|
||||||
|
* sheet, and the bytes through the ContentResolver — a content URI is
|
||||||
|
* not a path, so `std::fs` on the Rust side could never have written
|
||||||
|
* this one. The file arrives whole (the playlist export builds it with
|
||||||
|
* the desktop's own writers), so nothing here knows a format. */
|
||||||
|
@Command
|
||||||
|
fun createDocument(invoke: Invoke) {
|
||||||
|
val args = invoke.parseArgs(CreateDocumentArgs::class.java)
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
intent.type = args.mime ?: "application/octet-stream"
|
||||||
|
intent.putExtra(Intent.EXTRA_TITLE, args.name ?: "playlist")
|
||||||
|
startActivityForResult(invoke, intent, "documentCreated")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ActivityCallback
|
||||||
|
fun documentCreated(invoke: Invoke, result: ActivityResult) {
|
||||||
|
when (result.resultCode) {
|
||||||
|
Activity.RESULT_OK -> {
|
||||||
|
val uri = result.data?.data
|
||||||
|
if (uri == null) { invoke.reject("no file came back"); return }
|
||||||
|
try {
|
||||||
|
/* The file comes off the SAME invoke, read a second time
|
||||||
|
rather than parked in a field of ours: PluginHandle's
|
||||||
|
activity-result closure holds the Invoke, so its
|
||||||
|
arguments are still here when the sheet comes back, and
|
||||||
|
no call of this can hand its bytes to another's file. */
|
||||||
|
val text = invoke.parseArgs(CreateDocumentArgs::class.java).text ?: ""
|
||||||
|
/* "wt" truncates: without it, writing a short file over a
|
||||||
|
longer one leaves the old tail behind the new bytes. */
|
||||||
|
val out = activity.contentResolver.openOutputStream(uri, "wt")
|
||||||
|
if (out == null) { invoke.reject("the file could not be opened for writing"); return }
|
||||||
|
out.use { o -> o.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
val res = JSObject()
|
||||||
|
res.put("uri", uri.toString())
|
||||||
|
res.put("name", displayName(uri))
|
||||||
|
invoke.resolve(res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
invoke.reject("the file could not be written: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Deliberately NOT a rejection, where pickFolder and pickImage
|
||||||
|
both reject "cancelled": a dismissed save is `Ok(None)` on the
|
||||||
|
desktop and the export says nothing at all for it. An error
|
||||||
|
here would paint a failure over a choice. */
|
||||||
|
Activity.RESULT_CANCELED -> invoke.resolve(JSObject())
|
||||||
|
else -> invoke.reject("the file could not be created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name the document actually got — the sheet lets it be changed on
|
||||||
|
* the way past. The URI's last segment when the provider will not say. */
|
||||||
|
private fun displayName(uri: Uri): String? {
|
||||||
|
try {
|
||||||
|
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
|
||||||
|
val i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (i >= 0 && c.moveToFirst()) {
|
||||||
|
val n = c.getString(i)
|
||||||
|
if (!n.isNullOrEmpty()) return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { /* the name is a courtesy; the bytes are written */ }
|
||||||
|
return uri.lastPathSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A photo from the phone: the document picker limited to images, the
|
||||||
|
* picked content copied into the app's cache so the Rust side gets a
|
||||||
|
* plain path (a content URI is not one). */
|
||||||
|
@Command
|
||||||
|
fun pickImage(invoke: Invoke) {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
intent.type = "image/*"
|
||||||
|
startActivityForResult(invoke, intent, "imagePicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ActivityCallback
|
||||||
|
fun imagePicked(invoke: Invoke, result: ActivityResult) {
|
||||||
|
when (result.resultCode) {
|
||||||
|
Activity.RESULT_OK -> {
|
||||||
|
val uri = result.data?.data
|
||||||
|
if (uri == null) { invoke.reject("no image came back"); return }
|
||||||
|
try {
|
||||||
|
val target = java.io.File(activity.cacheDir, "picked-cover-" + System.currentTimeMillis() + ".img")
|
||||||
|
val input = activity.contentResolver.openInputStream(uri)
|
||||||
|
if (input == null) { invoke.reject("the image could not be read"); return }
|
||||||
|
input.use { i -> target.outputStream().use { o -> i.copyTo(o) } }
|
||||||
|
val out = JSObject()
|
||||||
|
out.put("path", target.absolutePath)
|
||||||
|
invoke.resolve(out)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
invoke.reject("the image could not be read: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
|
||||||
|
else -> invoke.reject("the picker failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A FILE from the phone: the document picker with the caller's own
|
||||||
|
* filter. A document that is already on this phone's storage is handed
|
||||||
|
* over as it lies — nothing is copied, so a 300 MB pick costs nothing
|
||||||
|
* — and one that has no path of its own is copied into cacheDir/picked
|
||||||
|
* under its own display name, as far as `maxCopyBytes` allows and no
|
||||||
|
* further. Zero refuses the copy outright.
|
||||||
|
*
|
||||||
|
* The copies are swept at the start of every pick: pickImage's own
|
||||||
|
* copies live in the cache for ever, and an audio file is not an
|
||||||
|
* image. So a path handed out by one pick is gone by the next — read
|
||||||
|
* it in the same breath.
|
||||||
|
*/
|
||||||
|
@Command
|
||||||
|
fun pickFile(invoke: Invoke) {
|
||||||
|
val args = invoke.parseArgs(PickFileArgs::class.java)
|
||||||
|
sweepPicked()
|
||||||
|
val types = (args.mimeTypes ?: "").split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
if (types.size == 1) {
|
||||||
|
intent.type = types[0]
|
||||||
|
} else {
|
||||||
|
intent.type = "*/*"
|
||||||
|
if (types.isNotEmpty()) intent.putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
||||||
|
}
|
||||||
|
startActivityForResult(invoke, intent, "filePicked")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ActivityCallback
|
||||||
|
fun filePicked(invoke: Invoke, result: ActivityResult) {
|
||||||
|
when (result.resultCode) {
|
||||||
|
Activity.RESULT_OK -> {
|
||||||
|
val uri = result.data?.data
|
||||||
|
if (uri == null) { invoke.reject("no file came back"); return }
|
||||||
|
val info = openableInfo(uri)
|
||||||
|
val name = info.first ?: "picked"
|
||||||
|
val stated = info.second
|
||||||
|
val out = JSObject()
|
||||||
|
out.put("name", name)
|
||||||
|
/* the document's own path first, and nothing copied: a row
|
||||||
|
naming a file beside it still resolves against the real
|
||||||
|
folder, and the size stops mattering */
|
||||||
|
val here = documentPath(uri)
|
||||||
|
if (here != null) {
|
||||||
|
out.put("path", here)
|
||||||
|
out.put("copied", false)
|
||||||
|
if (stated != null) out.put("size", stated)
|
||||||
|
invoke.resolve(out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* the same Invoke the pick was made with — PluginHandle
|
||||||
|
holds it in the result closure — so the cap is read back
|
||||||
|
off the caller's own arguments rather than stashed in a
|
||||||
|
field a recreated activity would have reset to zero */
|
||||||
|
val cap = invoke.parseArgs(PickFileArgs::class.java).maxCopyBytes.toLong()
|
||||||
|
if (cap <= 0L) { invoke.reject("that file is not on this phone's storage"); return }
|
||||||
|
if (stated != null && stated > cap) {
|
||||||
|
invoke.reject("that file is " + (stated / (1024 * 1024)) + " MB — too large to copy here")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val copy = copyInto(uri, name, cap)
|
||||||
|
if (copy == null) { invoke.reject("that file could not be read"); return }
|
||||||
|
out.put("path", copy.absolutePath)
|
||||||
|
out.put("copied", true)
|
||||||
|
out.put("size", copy.length())
|
||||||
|
invoke.resolve(out)
|
||||||
|
} catch (e: TooBig) {
|
||||||
|
invoke.reject(e.message ?: "that file is too large to copy here")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
invoke.reject("that file could not be read: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Activity.RESULT_CANCELED -> invoke.reject("cancelled")
|
||||||
|
else -> invoke.reject("the picker failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DISPLAY_NAME and SIZE off the provider. The name is load-bearing:
|
||||||
|
* the Rust parser picks the format off the extension and titles the
|
||||||
|
* draft from the stem, so a copy under an invented name would read
|
||||||
|
* every playlist as plain text. SIZE is often absent, and absent is
|
||||||
|
* not zero — the copy counts for itself. */
|
||||||
|
private fun openableInfo(uri: Uri): Pair<String?, Long?> {
|
||||||
|
var name: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
try {
|
||||||
|
activity.contentResolver.query(uri, null, null, null, null)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
val ni = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (ni >= 0 && !c.isNull(ni)) name = c.getString(ni)
|
||||||
|
val si = c.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (si >= 0 && !c.isNull(si)) size = c.getLong(si)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { /* a provider that answers nothing still has a document */ }
|
||||||
|
return Pair(name, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The document's own path, when this phone has one for it: the
|
||||||
|
* Downloads provider's "raw:/storage/…", and the external-storage
|
||||||
|
* provider's "primary:Music/set.m3u" — the DOCUMENT sibling of the
|
||||||
|
* tree id pathOf resolves. Null when it names no path, and null when
|
||||||
|
* this app may not read the one it names (all-files access is not
|
||||||
|
* always granted, and a path Rust cannot open is worse than none). */
|
||||||
|
private fun documentPath(uri: Uri): String? {
|
||||||
|
val id = try { DocumentsContract.getDocumentId(uri) } catch (_: Exception) { return null }
|
||||||
|
val path = if (id.startsWith("raw:")) {
|
||||||
|
id.substring(4)
|
||||||
|
} else if (uri.authority == "com.android.externalstorage.documents") {
|
||||||
|
val i = id.indexOf(':')
|
||||||
|
if (i < 0) return null
|
||||||
|
val volume = id.substring(0, i)
|
||||||
|
val rel = id.substring(i + 1).trimEnd('/')
|
||||||
|
val root = if (volume == "primary") Environment.getExternalStorageDirectory().absolutePath else "/storage/$volume"
|
||||||
|
if (rel.isEmpty()) root else "$root/$rel"
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (java.io.File(path).canRead()) path else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pickedDir(): java.io.File {
|
||||||
|
val dir = java.io.File(activity.cacheDir, "picked")
|
||||||
|
if (!dir.isDirectory) dir.mkdirs()
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last pick's copy, gone. A playlist is kilobytes and a DJ set is
|
||||||
|
* not, and nothing here is worth keeping once the caller has read it. */
|
||||||
|
private fun sweepPicked() {
|
||||||
|
try { pickedDir().listFiles()?.forEach { it.delete() } } catch (_: Exception) { /* the copy is a courtesy */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picked document, copied under its own name so the extension
|
||||||
|
* survives, counted as it goes so a provider that understated its size
|
||||||
|
* cannot fill the cache; a partial copy is deleted. */
|
||||||
|
private fun copyInto(uri: Uri, name: String, cap: Long): java.io.File? {
|
||||||
|
val target = java.io.File(pickedDir(), safeName(name))
|
||||||
|
val input = activity.contentResolver.openInputStream(uri) ?: return null
|
||||||
|
var total = 0L
|
||||||
|
try {
|
||||||
|
input.use { i ->
|
||||||
|
target.outputStream().use { o ->
|
||||||
|
val buf = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = i.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
total += n.toLong()
|
||||||
|
if (total > cap) throw TooBig("that file is larger than " + (cap / (1024 * 1024)) + " MB")
|
||||||
|
o.write(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
target.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A name the cache can hold: the document's own, minus anything that
|
||||||
|
* could walk out of the folder. What the CALLER parses by is the
|
||||||
|
* display name, which travels beside the path untouched. */
|
||||||
|
private fun safeName(name: String): String {
|
||||||
|
val base = name.substringAfterLast('/').substringAfterLast('\\').trim()
|
||||||
|
val clean = base.replace(Regex("[^A-Za-z0-9 ._()\\[\\]+&,'-]"), "_")
|
||||||
|
return if (clean.isEmpty() || clean == "." || clean == "..") "picked" else clean.take(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// The commands, as the Rust functions are named; the ACL slugs them
|
||||||
|
// (`allow-pick-folder`, …) into permissions/autogenerated.
|
||||||
|
const COMMANDS: &[&str] = &["pick_folder", "all_files_access", "request_all_files_access", "pick_image", "pick_file"];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Registers the command permissions and, on Android, hands the Gradle
|
||||||
|
// module under android/ to tauri-build, which includes it in the app
|
||||||
|
// project (tauri.settings.gradle) — only with TAURI_ANDROID_PROJECT_PATH
|
||||||
|
// set, see ANDROID.md.
|
||||||
|
tauri_plugin::Builder::new(COMMANDS)
|
||||||
|
.android_path("android")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-all-files-access"
|
||||||
|
description = "Enables the all_files_access command without any pre-configured scope."
|
||||||
|
commands.allow = ["all_files_access"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-all-files-access"
|
||||||
|
description = "Denies the all_files_access command without any pre-configured scope."
|
||||||
|
commands.deny = ["all_files_access"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-pick-file"
|
||||||
|
description = "Enables the pick_file command without any pre-configured scope."
|
||||||
|
commands.allow = ["pick_file"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-pick-file"
|
||||||
|
description = "Denies the pick_file command without any pre-configured scope."
|
||||||
|
commands.deny = ["pick_file"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-pick-folder"
|
||||||
|
description = "Enables the pick_folder command without any pre-configured scope."
|
||||||
|
commands.allow = ["pick_folder"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-pick-folder"
|
||||||
|
description = "Denies the pick_folder command without any pre-configured scope."
|
||||||
|
commands.deny = ["pick_folder"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-pick-image"
|
||||||
|
description = "Enables the pick_image command without any pre-configured scope."
|
||||||
|
commands.allow = ["pick_image"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-pick-image"
|
||||||
|
description = "Denies the pick_image command without any pre-configured scope."
|
||||||
|
commands.deny = ["pick_image"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-request-all-files-access"
|
||||||
|
description = "Enables the request_all_files_access command without any pre-configured scope."
|
||||||
|
commands.allow = ["request_all_files_access"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-request-all-files-access"
|
||||||
|
description = "Denies the request_all_files_access command without any pre-configured scope."
|
||||||
|
commands.deny = ["request_all_files_access"]
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
## Default Permission
|
||||||
|
|
||||||
|
The phone's storage: pick a library folder (a Storage Access Framework tree,
|
||||||
|
resolved to a path when the volume allows), pick a photo or a file (the
|
||||||
|
document picker, answered with a path — the document's own where this phone
|
||||||
|
has one, else a bounded copy in the app's cache), and Android's all-files
|
||||||
|
access — whether it is granted, and the system screen that grants it.
|
||||||
|
|
||||||
|
#### This default permission set includes the following:
|
||||||
|
|
||||||
|
- `allow-pick-folder`
|
||||||
|
- `allow-all-files-access`
|
||||||
|
- `allow-request-all-files-access`
|
||||||
|
- `allow-pick-image`
|
||||||
|
- `allow-pick-file`
|
||||||
|
|
||||||
|
## Permission Table
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:allow-all-files-access`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the all_files_access command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:deny-all-files-access`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the all_files_access command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:allow-pick-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the pick_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:deny-pick-file`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the pick_file command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:allow-pick-folder`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the pick_folder command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:deny-pick-folder`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the pick_folder command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:allow-pick-image`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the pick_image command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:deny-pick-image`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the pick_image command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:allow-request-all-files-access`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the request_all_files_access command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`storage:deny-request-all-files-access`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the request_all_files_access command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"$schema" = "schemas/schema.json"
|
||||||
|
|
||||||
|
[default]
|
||||||
|
description = """
|
||||||
|
The phone's storage: pick a library folder (a Storage Access Framework tree,
|
||||||
|
resolved to a path when the volume allows), pick a photo or a file (the
|
||||||
|
document picker, answered with a path — the document's own where this phone
|
||||||
|
has one, else a bounded copy in the app's cache), and Android's all-files
|
||||||
|
access — whether it is granted, and the system screen that grants it.
|
||||||
|
"""
|
||||||
|
permissions = ["allow-pick-folder", "allow-all-files-access", "allow-request-all-files-access", "allow-pick-image", "allow-pick-file"]
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "PermissionFile",
|
||||||
|
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"default": {
|
||||||
|
"description": "The default permission set for the plugin",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/DefaultPermission"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"description": "A list of permissions sets defined",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionSet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"description": "A list of inlined permissions",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Permission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"DefaultPermission": {
|
||||||
|
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionSet": {
|
||||||
|
"description": "A set of direct permissions grouped together under a new name.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"identifier",
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionKind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Permission": {
|
||||||
|
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"description": "Allowed or denied commands when using this permission.",
|
||||||
|
"default": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Commands"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "Allowed or denied scoped when using this permission.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Scopes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Commands": {
|
||||||
|
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Allowed command.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Denied command, which takes priority.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scopes": {
|
||||||
|
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Data that defines what is allowed by the scope.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Value": {
|
||||||
|
"description": "All supported ACL values.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents a null JSON value.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`bool`].",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a valid ACL [`Number`].",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`String`].",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a list of other [`Value`]s.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Number": {
|
||||||
|
"description": "A valid ACL number.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents an [`i64`].",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`f64`].",
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Target": {
|
||||||
|
"description": "Platform target.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "MacOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"macOS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Windows.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Linux.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Android.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "iOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"iOS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PermissionKind": {
|
||||||
|
"type": "string",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "Enables the all_files_access command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-all-files-access",
|
||||||
|
"markdownDescription": "Enables the all_files_access command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the all_files_access command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-all-files-access",
|
||||||
|
"markdownDescription": "Denies the all_files_access command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the pick_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-pick-file",
|
||||||
|
"markdownDescription": "Enables the pick_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the pick_file command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-pick-file",
|
||||||
|
"markdownDescription": "Denies the pick_file command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the pick_folder command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-pick-folder",
|
||||||
|
"markdownDescription": "Enables the pick_folder command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the pick_folder command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-pick-folder",
|
||||||
|
"markdownDescription": "Denies the pick_folder command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the pick_image command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-pick-image",
|
||||||
|
"markdownDescription": "Enables the pick_image command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the pick_image command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-pick-image",
|
||||||
|
"markdownDescription": "Denies the pick_image command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the request_all_files_access command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-request-all-files-access",
|
||||||
|
"markdownDescription": "Enables the request_all_files_access command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the request_all_files_access command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-request-all-files-access",
|
||||||
|
"markdownDescription": "Denies the request_all_files_access command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "default",
|
||||||
|
"markdownDescription": "The phone's storage: pick a library folder (a Storage Access Framework tree,\nresolved to a path when the volume allows), pick a photo or a file (the\ndocument picker, answered with a path — the document's own where this phone\nhas one, else a bounded copy in the app's cache), and Android's all-files\naccess — whether it is granted, and the system screen that grants it.\n\n#### This default permission set includes:\n\n- `allow-pick-folder`\n- `allow-all-files-access`\n- `allow-request-all-files-access`\n- `allow-pick-image`\n- `allow-pick-file`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Storage on the phone.
|
||||||
|
//!
|
||||||
|
//! The library on Android lives in the app's private folder unless the user
|
||||||
|
//! points it somewhere else. Pointing it somewhere else needs two things the
|
||||||
|
//! WebView cannot do: a folder picker (Android's document tree picker, whose
|
||||||
|
//! answer is a content URI — resolved here to a real path when the volume
|
||||||
|
//! allows, which the Rust scanner and the download pipeline work on), and
|
||||||
|
//! for a folder in shared storage, Android's all-files access, which only
|
||||||
|
//! the user can grant on a system screen.
|
||||||
|
//!
|
||||||
|
//! Five commands: `pick_folder` → `{ uri, path }` (path null when the tree
|
||||||
|
//! is not on a volume with a path), `all_files_access` → `{ granted }`,
|
||||||
|
//! `request_all_files_access`, which opens the system screen, `pick_image`
|
||||||
|
//! → `{ path }` (a photo, copied into the app's cache), and `pick_file` →
|
||||||
|
//! `{ path, name, size, copied }` — the document picker with the caller's
|
||||||
|
//! own MIME filter.
|
||||||
|
//!
|
||||||
|
//! `pick_file` copies as little as it can, because an image is small and an
|
||||||
|
//! audio file is not. A document already on this phone's storage hands its
|
||||||
|
//! own path over as it lies, so picking a 300 MB file costs nothing; one
|
||||||
|
//! with no path of its own is copied into `cacheDir/picked` under its own
|
||||||
|
//! display name, and only as far as `maxCopyBytes` allows — zero refuses
|
||||||
|
//! the copy outright. Those copies are swept at the start of every pick, so
|
||||||
|
//! a path from one pick is gone by the next: read it in the same breath.
|
||||||
|
//! The display name travels beside the path because it is load-bearing —
|
||||||
|
//! the playlist parser picks its format off the extension and titles the
|
||||||
|
//! draft from the stem.
|
||||||
|
//!
|
||||||
|
//! On the desktop the plugin is registered so the permission set resolves,
|
||||||
|
//! and every command answers "not here".
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PickedFolder {
|
||||||
|
pub uri: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AccessState {
|
||||||
|
pub granted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a pick offers and what it may copy. `mime_types` is the document
|
||||||
|
/// sheet's own filter, comma-separated; empty offers every type.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PickFileArgs {
|
||||||
|
pub mime_types: Option<String>,
|
||||||
|
/// How big a document may be COPIED into the app's cache when it has no
|
||||||
|
/// path of its own on this phone. Zero refuses the copy.
|
||||||
|
pub max_copy_bytes: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A picked document. `path` is the document's own when this phone has one
|
||||||
|
/// for it, else the cache copy's (`copied`); `name` is the display name the
|
||||||
|
/// provider gave it, which is what decides a playlist's format and title.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PickedFile {
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub size: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub copied: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PickedImage {
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
|
pub struct Storage<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_folder<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedFolder, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<PickedFolder>("pickFolder", ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Err("the folder picker here is the desktop's dialog".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<AccessState, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<AccessState>("allFilesAccess", ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Ok(AccessState { granted: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn request_all_files_access<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("requestAllFilesAccess", ())
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A FILE from the phone: the document picker with the caller's own MIME
|
||||||
|
/// filter, answered with a path the Rust side can open. A document already
|
||||||
|
/// on this phone's storage is handed over as it lies; one that has no path
|
||||||
|
/// is copied into the app's cache under its own name, and only as far as
|
||||||
|
/// `max_copy_bytes` allows — zero refuses the copy, which is what a pick
|
||||||
|
/// that could be hundreds of megabytes wants.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_file<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
mime_types: Option<String>,
|
||||||
|
max_copy_bytes: Option<u32>,
|
||||||
|
) -> Result<PickedFile, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<PickedFile>(
|
||||||
|
"pickFile",
|
||||||
|
PickFileArgs {
|
||||||
|
mime_types,
|
||||||
|
max_copy_bytes: max_copy_bytes.unwrap_or(0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = (app, mime_types, max_copy_bytes);
|
||||||
|
Err("the file picker here is the desktop's dialog".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A photo from the phone: the document picker limited to images, the
|
||||||
|
/// picked content copied into the app's cache — a plain path for the Rust
|
||||||
|
/// side (a playlist's cover, for one).
|
||||||
|
#[tauri::command]
|
||||||
|
async fn pick_image<R: Runtime>(app: tauri::AppHandle<R>) -> Result<PickedImage, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<PickedImage>("pickImage", ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Err("the image picker here is the desktop's dialog".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Android's create-document sheet answered: where the file went, and
|
||||||
|
/// the name it actually got (the sheet lets the user change it on the way
|
||||||
|
/// past). Both absent when the sheet was dismissed — a cancel is not an
|
||||||
|
/// error here, because the export it serves answers a cancel with None.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreatedDocument {
|
||||||
|
pub uri: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `createDocument` is handed, as the Kotlin `@InvokeArg` names it.
|
||||||
|
/// Borrowed: the file can be long and nothing here keeps a copy.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[allow(dead_code)] // only the phone ever sends one; the desktop has a dialog
|
||||||
|
struct CreateDocumentArgs<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
mime: &'a str,
|
||||||
|
text: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A text file written wherever the user points: Android's create-document
|
||||||
|
/// sheet (ACTION_CREATE_DOCUMENT), and the Kotlin side writing the bytes
|
||||||
|
/// through the ContentResolver. That last part is the whole reason this
|
||||||
|
/// exists — the sheet answers with a `content://` URI, and `std::fs` has
|
||||||
|
/// never been able to open one, which is why the playlist export had no
|
||||||
|
/// door on the phone at all.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT a `#[tauri::command]`: the caller is Rust, so the file
|
||||||
|
/// is built where the desktop builds it and the bytes never pass through
|
||||||
|
/// the WebView. Nothing is added to the ACL, and there is no JS wrapper to
|
||||||
|
/// keep in step with.
|
||||||
|
///
|
||||||
|
/// A dismissed sheet is not an error — it answers with both fields empty.
|
||||||
|
pub fn create_document<R: Runtime>(
|
||||||
|
app: &tauri::AppHandle<R>,
|
||||||
|
name: &str,
|
||||||
|
mime: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> Result<CreatedDocument, String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let storage = app.state::<Storage<R>>();
|
||||||
|
storage
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<CreatedDocument>(
|
||||||
|
"createDocument",
|
||||||
|
CreateDocumentArgs { name, mime, text },
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = (app, name, mime, text);
|
||||||
|
Err("the create-document sheet is the phone's; here a save dialog picks the path".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("storage")
|
||||||
|
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access, pick_image, pick_file])
|
||||||
|
.setup(|app, api| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let handle = api.register_android_plugin("com.soulstream.storage", "StoragePlugin")?;
|
||||||
|
app.manage(Storage(handle));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let _ = (app, api);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "tauri-plugin-transfer-notice"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "SoulStream's downloads in the phone's shade: a progress notification on its own low-importance channel, carried by its own dataSync foreground service, so a Save outlives the app going to the background"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
# required by tauri-plugin's build script; must equal the crate name
|
||||||
|
links = "tauri-plugin-transfer-notice"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-plugin = { version = "2", features = ["build"] }
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build
|
||||||
|
/.tauri
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.soulstream.transfernotice"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
// the plugin's own R8 keep rules, handed to whatever app links this
|
||||||
|
// module — inert unless that app minifies, which only the release
|
||||||
|
// build type does
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// NotificationCompat, ServiceCompat, ContextCompat — no media here,
|
||||||
|
// there is no session in this one
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation(project(":tauri-android"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Consumer R8/ProGuard rules for the transfer-notice plugin. Inert unless the
|
||||||
|
# consuming app minifies, and only the `release` build type does, so the
|
||||||
|
# debug APK the deploy pipeline builds is untouched by this file.
|
||||||
|
#
|
||||||
|
# TransferNoticePlugin is found by the string
|
||||||
|
# "com/soulstream/transfernotice/TransferNoticePlugin"
|
||||||
|
# (register_android_plugin → FindClass), constructed through
|
||||||
|
# "(Landroid/app/Activity;)V", and its two @Command methods — update and
|
||||||
|
# clear — are dispatched by NAME through PluginHandle's commands map.
|
||||||
|
# NoticeArgs is filled by Jackson field by field. Rename any of it and a Save
|
||||||
|
# runs to completion with no notice in the shade at all.
|
||||||
|
#
|
||||||
|
# The notice's own Cancel and Open come back as the plugin event "action",
|
||||||
|
# which the WebView subscribes to through Plugin.registerListener — declared
|
||||||
|
# on tauri's base class, which no rule of tauri's own names. See
|
||||||
|
# src-tauri/android/proguard-soulstream.pro.
|
||||||
|
#
|
||||||
|
# TransferService is declared in this module's AndroidManifest.xml, so AGP's
|
||||||
|
# own merged-manifest keep rules hold it by name. Not repeated here: one
|
||||||
|
# place decides it.
|
||||||
|
-keep class com.soulstream.transfernotice.TransferNoticePlugin { *; }
|
||||||
|
-keep class com.soulstream.transfernotice.NoticeArgs { *; }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- the foreground service that carries the downloads while the app is
|
||||||
|
in the background; a fetch is dataSync, not playback -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<!-- the notice is a notification; asked for at runtime on Android 13+ -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- no intent filter: the notice's own buttons come back as
|
||||||
|
onStartCommand intents on this very service -->
|
||||||
|
<service
|
||||||
|
android:name="com.soulstream.transfernotice.TransferService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package com.soulstream.transfernotice
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import app.tauri.annotation.Command
|
||||||
|
import app.tauri.annotation.InvokeArg
|
||||||
|
import app.tauri.annotation.TauriPlugin
|
||||||
|
import app.tauri.plugin.Invoke
|
||||||
|
import app.tauri.plugin.JSObject
|
||||||
|
import app.tauri.plugin.Plugin
|
||||||
|
|
||||||
|
/** What the notice says — the Rust `TransferNotice`, camelCase. */
|
||||||
|
@InvokeArg
|
||||||
|
class NoticeArgs {
|
||||||
|
/** how many files are still in flight; zero means take the notice down */
|
||||||
|
var count: Int = 0
|
||||||
|
var title: String? = null
|
||||||
|
var line: String? = null
|
||||||
|
var percent: Int = 0
|
||||||
|
/** nobody has stated a size yet, so the bar has no end to walk towards */
|
||||||
|
var indeterminate: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bridge between the WebView and the service that owns the notice.
|
||||||
|
* `update` mirrors what Save is fetching (and starts the service the first
|
||||||
|
* time); `clear` takes the notice down. Its buttons come back as the plugin
|
||||||
|
* event `action` with `{ action }` — "cancel" or "open".
|
||||||
|
*/
|
||||||
|
@TauriPlugin
|
||||||
|
class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) {
|
||||||
|
private var askedForNotifications = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
TransferHub.onAction = { action ->
|
||||||
|
val payload = JSObject()
|
||||||
|
payload.put("action", action)
|
||||||
|
trigger("action", payload)
|
||||||
|
}
|
||||||
|
/* the channel exists from app start, so a user can silence the
|
||||||
|
downloads on their own before the first one ever runs */
|
||||||
|
TransferHub.ensureChannel(activity.applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun update(invoke: Invoke) {
|
||||||
|
val args = invoke.parseArgs(NoticeArgs::class.java)
|
||||||
|
ensureNotificationPermission()
|
||||||
|
TransferHub.update(activity.applicationContext, args)
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command
|
||||||
|
fun clear(invoke: Invoke) {
|
||||||
|
TransferHub.clear(activity.applicationContext)
|
||||||
|
invoke.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android 13+: the notice is a notification and needs the permission;
|
||||||
|
* asked once, the first time something is fetched. Its own request code
|
||||||
|
* — 4711 is the media card's, and two requests that share one would
|
||||||
|
* answer each other. The service runs either way; without the
|
||||||
|
* permission it simply has nothing to show. */
|
||||||
|
private fun ensureNotificationPermission() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
|
||||||
|
askedForNotifications = true
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) return
|
||||||
|
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712)
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
package com.soulstream.transfernotice
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is being fetched, shared between the plugin (which hears the
|
||||||
|
* WebView) and the service (which owns the notice). The service is started
|
||||||
|
* on the first update and refreshed on every one after; a count of zero is
|
||||||
|
* a clear, since the phone's own rule is that an empty queue has no notice.
|
||||||
|
*/
|
||||||
|
object TransferHub {
|
||||||
|
const val CHANNEL = "downloads"
|
||||||
|
|
||||||
|
/** 1 is the media card's, and ids are per-package */
|
||||||
|
const val NOTIFICATION_ID = 2
|
||||||
|
|
||||||
|
const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL"
|
||||||
|
const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN"
|
||||||
|
|
||||||
|
@Volatile var onAction: ((String) -> Unit)? = null
|
||||||
|
@Volatile var state: NoticeArgs? = null
|
||||||
|
@Volatile var service: TransferService? = null
|
||||||
|
|
||||||
|
/** Its own channel, low importance and no badge: a download is not an
|
||||||
|
* alarm, and silencing it must not silence the player's card. A
|
||||||
|
* filename is not for a locked screen, so the notice is private there. */
|
||||||
|
fun ensureChannel(ctx: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT < 26) return
|
||||||
|
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL) != null) return
|
||||||
|
val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
channel.description = "What Save is fetching into your library"
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(ctx: Context, np: NoticeArgs) {
|
||||||
|
if (np.count <= 0) {
|
||||||
|
clear(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = np
|
||||||
|
val running = service
|
||||||
|
if (running == null) {
|
||||||
|
/* Android 12+ refuses a foreground start from the background. A
|
||||||
|
Save is always begun from the foreground, so the first start
|
||||||
|
lands; one that cannot simply leaves the fetch without a card */
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SoulStream", "transfer notice could not start: $e")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
running.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(ctx: Context) {
|
||||||
|
state = null
|
||||||
|
val running = service
|
||||||
|
/* refresh() on a null state is the one path that takes the notice
|
||||||
|
down and stops the service; stopService is for a service that was
|
||||||
|
never here */
|
||||||
|
if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun act(action: String) {
|
||||||
|
onAction?.invoke(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The foreground service behind the notice: a progress notification with
|
||||||
|
* Stop all and Open on it, and — the point — the reason the process is not
|
||||||
|
* frozen while a Save runs with nothing playing. The fetches themselves
|
||||||
|
* stay where they are, on the Rust core's own connections; this only says
|
||||||
|
* what they are doing.
|
||||||
|
*/
|
||||||
|
class TransferService : Service() {
|
||||||
|
private var inForeground = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
TransferHub.ensureChannel(this)
|
||||||
|
TransferHub.service = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
/* Started with startForegroundService, so Android demands
|
||||||
|
startForeground() promptly WHATEVER the state — a queue that
|
||||||
|
emptied while the start was in flight included — or it kills the
|
||||||
|
whole app about ten seconds later. MediaService learned this the
|
||||||
|
same way, on 2026-09-13, and it was a Save that died. Promote
|
||||||
|
first, with the notice there is or a plain one; refresh() steps
|
||||||
|
back at once if there is nothing left to say. */
|
||||||
|
if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs()))
|
||||||
|
when (intent?.action) {
|
||||||
|
TransferHub.ACTION_CANCEL -> TransferHub.act("cancel")
|
||||||
|
TransferHub.ACTION_OPEN -> {
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||||
|
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ }
|
||||||
|
}
|
||||||
|
TransferHub.act("open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirror TransferHub.state into the notice — or take it down. */
|
||||||
|
fun refresh() {
|
||||||
|
val np = TransferHub.state
|
||||||
|
if (np == null) {
|
||||||
|
/* the queue emptied: the notice goes, whole */
|
||||||
|
if (inForeground) {
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
inForeground = false
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* promote() on an already-promoted service is just a notify */
|
||||||
|
promote(build(np))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promote(notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this, TransferHub.NOTIFICATION_ID, notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(TransferHub.NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
inForeground = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android 15 caps a dataSync service at six hours a day and kills the
|
||||||
|
* app if it is still up when this returns. Take the notice down: the
|
||||||
|
* fetches are the Rust core's own and Transfers still lists them. */
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
TransferHub.clear(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
TransferHub.service = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(np: NoticeArgs): Notification {
|
||||||
|
val open = serviceIntent(11, TransferHub.ACTION_OPEN)
|
||||||
|
val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL)
|
||||||
|
return NotificationCompat.Builder(this, TransferHub.CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_soulstream_down)
|
||||||
|
.setContentTitle(np.title ?: "Saving")
|
||||||
|
.setContentText(np.line ?: "")
|
||||||
|
.setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setShowWhen(false)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel)
|
||||||
|
.addAction(android.R.drawable.ic_menu_upload, "Open", open)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The notice only exists while this service is in the foreground, so
|
||||||
|
* its own buttons are never a background service start. The request
|
||||||
|
* codes are 11 and 12 on purpose — the media plugin uses 0 in the same
|
||||||
|
* package, and two PendingIntents that filterEqual overwrite each other. */
|
||||||
|
private fun serviceIntent(code: Int, action: String): PendingIntent {
|
||||||
|
val i = Intent(this, TransferService::class.java).setAction(action)
|
||||||
|
return PendingIntent.getForegroundService(
|
||||||
|
this, code, i,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- the status-bar glyph: the falcon diving into a tray, white on transparent,
|
||||||
|
as the shade wants. Its own copy of the path on purpose —
|
||||||
|
R.drawable.ic_soulstream_note belongs to the media-session module's R
|
||||||
|
class and is not visible here — and deliberately the SAME falcon, so the
|
||||||
|
two notices read as one app.
|
||||||
|
|
||||||
|
Same kit source and same fold as the playback glyph
|
||||||
|
(branding/soulstream-logo-kit/android/adaptive/ic_launcher_monochrome.svg),
|
||||||
|
sized to clear the tray: the falcon lands x 7.04 to 16.96, y 2 to 16, and
|
||||||
|
the tray bar below it is unchanged at y 17.5 to 20. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<group
|
||||||
|
android:translateX="2.4375"
|
||||||
|
android:translateY="0.0899"
|
||||||
|
android:scaleX="0.018330"
|
||||||
|
android:scaleY="0.018330">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.833 C 535.533Line truncated
|
||||||
|
</group>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M5,17.5h14v2.5H5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// `update` and `clear` are ours. registerListener / removeListener are the
|
||||||
|
// mobile plugin runtime's own commands, the ones `addPluginListener`
|
||||||
|
// invokes to hear the `action` event — commands like any other to the ACL,
|
||||||
|
// and without them here the listener is refused and the notice's buttons go
|
||||||
|
// nowhere. The media-session plugin carries the same scar.
|
||||||
|
const COMMANDS: &[&str] = &["update", "clear", "registerListener", "removeListener"];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Registers the command permissions (permissions/autogenerated) and, on
|
||||||
|
// Android, hands the Gradle module under android/ to tauri-build, which
|
||||||
|
// includes it in the app project (tauri.settings.gradle) — but only with
|
||||||
|
// TAURI_ANDROID_PROJECT_PATH set, see ANDROID.md.
|
||||||
|
tauri_plugin::Builder::new(COMMANDS)
|
||||||
|
.android_path("android")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-clear"
|
||||||
|
description = "Enables the clear command without any pre-configured scope."
|
||||||
|
commands.allow = ["clear"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-clear"
|
||||||
|
description = "Denies the clear command without any pre-configured scope."
|
||||||
|
commands.deny = ["clear"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-registerListener"
|
||||||
|
description = "Enables the registerListener command without any pre-configured scope."
|
||||||
|
commands.allow = ["registerListener"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-registerListener"
|
||||||
|
description = "Denies the registerListener command without any pre-configured scope."
|
||||||
|
commands.deny = ["registerListener"]
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-removeListener"
|
||||||
|
description = "Enables the removeListener command without any pre-configured scope."
|
||||||
|
commands.allow = ["removeListener"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-removeListener"
|
||||||
|
description = "Denies the removeListener command without any pre-configured scope."
|
||||||
|
commands.deny = ["removeListener"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
"$schema" = "../../schemas/schema.json"
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-update"
|
||||||
|
description = "Enables the update command without any pre-configured scope."
|
||||||
|
commands.allow = ["update"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-update"
|
||||||
|
description = "Denies the update command without any pre-configured scope."
|
||||||
|
commands.deny = ["update"]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
## Default Permission
|
||||||
|
|
||||||
|
The downloads notice on the phone: `update` mirrors what Save is fetching,
|
||||||
|
`clear` takes it down, and the two listener commands are what lets the page
|
||||||
|
hear the notice's buttons (`addPluginListener`).
|
||||||
|
|
||||||
|
#### This default permission set includes the following:
|
||||||
|
|
||||||
|
- `allow-update`
|
||||||
|
- `allow-clear`
|
||||||
|
- `allow-registerListener`
|
||||||
|
- `allow-removeListener`
|
||||||
|
|
||||||
|
## Permission Table
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Identifier</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:allow-clear`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the clear command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:deny-clear`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the clear command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:allow-registerListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the registerListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:deny-registerListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the registerListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:allow-removeListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the removeListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:deny-removeListener`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the removeListener command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:allow-update`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Enables the update command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
`transfer-notice:deny-update`
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
Denies the update command without any pre-configured scope.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"$schema" = "schemas/schema.json"
|
||||||
|
|
||||||
|
[default]
|
||||||
|
description = """
|
||||||
|
The downloads notice on the phone: `update` mirrors what Save is fetching,
|
||||||
|
`clear` takes it down, and the two listener commands are what lets the page
|
||||||
|
hear the notice's buttons (`addPluginListener`).
|
||||||
|
"""
|
||||||
|
permissions = ["allow-update", "allow-clear", "allow-registerListener", "allow-removeListener"]
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "PermissionFile",
|
||||||
|
"description": "Permission file that can define a default permission, a set of permissions or a list of inlined permissions.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"default": {
|
||||||
|
"description": "The default permission set for the plugin",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/DefaultPermission"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"set": {
|
||||||
|
"description": "A list of permissions sets defined",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionSet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"description": "A list of inlined permissions",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Permission"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"DefaultPermission": {
|
||||||
|
"description": "The default permission set of the plugin.\n\nWorks similarly to a permission with the \"default\" identifier.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PermissionSet": {
|
||||||
|
"description": "A set of direct permissions grouped together under a new name.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"identifier",
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"description": "All permissions this set contains.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/PermissionKind"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Permission": {
|
||||||
|
"description": "Descriptions of explicit privileges of commands.\n\nIt can enable commands to be accessible in the frontend of the application.\n\nIf the scope is defined it can be used to fine grain control the access of individual or multiple commands.",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"description": "The version of the permission.",
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 1.0
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"description": "A unique identifier for the permission.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"description": "Human-readable description of what the permission does. Tauri internal convention is to use `<h4>` headings in markdown content for Tauri documentation generation purposes.",
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"description": "Allowed or denied commands when using this permission.",
|
||||||
|
"default": {
|
||||||
|
"allow": [],
|
||||||
|
"deny": []
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Commands"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"description": "Allowed or denied scoped when using this permission.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Scopes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"platforms": {
|
||||||
|
"description": "Target platforms this permission applies. By default all platforms are affected by this permission.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Commands": {
|
||||||
|
"description": "Allowed and denied commands inside a permission.\n\nIf two commands clash inside of `allow` and `deny`, it should be denied by default.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Allowed command.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Denied command, which takes priority.",
|
||||||
|
"default": [],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Scopes": {
|
||||||
|
"description": "An argument for fine grained behavior control of Tauri commands.\n\nIt can be of any serde serializable type and is used to allow or prevent certain actions inside a Tauri command. The configured scope is passed to the command and will be enforced by the command implementation.\n\n## Example\n\n```json { \"allow\": [{ \"path\": \"$HOME/**\" }], \"deny\": [{ \"path\": \"$HOME/secret.txt\" }] } ```",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"description": "Data that defines what is allowed by the scope.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.",
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Value": {
|
||||||
|
"description": "All supported ACL values.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents a null JSON value.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`bool`].",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a valid ACL [`Number`].",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`String`].",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a list of other [`Value`]s.",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a map of [`String`] keys to [`Value`]s.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/definitions/Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Number": {
|
||||||
|
"description": "A valid ACL number.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Represents an [`i64`].",
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Represents a [`f64`].",
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Target": {
|
||||||
|
"description": "Platform target.",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "MacOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"macOS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Windows.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"windows"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Linux.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Android.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "iOS.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"iOS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PermissionKind": {
|
||||||
|
"type": "string",
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"description": "Enables the clear command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-clear",
|
||||||
|
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the clear command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-clear",
|
||||||
|
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the registerListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-registerListener",
|
||||||
|
"markdownDescription": "Enables the registerListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the registerListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-registerListener",
|
||||||
|
"markdownDescription": "Denies the registerListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the removeListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-removeListener",
|
||||||
|
"markdownDescription": "Enables the removeListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the removeListener command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-removeListener",
|
||||||
|
"markdownDescription": "Denies the removeListener command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the update command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "allow-update",
|
||||||
|
"markdownDescription": "Enables the update command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the update command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "deny-update",
|
||||||
|
"markdownDescription": "Denies the update command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "default",
|
||||||
|
"markdownDescription": "The downloads notice on the phone: `update` mirrors what Save is fetching,\n`clear` takes it down, and the two listener commands are what lets the page\nhear the notice's buttons (`addPluginListener`).\n\n#### This default permission set includes:\n\n- `allow-update`\n- `allow-clear`\n- `allow-registerListener`\n- `allow-removeListener`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//! The downloads notice on the phone.
|
||||||
|
//!
|
||||||
|
//! The Transfers screen only exists inside the app: the desktop says what a
|
||||||
|
//! Save is doing with its title-bar ring, the phone with a badge on the nav,
|
||||||
|
//! and neither is visible once the app is in the background — which is
|
||||||
|
//! exactly when a Save is left to run on its own. So a running Save says so
|
||||||
|
//! where the phone says things, in the shade: how many files are left, the
|
||||||
|
//! one it is on, a progress bar, Stop all and Open.
|
||||||
|
//!
|
||||||
|
//! Its own channel, low importance, so it can be silenced without silencing
|
||||||
|
//! the player's card. Its own foreground service (`TransferService`,
|
||||||
|
//! `dataSync`) rather than the media one, because a download is not
|
||||||
|
//! playback: `MediaService` stops itself the moment the bar empties, and a
|
||||||
|
//! `mediaPlayback` service with no session is a policy violation. That
|
||||||
|
//! service is also what keeps the process alive for a Save with nothing
|
||||||
|
//! playing — the way the first real Save died, four tracks in.
|
||||||
|
//!
|
||||||
|
//! Two commands, both fed by the phone UI (src/mobile/xfernote.svelte.js):
|
||||||
|
//! `update` with what is still in flight, `clear` when the queue empties.
|
||||||
|
//! The notice's buttons arrive as the plugin event `action`
|
||||||
|
//! (`addPluginListener("transfer-notice", "action", …)`): `cancel` stops
|
||||||
|
//! everything still going, `open` brings the app forward on Transfers.
|
||||||
|
//!
|
||||||
|
//! On the desktop the plugin is registered too, so the permission set
|
||||||
|
//! resolves on every platform, but its commands answer "not here".
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{
|
||||||
|
plugin::{Builder, TauriPlugin},
|
||||||
|
Runtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What the notice says: how many files are still in flight, the one it is
|
||||||
|
/// on, and how far the burst has come. `indeterminate` is a queue whose
|
||||||
|
/// sizes no peer has stated yet — a bar with no end to walk towards.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TransferNotice {
|
||||||
|
#[serde(default)]
|
||||||
|
pub count: u32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub line: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub percent: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub indeterminate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
|
pub struct TransferNoticePlugin<R: Runtime>(tauri::plugin::PluginHandle<R>);
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn update<R: Runtime>(app: tauri::AppHandle<R>, state: TransferNotice) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let notice = app.state::<TransferNoticePlugin<R>>();
|
||||||
|
notice
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("update", state)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = (app, state);
|
||||||
|
Err("the downloads notice is a phone feature".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear<R: Runtime>(app: tauri::AppHandle<R>) -> Result<(), String> {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let notice = app.state::<TransferNoticePlugin<R>>();
|
||||||
|
notice
|
||||||
|
.0
|
||||||
|
.run_mobile_plugin::<serde_json::Value>("clear", ())
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
{
|
||||||
|
let _ = app;
|
||||||
|
Err("the downloads notice is a phone feature".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
|
Builder::new("transfer-notice")
|
||||||
|
.invoke_handler(tauri::generate_handler![update, clear])
|
||||||
|
.setup(|app, api| {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
use tauri::Manager;
|
||||||
|
let handle = api.register_android_plugin(
|
||||||
|
"com.soulstream.transfernotice",
|
||||||
|
"TransferNoticePlugin",
|
||||||
|
)?;
|
||||||
|
app.manage(TransferNoticePlugin(handle));
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let _ = (app, api);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# handoff/ — the Android port's pending work
|
||||||
|
|
||||||
|
Saved 2026-09-14 when the session ended. Nothing here is on `main`; `main` holds
|
||||||
|
only rounds that were built, walked on the device and committed.
|
||||||
|
|
||||||
|
- `rounds/` — every round's patch script and commit message, and the insertion
|
||||||
|
files they read. **Rounds 43 to 48 are written and verified but not yet
|
||||||
|
shipped**; 49 is incomplete (authoring was stopped mid-flight). The earlier
|
||||||
|
ones are kept because a patch is the record of how a round was made.
|
||||||
|
- `tools/` — the pipeline. `preflight.sh` applies a wave in order to a throwaway
|
||||||
|
copy; `chain.sh` patches and ships a list of rounds; `reset-tree.sh` puts the
|
||||||
|
tree back after a failure (a plain `git checkout --` leaves new files behind
|
||||||
|
and trips the round's own re-run guard); `compare-walks.sh` and
|
||||||
|
`baseline-mock-steps.txt` are the A/B for the unverified adaptive-wait change;
|
||||||
|
`audit-commands.cjs` lists backend commands the phone never calls.
|
||||||
|
- `notes/` — the Android Auto design in full, and the icon background comparison
|
||||||
|
mock (open `icon-mock.html` directly; it needs no server).
|
||||||
|
|
||||||
|
Start with `../HANDOFF-2026-09-14-android-auto-and-icon.md`.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Android Auto — the design
|
||||||
|
|
||||||
|
_Produced 2026-09-14 by a research pass (current API research, testing research,
|
||||||
|
a scout of this repo's media stack, a scout of every icon surface) and a synthesis._
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Build it on media3 `MediaLibraryService`, and migrate the existing card onto it rather than standing a second service beside it. Do the icon first, in two small rounds, before touching any of it.**
|
||||||
|
|
||||||
|
Three decisions, stated plainly:
|
||||||
|
|
||||||
|
**1. media3, not `MediaBrowserServiceCompat`.** `androidx.media` — the whole library the plugin currently depends on (`crates/tauri-plugin-media-session/android/build.gradle.kts:26`, `androidx.media:media:1.7.0`) — was deprecated in 1.8.0 (2026-05-06). `MediaSessionCompat`, `MediaButtonReceiver` and the `MediaStyle` notification in `MediaService.kt` are all on it. Building Auto on `MediaBrowserServiceCompat` would be adding new code to a deprecated library, which is exactly the obsolete pattern Kyle asked to avoid. media3 1.11.0 is stable and gives the legacy `android.media.browse.MediaBrowserService` binder Auto actually binds, for free — you just declare both actions in the intent-filter.
|
||||||
|
|
||||||
|
**2. Migrate, do not wrap or double up.** Two live `MediaSession`s in one app means two cards in the shade and undefined media-button targeting; media3 posts and owns the notification itself. There is no safe "add Auto alongside" intermediate — the cut is atomic within one deploy. That means one round deliberately puts the daily-use lock-screen card at risk. **This is the real cost of Auto and it should be said out loud before starting.** The mitigations are: land the hardest sub-piece (the queue mirror) a round *early*, where it cannot break anything; make the migration its own round that ships and soaks before any car-facing code follows; and re-run by hand the one check the walk provably cannot catch — the 25-second paused-restore FGS survival test, which `memory/android-fgs-promote-first.md` records as having killed a real Save while the walks passed.
|
||||||
|
|
||||||
|
**3. The cold-start problem has a real answer, and it is smaller than it looks.** The framing "playback lives in Rust driven by a WebView, so cold start is hard" is half right. What is genuinely WebView-bound is the **queue** (`src/mobile/player.svelte.js`) and the **resolver** (`src/lib/sources.js`, 872 lines of JS). What is *not* WebView-bound — and this is the load-bearing finding — is everything else: `crates/library` is plain rusqlite with no Tauri dependency, `crates/player` is plain rodio/symphonia with no Tauri dependency, and `JNI_OnLoad` already publishes the JavaVM and Context to `ndk-context` at `System.loadLibrary` time, before any Activity exists. `object Rust`'s class initializer does nothing but load that .so.
|
||||||
|
|
||||||
|
So a service-started process with no Activity, no Tauri and no WebView can already: load the native library, open `library.db`, and drive rodio. The honest staged answer is:
|
||||||
|
|
||||||
|
- **Browse works cold from day one**, served by a new JNI entry in `libapp_lib.so` that opens the library **read-only** (`Connection::open_with_flags`, never `Library::open` — that one writes) and answers from its own small queries.
|
||||||
|
- **Playback from cold lands one stage later**, as a headless fallback queue of local file paths that exists only while no WebView is alive, over a process-singleton `Player`.
|
||||||
|
- **Catalog and Soulseek content never plays from cold**, in this plan or any realistic successor, because the resolver is JS and Soulseek needs a live session. Those items get the documented `STATE_ERROR` + `ERROR_RESOLUTION_ACTION_LABEL` + resolution intent — "open SoulStream on your phone" — which is also exactly what satisfies Google's "must work when the user isn't signed in" test requirement.
|
||||||
|
|
||||||
|
**What the user experiences in between** (after browse lands, before cold playback lands): the car shows the full library tree whether or not the app is running. If the app *is* running — which it is whenever the phone has been used recently — taps play normally. If the process is cold, a tap surfaces a one-line card telling them to open the app, and the next tap works. That is a real, defensible intermediate, not a broken one, and it is worth shipping on the way.
|
||||||
|
|
||||||
|
**Two mechanism notes that save real work.** First, the JNI symbol lives in `libapp_lib.so` (the app crate, which has the `library` and `player` deps) while the Kotlin `external fun` is declared in the *plugin* module's own package and calls `System.loadLibrary("app_lib")` itself. Names link at runtime, so the plugin crate needs no Rust dependency on `library` — the whole "plugin can't depend upward on `LibraryState`, so it needs a registration hook" problem simply evaporates. Second, resources: a library module cannot override a resource the app module already defines, so the density mipmaps must come from a **tracked res overlay copied in by `build.sh`/`dev.sh`**; but the app module defines no `mipmap-anydpi-v26` and sets no `android:roundIcon`/`appCategory`, so the adaptive icon and the manifest attributes can come from a library module. Use both, each for what it can actually do. The overlay also becomes the only sane home for `res/xml/file_paths.xml`, which Auto needs later for `content://` cover art and which currently exposes only `external-path` and `cache-path`.
|
||||||
|
|
||||||
|
Scope: Android Automotive OS, Android TV, Wear and a Play release are **separate jobs**, none of which come along for free. The template already declares `LEANBACK_LAUNCHER` with no banner — leave it dormant rather than adding a banner and making a never-tested TV entry look shippable. And note that a sideloaded SoulStream is invisible to Android Auto until developer mode + "Unknown sources" are enabled on the phone; real-world visibility needs a Play release and the car-app-quality review. That is a product decision, not an engineering one.
|
||||||
|
|
||||||
|
## Rounds
|
||||||
|
|
||||||
|
Nine rounds. Two are icon, six are Auto, one is Kyle's. Rounds 4 and 7 are honestly bigger than a sitting and are flagged as such.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R1 — The launcher icon, everywhere it is static.**
|
||||||
|
|
||||||
|
*Delivers:* SoulStream's falcon replaces Tauri's logo on the launcher, in recents, on the splash and in other apps' share sheets; a real adaptive icon with a monochrome layer so Android 13+ themed icons work.
|
||||||
|
|
||||||
|
*Touches:* a new tracked `src-tauri/android/res/` overlay carrying `mipmap-anydpi-v26/{ic_launcher,ic_launcher_round}.xml`, `mipmap-*/ic_launcher.png` (kit `legacy/`), and the foreground/background/monochrome layers; a copy step added to `src-tauri/android/build.sh` (before the `cd src-tauri/gen/android && gradlew` at line ~21) **and** to `dev.sh`. The kit's own `mipmap-anydpi-v26/*.xml` references `@drawable/ic_launcher_foreground` while the PNGs ship under `mipmap-*` with no `drawable-*` directory anywhere — verified by reading both — so it will not compile as delivered and must be reconciled during the copy (point the XML at `@mipmap/`, or land the layers under `drawable-*`).
|
||||||
|
|
||||||
|
*Verified by:* the walk cannot see a launcher. Three checks instead — (a) unzip the built APK and assert `res/mipmap-anydpi-v26/ic_launcher.xml` exists and the `ic_launcher.png` hashes now match the kit, which is a pure build assertion and can be a permanent one-liner; (b) `deploy.sh` already screenshots after launch, so the recents/splash change shows up there; (c) one `adb exec-out screencap` of the launcher with themed icons off, then on — 60 seconds of Kyle's phone, and the only way adaptive masking and the monochrome layer are ever really proven.
|
||||||
|
|
||||||
|
*Depends on:* nothing. Ship first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R2 — The icon's moving parts.**
|
||||||
|
|
||||||
|
*Delivers:* a falcon notification glyph replacing the generic eighth-note; an app-icon fallback bitmap so the lock screen and (later) Auto never show a grey hole when a cover hasn't decoded; a proper Android 12+ splash instead of the platform's white flash; `android:appCategory="audio"`.
|
||||||
|
|
||||||
|
*Touches:* `ic_soulstream_note.xml` (media-session) and `ic_soulstream_down.xml` (transfer-notice) — **both**, or the two notifications stop matching; `MediaService.kt:261` `.setLargeIcon(MediaHub.art)` and `:205-208`'s `METADATA_KEY_ALBUM_ART` gain a decoded-once fallback; `values/themes.xml` in the overlay gains `Theme.App.Starting`; a small library manifest in the media-session module adds `appCategory` (the app manifest sets none, so the merger takes it).
|
||||||
|
|
||||||
|
*Verified by:* the walk already reads `dumpsys notification --noredact` (smoke.mjs:2180) — assert the card is still posted and the transport still works. The glyph itself is a screenshot: Android's shade takes the alpha channel only, so a falcon exported with colour renders as a white blob and only a pixel will show it. Splash is a screencap during the first 400 ms of a cold start.
|
||||||
|
|
||||||
|
*Depends on:* R1's overlay mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R3 — The mirror grows a queue window. (No behaviour change.)**
|
||||||
|
|
||||||
|
*Delivers:* the single hardest sub-piece of the media3 migration, landed a round early where it cannot break anything.
|
||||||
|
|
||||||
|
*Touches:* `NowPlaying` in `crates/tauri-plugin-media-session/src/lib.rs:29-44` and `NowPlayingArgs` in `MediaSessionPlugin.kt:18-31` gain a bounded queue window (current ±N items: id, title, artist, album, duration, artKey) plus the current index; `src/mobile/media.svelte.js:15-43` fills them from `player.queue`/`player.qi`. `MediaService.kt` ignores the new fields entirely.
|
||||||
|
|
||||||
|
*Why it matters:* media3's `SimpleBasePlayer.getState()` wants a playlist and an index. Today Kotlin knows exactly one track. Doing this inside R4 would mean debugging a new mirror contract and a new session library at the same time. Doing it here means that by the time media3 arrives, the data it needs has already been running on the phone for a round.
|
||||||
|
|
||||||
|
*Verified by:* the existing card must be completely unchanged — the walk's media-session steps pass as-is. Add one logcat assertion that the window arrives populated and the index is right after a next/prev. Genuinely half a sitting.
|
||||||
|
|
||||||
|
*Depends on:* nothing (can even run in parallel with R1/R2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R4 — media3 migration. No Auto yet. THE BIG ONE.**
|
||||||
|
|
||||||
|
*Delivers:* the same lock-screen card, shade transport, headset buttons, audio focus and becoming-noisy pause, on a current API — and the `Player` abstraction that everything after this depends on.
|
||||||
|
|
||||||
|
*Touches:* `build.gradle.kts` swaps `androidx.media:media:1.7.0` for `androidx.media3:media3-session` + `media3-common`. A new `SoulStreamPlayer : SimpleBasePlayer` maps `getState()` from `MediaHub.state` (with R3's window) and routes `handlePlay`/`handlePause`/`handleSeek`/`handleSetMediaItem`/`handleSeekToNext|Previous` into the existing `MediaHub.act(...)` — the round trip out to the WebView via `Plugin.trigger("action", …)` is unchanged, so `media.svelte.js:57-68` keeps working untouched. `MediaService` becomes a `MediaSessionService` returning a `MediaSession`. Deleted: `buildNotification` (`:247-282`), the `MediaButtonReceiver` declaration and the `MEDIA_BUTTON` intent-filter in the plugin manifest (`:11-27`), and the whole audio-focus block (`:107-127`, `:295-308`) — media3 handles focus via `setAudioAttributes(…, handleAudioFocus = true)`. Leaving the old receiver in place alongside media3's will fight for media buttons.
|
||||||
|
|
||||||
|
*Verified by:* the walk's existing media-session steps re-pointed at the new card (`mediaSessionState()` at smoke.mjs:2211 reads `dumpsys media_session`, which media3 still populates). Then **by hand, every time**: lock screen shows title/artist/art; shade transport play/pause/next/prev; a headset button with the app backgrounded; unplugging headphones pauses; another app taking focus pauses and returning resumes; and the 25-second paused-restore check — force-stop, relaunch with a paused track, wait 25 s, confirm the pid survives and logcat has no `ForegroundServiceDidNotStartInTimeException`. That last one is not optional and the walk cannot do it: its restart step resumes inside 3.5 s, well under the ~10 s kill window, which is exactly why this class of bug shipped once already.
|
||||||
|
|
||||||
|
*Honest sizing:* 3–4 sittings, and it cannot be split — a half-migrated app has two sessions. Recommend it ships on its own and soaks for a few days of ordinary use before R5.
|
||||||
|
|
||||||
|
*Depends on:* R3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R5 — Discovery + the prober. Auto can see the app and gets a root.**
|
||||||
|
|
||||||
|
*Delivers:* Android Auto lists SoulStream and opens it to a root that answers instantly, with nothing under it yet. Plus the only instrument that can observe any of this.
|
||||||
|
|
||||||
|
*Touches:* `MediaSessionService` becomes `MediaLibraryService`; `onGetSession` returns a `MediaLibrarySession`; `onGetLibraryRoot` returns immediately and unconditionally (no DB, no I/O — the host times out otherwise). Plugin manifest: service `exported="true"` with **both** `androidx.media3.session.MediaLibraryService` and `android.media.browse.MediaBrowserService` actions, plus `<meta-data android:name="com.google.android.gms.car.application" android:resource="@xml/automotive_app_desc"/>` and a new `res/xml/automotive_app_desc.xml` in the plugin module. New `tools/auto-prober/` — its own ~200-line Kotlin Gradle project, deliberately outside `src-tauri/gen/android` so `tauri android init` cannot delete it — that binds a `MediaBrowser` with Auto-shaped root hints, walks the tree, and logs JSON under one logcat tag.
|
||||||
|
|
||||||
|
*Verified by:* `adb shell pm dump com.soulstream.app` asserts the `android.media.browse.MediaBrowserService` filter resolves and the `gms.car.application` meta-data points at a real resource — two permanent one-line assertions in the walk that catch the `TAURI_ANDROID_PROJECT_PATH` trap documented in `env.sh:18-22`, a failure this project has already lived through as a start-up `ClassNotFoundException`. Then the prober: `am force-stop`, bind, assert a root comes back within DR-3's 10 s with the app process cold.
|
||||||
|
|
||||||
|
*Before committing to this round:* run the plain `bindService`/`unbindService` check for androidx/media issue #3158 — a create/bind/destroy loop at ~30 cycles/second when Auto binds with nothing playing, reported open against 1.6.1 and 1.7.1 and unconfirmed on 1.11.0. `MediaService` registers a `BroadcastReceiver` and an `AudioFocusRequest` in `onCreate`, which is precisely the shape that bug punishes. Mitigation if it still bites: process-lifetime singleton session/player, nothing registered in `onCreate`.
|
||||||
|
|
||||||
|
*Depends on:* R4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R6 — The tree.**
|
||||||
|
|
||||||
|
*Delivers:* Recent / Albums / Artists / Playlists in the car, three levels deep, with covers.
|
||||||
|
|
||||||
|
*Touches:* a new Rust module in the app crate exporting `Java_com_soulstream_mediasession_..._nativeBrowse`, opening the DB **read-only** via `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` with its own queries — never `Library::open`, which runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` write batch (`crates/library/src/lib.rs:259-262`). The path is derived from the `filesDir` Kotlin passes down plus a **read-only, never-saving** parse of `config.json` for `download_dir` (default `<filesDir>/Library`, per `config.rs:326-341`). Artists are not a Rust concept — they are derived in JS at `lib.svelte.js:55-65` — so the Rust side needs the same grouping. Cover art goes out as `content://` through the existing FileProvider, which needs a `files-path` entry added to `res/xml/file_paths.xml` via R1's overlay. Root children: read `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT` rather than hardcoding 4; all browsable, none playable; ignore `page`/`pageSize`, since Auto does not paginate.
|
||||||
|
|
||||||
|
*Verified by:* the prober, extended into real assertions in the walk — root ≤ limit and all browsable; each tab's children load with unique stable ids; every item has a title and an art URI (MC-1); per-node `onGetChildren` latency under DR-3's 10 s, as numbers, the same way the walk already asserts preload latency. All of it with the app force-stopped, which is the whole point: with no WebView there is no CDP, so the existing walk is blind here and the prober is the only eye.
|
||||||
|
|
||||||
|
*Watch for:* a read-only connection on a WAL database needs the `-shm`; same UID so it should create one, but if the WAL holds uncheckpointed content with no writer around it can fail to open. Prove it, and make the failure return an empty-but-valid tree rather than hanging the head unit.
|
||||||
|
|
||||||
|
*Depends on:* R5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R7 — Cold-start playback. The second big one.**
|
||||||
|
|
||||||
|
*Delivers:* a tap in the car plays a local library track with the phone in a pocket and no UI running, and the album keeps going.
|
||||||
|
|
||||||
|
*Touches:* the `Player` moves to a process-wide `OnceLock<Arc<Player>>` in the app crate, taken by whoever needs it first — `playback::init` (`src-tauri/src/playback.rs:187-191`, today `Player::spawn_with_device` owned inside `PlayerState`) or the headless service. Two `Player`s in one process means two AAudio output streams, so this is the load-bearing piece. The event pump that feeds `PlaybackSnapshot` becomes the same singleton, with a subscription slot. A new `nativePlay(mediaId)` resolves local paths read-only and drives a small **headless fallback queue** — a `Vec<PathBuf>` plus an index that advances on `track-ended`. It is not "move the queue into Rust": the JS queue keeps shuffle, repeat, `alts` failover, provenance and 3-ahead preload. The fallback exists only when no WebView is alive, and the WebView takes over when it comes up — which the session-restore path already half-does, since it asks `player_state()` and follows a live audio thread.
|
||||||
|
|
||||||
|
*Verified by:* the prober — force-stop, bind, `playFromMediaId`, assert `dumpsys media_session` reaches `STATE_PLAYING` and `dumpsys media.audio_flinger` shows a track on the app's uid (both parsers already exist in the walk, smoke.mjs:2211 and :2242). The real proof is the **second** track: assert the fallback queue advances on its own. Then the handover: launch the app while headless playback is running and assert exactly one audio stream and one queue driver survive.
|
||||||
|
|
||||||
|
*Honest sizing:* 3 sittings. The handover rule is where the bugs will be.
|
||||||
|
|
||||||
|
*Depends on:* R6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R8 — Voice, errors, and the quality pass.**
|
||||||
|
|
||||||
|
*Delivers:* "play X" from Assistant/Gemini; an honest error instead of silence for anything the resolver would be needed for; the checklist items that block Play acceptance later.
|
||||||
|
|
||||||
|
*Touches:* `onSearch`/`onGetSearchResult` **and** the `mediaItem.requestMetadata.searchQuery` path in `onAddMediaItems` — Google's own samples disagree on which Auto uses, so implement both. Empty query ("play some music") → Recent. Catalog/Soulseek items → `STATE_ERROR` with a localized message routed through `src/mobile/trouble.js`'s existing `say()`, plus `ERROR_RESOLUTION_ACTION_LABEL` and a resolution intent. Content-style extras (grid for albums/artists, list for tracks), title + thumbnail on every item (MC-1), no autoplay on bind (MA-1 — already half-satisfied, since restore comes up paused), tintable white icons from the kit's monochrome SVG (VD-2).
|
||||||
|
|
||||||
|
*Verified by:* the prober for search (assert rows come back for a known query, and that a catalog item's tap lands in `STATE_ERROR` with a non-empty message rather than silence). The visual half is DHU-only.
|
||||||
|
|
||||||
|
*Depends on:* R7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**R9 — DHU pass. Kyle's round, not an agent's.**
|
||||||
|
|
||||||
|
*Delivers:* the answers only pixels can give.
|
||||||
|
|
||||||
|
*How:* `sdkmanager "extras;google;auto"` → `%LOCALAPPDATA%\Android\Sdk\extras\google\auto\desktop-head-unit.exe`. On the phone: Settings → Connected devices → Connection preferences → Android Auto → About → tap Version ×10 → ⋮ → Developer settings → **Unknown sources on**, then ⋮ → Start head unit server. Then `adb forward tcp:5277 tcp:5277` and launch the DHU. Prefer the adb tunnel over `--usb` on Windows — `--usb` needs a WinUSB driver and fights adb.
|
||||||
|
|
||||||
|
*Checks:* contrast (VD-1/2/3), no animation (SA-1 — the app's animated eq-bars glyph and waveform lane must never reach a browse row or the playback card), no auto-scrolling text (ST-1), ≤2 s button response (DR-1), day/night (`daynight`), driving restrictions (`U` / `Shift+U`), rotary input (`-i rotary`), all three resolutions, and — critically — what Auto actually presents itself as in `onConnect` (log `packageName`/`uid`/signature rather than copying a years-old `allowed_media_browser_callers.xml`).
|
||||||
|
|
||||||
|
*Cannot be answered by DHU or by any agent:* real car audio routing, OEM launcher skins, wireless projection handover, and genuine Gemini/Assistant round trips. Those need an actual car, and are worth deferring to "Kyle happens to be in one."
|
||||||
|
|
||||||
|
*One caution:* `smoke.mjs` calls `adb("forward", "--remove-all")` around line 2430, which will drop the DHU tunnel mid-session. Do not run the walk and a DHU session at the same time, or fence that call.
|
||||||
|
|
||||||
|
*Add to ANDROID.md:* the exact invocation and the phone's setting path. Those taps are forgettable and the docs for them move.
|
||||||
|
|
||||||
|
## Decisions that were Kyle's
|
||||||
|
|
||||||
|
**Fork 1 — The phone icon's background: the kit's flat `#05080B`, or concept-04's navy gradient rebuilt at 108dp.**
|
||||||
|
|
||||||
|
The desktop ships concept 04 "Deep navy glow" (`src-tauri/icons/icon.svg` is byte-identical to the kit's `soulstream-04-deep-navy-glow.svg`). The kit's *adaptive* background is flat `#05080B` — the older, darker `soulstream-app-dark` ground. Taking the kit as-is gives a phone icon visibly flatter than the desktop's, side by side.
|
||||||
|
|
||||||
|
- Flat: ship it as `<color name="ic_launcher_background">#FF05080B</color>` rather than five identical flat PNGs. Zero extra work, and honestly it reads fine under a launcher mask.
|
||||||
|
- Gradient: one 108-unit SVG reproducing concept 04's `#0A2330 → #07141F → #040A10` axis plus the `#31C0CB`/0.18 radial glow, scaled 1024→108.
|
||||||
|
|
||||||
|
**Recommendation: build the gradient, but mock it first.** It is maybe 15 minutes and it is the difference between "the phone has the app's icon" and "the phone has a version of the app's icon". Kyle's own standing rule is that a look gets mocked before it gets implemented, and this qualifies — a side-by-side of both backgrounds under a circle mask, themed on and off, is cheap and is the only way to actually judge it.
|
||||||
|
|
||||||
|
One note either way: the kit's foreground sits 2 px above the geometric safe-zone line at xxxhdpi, recorded in `brand-tokens.json` as `opticalSourceOffset`. That is a deliberate optical correction for the light wing and tail tips. Do not "fix" it — it will look low, and it will diverge from every other kit export.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fork 2 — Where Auto stops.**
|
||||||
|
|
||||||
|
Three defensible stopping points, and they differ by several sittings:
|
||||||
|
|
||||||
|
- **(a) R1–R6.** Browse works always; playback works when the app process is alive, which is most of the time in practice. Cold tap → "open SoulStream on your phone", then it works. Stops after the tree.
|
||||||
|
- **(b) R1–R8.** Adds cold-start playback for the local library and the search/voice/error pass. The car works from a genuinely cold phone for music Kyle owns.
|
||||||
|
- **(c) Catalog and Soulseek in the car.** Would require the resolver (`src/lib/sources.js`, 872 lines of shared JS) and a Soulseek session to exist headlessly. Not in this plan and not in a realistic successor.
|
||||||
|
|
||||||
|
**Recommendation: commit to (b), ship (a) on the way, never promise (c).** (a) is a real intermediate rather than a broken one, and shipping it means R7's risk is taken against something already working in a car rather than against a blank. But (a) alone is not what "Android Auto support" means to a user — the car binds the service when the phone is in a pocket, which is exactly when the process is cold. Stopping at (a) permanently would mean the feature works least often precisely when it is most wanted.
|
||||||
|
|
||||||
|
Folded into this recommendation: **let R4 soak.** The media3 migration should ship on its own and get a few days of ordinary daily use before R5 lands on top of it. It is the round that risks the card Kyle uses every day, and a regression in it is much easier to see and attribute alone than underneath three rounds of car code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fork 3 — Build the prober APK, or drive everything by hand from the DHU.**
|
||||||
|
|
||||||
|
The prober is ~200 lines of Kotlin plus its own small Gradle project under `tools/`, installed alongside the app, driven from `smoke.mjs` with the `adb` helper it already has (`tests/mobile/cdp.mjs:25`) and gated to dev builds.
|
||||||
|
|
||||||
|
- **Build it:** every structural thing Auto checks becomes an assertion — root width and flags, unique stable ids, title and art on every item, per-node latency as numbers against DR-3, and the cold-start case.
|
||||||
|
- **Skip it:** rely on the DHU and Google's Media Controller Test app, both of which are human-driven and neither of which asserts anything about the browse tree.
|
||||||
|
|
||||||
|
**Recommendation: build it — but note the decision is coupled to Fork 2.** If Kyle stops at (a), the prober's value drops a lot: the tree can be eyeballed in the DHU and the cold-start case barely matters. If Kyle goes to (b), the prober is not optional. With no WebView there is no CDP, so the walk is structurally blind to every single thing R6 and R7 deliver — the prober is the only instrument that can see the state Auto actually operates in. It is also the cheapest insurance against R7's handover bugs, which will otherwise only show up as "the car went quiet sometimes".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Deliberately not offered as forks:* media3 vs `MediaBrowserServiceCompat` (the latter means writing new code into a library deprecated five months ago — settled), migrate vs second service (two sessions means two shade cards and undefined button routing — settled), and Android TV (the template's dormant `LEANBACK_LAUNCHER` costs nothing; adding the kit's banner would make a never-tested TV entry look shippable, and removing the category means editing a generated file — leave it alone).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
**What could break that works today**
|
||||||
|
|
||||||
|
**The lock-screen card, in R4.** This is the headline risk and there is no way to avoid it while getting Auto right. Media3 takes over notification posting, media-button routing and audio focus, which means the promote-first invariant gets re-litigated inside `MediaSessionService`'s own lifecycle. `memory/android-fgs-promote-first.md` records that this exact class of bug — a foreground start that does not promote in time — killed the process mid-Save on 2026-09-13, four of fourteen tracks in, **and that the walks did not catch it**: the restart step resumed in 3.5 s, well inside the ~10 s kill window. The 25-second paused-restore check is manual, every time, no exceptions.
|
||||||
|
|
||||||
|
**Two sessions is not a safe intermediate.** No incremental "add media3 alongside" exists. R4 is atomic within one deploy, or the shade shows two cards and media buttons land somewhere undefined.
|
||||||
|
|
||||||
|
**The `mediaPlayback` service's lifetime changes shape.** Today `MediaService.refresh()` calls `stopSelf()` the moment the bar empties (`:191-198`) — deliberately, which is why downloads needed their own `dataSync` service. Auto binds with `BIND_AUTO_CREATE` and **never calls `onStartCommand`**, so the current promote-first path is simply not on Auto's route into the process. Bound is not the same as foregrounded, so this is probably fine — but "probably" is not good enough for the code path that already killed the app once. Decide it deliberately in R5, do not inherit it.
|
||||||
|
|
||||||
|
**androidx/media #3158 — the bind loop.** When Auto binds a `MediaLibraryService` with no playback ongoing, the service can enter a `onCreate → onBind → onGetSession → onConnect → onUnbind → onDestroy` loop at ~30 cycles/second, building a fresh player and session each time; reporters hit crashes past 1000 registered receivers. Open, reported on 1.6.1 and 1.7.1, **unconfirmed on 1.11.0**. `MediaService.onCreate` registers a `BroadcastReceiver` and requests audio focus, which is the exact shape that gets punished. Reproduce it with a plain `bindService`/`unbindService` instrumentation test before committing to R5 — no DHU needed — and if it still bites, the mitigation is a process-lifetime singleton session/player with nothing registered in `onCreate`.
|
||||||
|
|
||||||
|
**Two `Player`s in one process, in R7.** `playback::init` creates its own `Player` via `Player::spawn_with_device` (`playback.rs:189`). A headless one alongside it means two rodio output streams and two AAudio clients. The `OnceLock<Arc<Player>>` singleton is the fix; getting it wrong is either doubled audio or a silently dead output, and the event pump has a single receiver so it must become part of the same singleton rather than a second consumer.
|
||||||
|
|
||||||
|
**The config-write hazard — the sharpest one in the plan.** `config::load` (`config.rs:306-311`) is `serde_json::from_str(&s).ok()` → `unwrap_or_default()`, with no BOM tolerance, and `save` writes unconditionally. A headless path that used the normal load/save shape and failed to parse would hand back all-defaults and, on the next save, wipe Kyle's library folder setting — the exact failure `memory/config-json-bom-silently-defaults.md` already documents, arriving from a new direction. **The headless browse path must be read-only end to end: parse `config.json` tolerantly, take `download_dir` if present, default to `<filesDir>/Library`, and never call anything that writes.**
|
||||||
|
|
||||||
|
**A second SQLite connection.** `Library::open` runs a `CREATE TABLE IF NOT EXISTS` + `PRAGMA journal_mode = WAL` batch (`crates/library/src/lib.rs:259-262`) — it is a writer, not an opener, and must not be used headlessly. `Connection::open_with_flags(SQLITE_OPEN_READ_ONLY)` is the right shape, but reading a WAL database needs the `-shm` file; same UID so it should create one, and if the WAL holds uncheckpointed content with no writer around it can still fail to open. Prove it in R6, and make failure yield an empty-but-valid tree rather than a head unit stuck on a spinner.
|
||||||
|
|
||||||
|
**The res overlay must be copied in two places.** `build.sh` and `dev.sh`. Miss `dev.sh` and dev builds silently revert to the Tauri logo — a failure with no error message that will be blamed on something else.
|
||||||
|
|
||||||
|
**`npx tauri icon` is a trap, and `src-tauri/icon-source.svg` is a loaded gun.** The CLI regenerates every platform from `default` in one pass — `icon.ico`, `.icns`, the `Square*Logo.png` set — and when `gen/` exists it writes into the gitignored tree rather than `src-tauri/icons/android/`, which is exactly why that tracked directory looks correct while the APK does not. Meanwhile `src-tauri/icon-source.svg` is still the pre-falcon green `#1ed760` waveform, tracked, with a name that invites the one command that would re-brand the app backwards. Worth deleting in passing.
|
||||||
|
|
||||||
|
**`src-tauri/icons/android/` is an attractive nuisance.** It is tracked and on-brand, so it looks like the obvious source — but its `ic_launcher_foreground.png` is full-bleed (alpha bbox = the whole 432×432 canvas) over a `#fff` background. Copied into `gen/` it produces a cropped rounded box on white: a *different* wrong icon that will look plausibly done in a file listing. Confirmed independently — the three candidate `mipmap-xxxhdpi/ic_launcher.png` files (`gen/`, `src-tauri/icons/android/`, kit) have three different md5s, and there is no `mipmap-anydpi-v26` in `gen/` at all.
|
||||||
|
|
||||||
|
**Library resources cannot override app resources.** Putting `mipmap-*/ic_launcher.png` in a plugin crate does nothing — the app module wins for the same name at the same qualifier. Only `mipmap-anydpi-v26` (which the app module does not define) survives, and only with layer names that do not collide with the app's existing `ic_launcher_foreground`. Get this wrong and you get a green build with an unchanged icon.
|
||||||
|
|
||||||
|
**Patch scripts.** `memory/heredoc-regex-patch-trap.md`: a regex inside a heredoc→template-literal patch became a `//` comment and shipped, losing every artist photo. These rounds are patch-script-shaped. Write them with the Write tool, and look at the resulting still.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**What is unverifiable without a car**
|
||||||
|
|
||||||
|
DHU covers more than expected: browse tree rendering, content style, contrast, day/night, driving-restriction states, rotary input, all three resolutions, the ≤2 s button response, error and sign-in cards, and a canned voice query via `mic play`. Everything structural is covered better by the prober.
|
||||||
|
|
||||||
|
Genuinely car-only: **real car audio routing** (DHU keeps audio on the phone — there is no car speaker path, no Bluetooth/USB audio, no car-side focus contention), **OEM launcher skins and quirks**, **wireless projection handover**, and **actual Gemini/Assistant round trips**. Also unverifiable anywhere before a Play release: whether the app is visible to a non-developer at all.
|
||||||
|
|
||||||
|
Which leads to the thing worth saying before the work starts rather than after: **a sideloaded SoulStream will not appear in Android Auto until developer mode and "Unknown sources" are turned on in the phone's Android Auto settings.** Until that tap happens, a completely correct implementation looks identical to a broken one. Real-world visibility needs a Play Store release and the car-app-quality review — a product decision, not an engineering one, and `tauri-plugin-storage`'s `MANAGE_EXTERNAL_STORAGE` is a Play-policy-restricted permission that would need its own answer if that ever comes up.
|
||||||
|
|
||||||
|
Two smaller unknowns worth five minutes each on the phone before R9: whether Android 16's Android Auto overflow still carries "Start head unit server" (every write-up describes Android 9–15 menus), and whether the head unit server can be started from adb at all — if it can, R9 becomes scriptable end to end; if not, it stays a manual tap before every DHU session.
|
||||||
|
|
||||||
|
## Effort
|
||||||
|
|
||||||
|
Sized in sittings of roughly twenty minutes, the unit this project actually ships in.
|
||||||
|
|
||||||
|
| Round | Sittings | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| R1 launcher icon overlay | 1 | + ~1 min of Kyle's phone for the launcher screencap |
|
||||||
|
| R2 glyph, art fallback, splash | 1 | |
|
||||||
|
| R3 mirror grows a queue window | 0.5 | pure additive, zero risk |
|
||||||
|
| R4 **media3 migration** | **3–4** | cannot be split; ships alone and soaks |
|
||||||
|
| R5 discovery + prober | 2 | prober is its own Gradle project |
|
||||||
|
| R6 the tree (JNI + read-only DB) | 2 | |
|
||||||
|
| R7 **cold-start playback** | **3** | singleton player + fallback queue + handover |
|
||||||
|
| R8 search, voice, errors, quality | 2 | |
|
||||||
|
| R9 DHU pass | — | Kyle, 30–45 min, once, not an agent's round |
|
||||||
|
|
||||||
|
**Total: 14–15 sittings of agent work, plus one session of Kyle's with the DHU.**
|
||||||
|
|
||||||
|
The proportions matter more than the total:
|
||||||
|
|
||||||
|
- **The icon is about 15% of this** — two and a half sittings out of fifteen. It is low risk, the assets already exist, it is the most visible change in the whole plan, and it fixes a live shipped defect: the phone is currently wearing Tauri's logo with no adaptive icon at all. It should go first, and it should go first even if Auto is deferred indefinitely.
|
||||||
|
- **Two rounds are half the work.** R4 and R7 together are 6–7 of the 15. Everything else is genuinely sitting-sized. Any estimate that makes those two look like ordinary rounds is wrong, and splitting either one produces a worse outcome — R4 split leaves two sessions fighting, R7 split leaves a headless player racing the app's.
|
||||||
|
- **R3 is the cheapest insurance in the plan.** Half a sitting, no risk, and it removes the single hardest sub-problem from R4 by landing it a round early where the existing card simply ignores it.
|
||||||
|
- **The two open questions that could move these numbers** are both cheap to settle and both should be settled before their round rather than during it: reproduce androidx/media #3158 against 1.11.0 with a plain `bindService`/`unbindService` test before R5 (if it still bites, R5 grows by a sitting for the singleton restructure), and prove a read-only WAL open of `library.db` before R6 (if it fails, R6 needs a different data path and grows by one to two).
|
||||||
|
- **Stopping at Fork 2(a)** — browse only, no cold-start playback — lands at about 9 sittings instead of 15, and makes the prober optional, which would trim another 1–2.
|
||||||
|
|
||||||
|
Nothing here is blocked on anything outside the repo except the DHU install and two settings taps on the phone. The critical path is strictly sequential from R3 onward; R1, R2 and R3 can run in any order or in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decided — 2026-09-15
|
||||||
|
|
||||||
|
The earlier document left the car UI as forks. Kyle's direction was to use the
|
||||||
|
app's own design language and pick. These are the picks, and the mockup at
|
||||||
|
`research/auto-mockup/` is built to them (`CHOSEN` in `auto.js`).
|
||||||
|
|
||||||
|
**First, the correction that reframes all of it.** An earlier pass in this repo
|
||||||
|
mocked a car UI from imagination — a tab strip, header icons, a bottom
|
||||||
|
mini-player, a big-art playback view. None of that exists. Google's line is
|
||||||
|
flat: *"You don't draw your user interface directly on Android Auto; the
|
||||||
|
framework has two well-defined UIs that are created automatically."* The
|
||||||
|
reference frames are saved with this note. What the app actually controls is a
|
||||||
|
short list, and everything below is one of those slots.
|
||||||
|
|
||||||
|
| slot | decision | why |
|
||||||
|
|---|---|---|
|
||||||
|
| **Root tabs** | Recent · Albums · Artists · **Saved** | Playlists loses its tab: the phone holds exactly one ("Imported playlist", 3 tracks), and an empty tab is a dead quarter of the only four we get. **Saved** is the set that plays with the app shut, which is the one thing genuinely different about SoulStream in a car — and the only tab that always works. |
|
||||||
|
| **Content style** | albums **grid**, artists and tracks **list** | Not a density choice: the car fits ~3 either way. It is about what identifies the row. A record is known by its sleeve — that is the app's whole thesis — so albums get art. An artist is known by name, a track only by text, so those get lists where the type is bigger. |
|
||||||
|
| **Playback actions** | **Shuffle · Save** (2 of the 6 allowed) | Shuffle is what a driver reaches for. Save is SoulStream's core verb and the highest-value button on the screen: hear it, save it, it plays cold on the way home. Repeat is rarely touched while driving; Follow is a library action, so it belongs on a browse row, not the transport. |
|
||||||
|
| **Reach signal** | an amber dot on rows that **need the app**, plus the Save button showing a tick when already saved | The dot marks the *risky* row, not the safe one — a warning has to appear where the tap can fail. It also thins out on its own as more music is saved, which is the right direction. Group titles were rejected: on today's library they make one block of 3 and one of 415. |
|
||||||
|
| **Accent** | brand teal `#31C0CB` | The app derives an accent per cover; Auto allows exactly one pair set at build time and it cannot follow the artwork, so it has to be the constant — the falcon's teal from the desktop icon. It tints the FAB and the ring around play/pause, and nothing else. |
|
||||||
|
|
||||||
|
**Deliberately not decided here:** the queue's contents, search ranking and the
|
||||||
|
error copy, all of which want the prober in front of them rather than a guess.
|
||||||
|
|
||||||
|
### What blocks building it
|
||||||
|
|
||||||
|
Nothing in this list can ship before **round 48 (media3)**. The current card is
|
||||||
|
`MediaSessionCompat`; Auto needs a `MediaLibraryService`, and two live sessions
|
||||||
|
is not a safe intermediate. Order is unchanged: 44 → 45 → 46 → 47 → 48, soak,
|
||||||
|
then 49–52 build the table above.
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Generates icon-mock.html from the REAL SoulStream brand-kit sources.
|
||||||
|
// Reads only; never writes into F:/Coding/SoulStream.
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const KIT = 'F:/Coding/SoulStream/branding/soulstream-logo-kit';
|
||||||
|
const ADP = KIT + '/android/adaptive';
|
||||||
|
const OUT = path.dirname(new URL(import.meta.url).pathname.replace(/^\//, ''));
|
||||||
|
|
||||||
|
const read = p => fs.readFileSync(p, 'utf8');
|
||||||
|
|
||||||
|
/* ---------- helpers ---------------------------------------------------- */
|
||||||
|
function attrs(tag) {
|
||||||
|
const o = {};
|
||||||
|
for (const m of tag.matchAll(/([\w:-]+)="([^"]*)"/g)) o[m[1]] = m[2];
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function gradBlock(svg, id) {
|
||||||
|
const i = svg.indexOf('id="' + id + '"');
|
||||||
|
if (i < 0) throw new Error('gradient not found: ' + id);
|
||||||
|
const start = svg.lastIndexOf('<', i);
|
||||||
|
const isRadial = svg.slice(start, i).includes('radialGradient');
|
||||||
|
const close = isRadial ? '</radialGradient>' : '</linearGradient>';
|
||||||
|
const end = svg.indexOf(close, i) + close.length;
|
||||||
|
return { block: svg.slice(start, end), radial: isRadial };
|
||||||
|
}
|
||||||
|
function stopsOf(block) {
|
||||||
|
return [...block.matchAll(/<stop\b[^>]*\/?>/g)].map(m => {
|
||||||
|
const a = attrs(m[0]);
|
||||||
|
const s = { o: Number(a.offset), c: a['stop-color'] };
|
||||||
|
if (a['stop-opacity'] != null) s.a = Number(a['stop-opacity']);
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function linOf(svg, id) {
|
||||||
|
const { block } = gradBlock(svg, id);
|
||||||
|
const a = attrs(block.slice(0, block.indexOf('>')));
|
||||||
|
return { x1: +a.x1, y1: +a.y1, x2: +a.x2, y2: +a.y2, stops: stopsOf(block) };
|
||||||
|
}
|
||||||
|
function radOf(svg, id) {
|
||||||
|
const { block } = gradBlock(svg, id);
|
||||||
|
const a = attrs(block.slice(0, block.indexOf('>')));
|
||||||
|
const gt = a.gradientTransform || '';
|
||||||
|
const tr = /translate\(\s*([-\d.]+)[ ,]+([-\d.]+)\s*\)/.exec(gt);
|
||||||
|
const ro = /rotate\(\s*([-\d.]+)\s*\)/.exec(gt);
|
||||||
|
const sc = /scale\(\s*([-\d.]+)\s*\)/.exec(gt);
|
||||||
|
if (!tr || !sc) throw new Error('unexpected gradientTransform: ' + gt);
|
||||||
|
return { cx: +tr[1], cy: +tr[2], rot: ro ? +ro[1] : 0, r: +sc[1], stops: stopsOf(block) };
|
||||||
|
}
|
||||||
|
const r6 = n => Math.round(n * 1e6) / 1e6;
|
||||||
|
function scaleLin(g, s, off) {
|
||||||
|
return { x1: r6(g.x1 * s + off), y1: r6(g.y1 * s + off),
|
||||||
|
x2: r6(g.x2 * s + off), y2: r6(g.y2 * s + off), stops: g.stops };
|
||||||
|
}
|
||||||
|
function scaleRad(g, s, off) {
|
||||||
|
return { cx: r6(g.cx * s + off), cy: r6(g.cy * s + off), rot: g.rot, r: r6(g.r * s), stops: g.stops };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- read the real layers --------------------------------------- */
|
||||||
|
const fgSvg = read(ADP + '/ic_launcher_foreground.svg');
|
||||||
|
const bgSvg = read(ADP + '/ic_launcher_background.svg');
|
||||||
|
const monoSvg = read(ADP + '/ic_launcher_monochrome.svg');
|
||||||
|
const conSvg = read('F:/Coding/SoulStream/src-tauri/icons/icon.svg');
|
||||||
|
|
||||||
|
const fgTag = /<path\b[^>]*>/.exec(fgSvg)[0];
|
||||||
|
const monoTag = /<path\b[^>]*>/.exec(monoSvg)[0];
|
||||||
|
const fgA = attrs(fgTag), monoA = attrs(monoTag);
|
||||||
|
|
||||||
|
if (fgA.d !== monoA.d) console.warn('WARN: monochrome path d differs from foreground');
|
||||||
|
else console.log('ok monochrome path d is byte-identical to the foreground path');
|
||||||
|
if (fgA.transform !== monoA.transform) console.warn('WARN: transforms differ');
|
||||||
|
else console.log('ok both layers share transform:', fgA.transform);
|
||||||
|
|
||||||
|
const flat = attrs(/<rect\b[^>]*>/.exec(bgSvg)[0]).fill;
|
||||||
|
console.log('ok kit adaptive background =', flat);
|
||||||
|
|
||||||
|
// transform: translate(a b) scale(s) translate(c d)
|
||||||
|
const tm = /translate\(([-\d.]+)\s+([-\d.]+)\)\s*scale\(([-\d.]+)\)\s*translate\(([-\d.]+)\s+([-\d.]+)\)/
|
||||||
|
.exec(fgA.transform);
|
||||||
|
if (!tm) throw new Error('unexpected foreground transform');
|
||||||
|
const tParsed = [ +tm[1], +tm[2], +tm[3], +tm[4], +tm[5] ];
|
||||||
|
console.log('ok optical offset preserved verbatim: translate(' + tm[4] + ' ' + tm[5] + ')');
|
||||||
|
|
||||||
|
const fgGrad = linOf(fgSvg, 'adaptive-gradient');
|
||||||
|
const surf1024 = linOf(conSvg, 'rounded-4-surface-gradient');
|
||||||
|
const glow1024 = radOf(conSvg, 'rounded-4-glow-gradient');
|
||||||
|
console.log('ok concept 04 surface axis :', JSON.stringify(surf1024).slice(0, 120));
|
||||||
|
console.log('ok concept 04 glow : centre', glow1024.cx + ',' + glow1024.cy,
|
||||||
|
'r', glow1024.r, 'peak alpha', glow1024.stops[0].a, glow1024.stops[0].c);
|
||||||
|
|
||||||
|
const S_LAYER = 108 / 1024; // B: 1024 artwork spans the whole 108 layer
|
||||||
|
const S_MASK = 72 / 1024; // C: 1024 artwork spans only the visible 72 window
|
||||||
|
|
||||||
|
const gradL = { surf: scaleLin(surf1024, S_LAYER, 0), glow: scaleRad(glow1024, S_LAYER, 0) };
|
||||||
|
const gradM = { surf: scaleLin(surf1024, S_MASK, 18), glow: scaleRad(glow1024, S_MASK, 18) };
|
||||||
|
|
||||||
|
/* ---------- composite sampling ----------------------------------------- */
|
||||||
|
const hex2rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const rgb2hex = c => '#' + c.map(v => Math.round(Math.max(0, Math.min(255, v)))
|
||||||
|
.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||||
|
function sampleStops(stops, t) {
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
let a = stops[0], b = stops[stops.length - 1];
|
||||||
|
for (let i = 0; i < stops.length - 1; i++)
|
||||||
|
if (t >= stops[i].o && t <= stops[i + 1].o) { a = stops[i]; b = stops[i + 1]; break; }
|
||||||
|
const f = b.o === a.o ? 0 : (t - a.o) / (b.o - a.o);
|
||||||
|
const ca = hex2rgb(a.c), cb = hex2rgb(b.c);
|
||||||
|
const al = (a.a == null ? 1 : a.a), bl = (b.a == null ? 1 : b.a);
|
||||||
|
return { rgb: ca.map((v, i) => v + f * (cb[i] - v)), a: al + f * (bl - al) };
|
||||||
|
}
|
||||||
|
function linT(g, p) {
|
||||||
|
const vx = g.x2 - g.x1, vy = g.y2 - g.y1;
|
||||||
|
return ((p[0] - g.x1) * vx + (p[1] - g.y1) * vy) / (vx * vx + vy * vy);
|
||||||
|
}
|
||||||
|
function composite(v, p) {
|
||||||
|
if (v === 'flat') return rgb2hex(hex2rgb(flat));
|
||||||
|
const g = v === 'gradL' ? gradL : gradM;
|
||||||
|
const s = sampleStops(g.surf.stops, linT(g.surf, p)).rgb;
|
||||||
|
const d = Math.hypot(p[0] - g.glow.cx, p[1] - g.glow.cy);
|
||||||
|
const gl = sampleStops(g.glow.stops, d / g.glow.r);
|
||||||
|
return rgb2hex(s.map((v2, i) => gl.rgb[i] * gl.a + v2 * (1 - gl.a)));
|
||||||
|
}
|
||||||
|
const R = 36 / Math.SQRT2;
|
||||||
|
const POINTS = [
|
||||||
|
['visible NW corner (18, 18)', [18, 18]],
|
||||||
|
['circle NW rim (' + r6(54 - R).toFixed(1) + ', ' + r6(54 - R).toFixed(1) + ')', [54 - R, 54 - R]],
|
||||||
|
['mask centre (54, 54)', [54, 54]],
|
||||||
|
['circle SE rim (' + r6(54 + R).toFixed(1) + ', ' + r6(54 + R).toFixed(1) + ')', [54 + R, 54 + R]],
|
||||||
|
['visible SE corner (90, 90)', [90, 90]]
|
||||||
|
];
|
||||||
|
const samples = POINTS.map(([where, p]) => ({
|
||||||
|
where, v: ['flat', 'gradL', 'gradM'].map(v => composite(v, p))
|
||||||
|
}));
|
||||||
|
console.log('\n point A flat B layer C mask');
|
||||||
|
for (const s of samples) console.log(' ' + s.where.padEnd(34) + s.v.join(' '));
|
||||||
|
|
||||||
|
/* ---------- shipping raster -------------------------------------------- */
|
||||||
|
const pngPath = 'F:/Coding/SoulStream/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png';
|
||||||
|
const todayPng = 'data:image/png;base64,' + fs.readFileSync(pngPath).toString('base64');
|
||||||
|
console.log('\nok shipping raster embedded:', pngPath);
|
||||||
|
|
||||||
|
/* ---------- emit -------------------------------------------------------- */
|
||||||
|
const DATA = {
|
||||||
|
flat,
|
||||||
|
fgPath: { d: fgA.d, t: fgA.transform, tParsed },
|
||||||
|
monoPath: { d: monoA.d, t: monoA.transform },
|
||||||
|
fgGrad, gradL, gradM, samples, todayPng,
|
||||||
|
themed: {
|
||||||
|
light: { ground: '#DCE3E9', ink: '#44515E' },
|
||||||
|
dark: { ground: '#2A3138', ink: '#C6D3DF' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tpl = read(path.join(OUT, 'template.html'));
|
||||||
|
if (!tpl.includes('/*__DATA__*/null')) throw new Error('template marker missing');
|
||||||
|
const html = tpl.replace('/*__DATA__*/null', JSON.stringify(DATA));
|
||||||
|
const dest = path.join(OUT, 'icon-mock.html');
|
||||||
|
fs.writeFileSync(dest, html, 'utf8');
|
||||||
|
console.log('ok wrote', dest, (html.length / 1024).toFixed(1) + ' KB');
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>SoulStream Android icon — flat vs concept 04 gradient</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--ui-bg:#0e1013; --ui-panel:#161a1f; --ui-line:#252b33;
|
||||||
|
--ui-fg:#d2d9e1; --ui-dim:#828e9b; --ui-hi:#6fd3e0;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;background:var(--ui-bg);color:var(--ui-fg);
|
||||||
|
font:13px/1.45 ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||||
|
a{color:var(--ui-hi)}
|
||||||
|
.wrap{max-width:1180px;margin:0 auto;padding:18px 20px 60px}
|
||||||
|
|
||||||
|
header.top{display:flex;flex-wrap:wrap;align-items:baseline;gap:10px 16px;margin-bottom:14px}
|
||||||
|
header.top h1{font-size:15px;font-weight:650;margin:0;letter-spacing:.2px}
|
||||||
|
header.top .sub{font-size:12px;color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.bar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;gap:8px 18px;
|
||||||
|
align-items:center;padding:10px 12px;margin:0 0 18px;border:1px solid var(--ui-line);
|
||||||
|
border-radius:10px;background:rgba(16,19,23,.94);backdrop-filter:blur(6px)}
|
||||||
|
.grp{display:flex;align-items:center;gap:7px}
|
||||||
|
.grp>label{font-size:10.5px;text-transform:uppercase;letter-spacing:.9px;color:var(--ui-dim)}
|
||||||
|
.seg{display:flex;border:1px solid var(--ui-line);border-radius:7px;overflow:hidden}
|
||||||
|
.seg button{appearance:none;border:0;background:transparent;color:var(--ui-dim);
|
||||||
|
font:inherit;font-size:12px;padding:5px 9px;cursor:pointer;white-space:nowrap}
|
||||||
|
.seg button+button{border-left:1px solid var(--ui-line)}
|
||||||
|
.seg button:hover{color:var(--ui-fg)}
|
||||||
|
.seg button[aria-pressed="true"]{background:#232c35;color:#eaf3fa}
|
||||||
|
.chk{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-dim);cursor:pointer}
|
||||||
|
|
||||||
|
h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:1.2px;color:var(--ui-dim);
|
||||||
|
margin:26px 0 9px;font-weight:600}
|
||||||
|
|
||||||
|
.band{border-radius:12px;padding:22px 18px 16px;margin-bottom:12px;position:relative;overflow:hidden}
|
||||||
|
.band .bandname{position:absolute;top:8px;left:12px;font-size:10px;letter-spacing:1.1px;
|
||||||
|
text-transform:uppercase;opacity:.55;font-weight:600}
|
||||||
|
.band.dark{background:radial-gradient(125% 105% at 18% -5%,#2e3339 0%,#191c21 52%,#0a0b0d 100%)}
|
||||||
|
.band.dark .bandname{color:#fff}
|
||||||
|
.band.light{background:radial-gradient(125% 105% at 18% -5%,#fdfaf5 0%,#ece5db 52%,#d5cdc1 100%)}
|
||||||
|
.band.light .bandname{color:#2b2722}
|
||||||
|
.band.neutral{background:#12151a;border:1px solid var(--ui-line)}
|
||||||
|
.band.neutral .bandname{color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.cols{display:flex;flex-wrap:wrap;gap:26px;align-items:flex-start;justify-content:center}
|
||||||
|
.col{display:flex;flex-direction:column;align-items:center;gap:9px;min-width:104px}
|
||||||
|
.col.today{opacity:.92}
|
||||||
|
.tilebox{display:flex;align-items:center;justify-content:center;min-height:0}
|
||||||
|
.tag{font-size:12px;font-weight:700;letter-spacing:.4px;text-align:center}
|
||||||
|
.tagsub{font-size:10.5px;line-height:1.35;text-align:center;max-width:150px}
|
||||||
|
.band.dark .tag{color:#eef4fa}.band.dark .tagsub{color:#9aa6b3}
|
||||||
|
.band.light .tag{color:#221f1b}.band.light .tagsub{color:#6a635a}
|
||||||
|
.band.neutral .tag{color:#eef4fa}.band.neutral .tagsub{color:var(--ui-dim)}
|
||||||
|
|
||||||
|
.divider{width:1px;align-self:stretch;background:currentColor;opacity:.16;margin:0 2px}
|
||||||
|
|
||||||
|
.ladder{display:flex;flex-direction:column;gap:14px}
|
||||||
|
.lrow{display:flex;align-items:flex-end;gap:26px;justify-content:center}
|
||||||
|
.lcell{display:flex;flex-direction:column;align-items:center;gap:6px}
|
||||||
|
.lcell .sz{font-size:10px;color:var(--ui-dim)}
|
||||||
|
.lhead{display:flex;gap:26px;justify-content:center;margin-bottom:2px}
|
||||||
|
.lhead span{width:118px;text-align:center;font-size:11px;font-weight:700;letter-spacing:.4px}
|
||||||
|
|
||||||
|
.note{border:1px solid var(--ui-line);border-radius:10px;padding:14px 16px;background:var(--ui-panel);
|
||||||
|
font-size:12.5px;color:#b6c0cb;margin-top:14px}
|
||||||
|
.note b{color:#e6eef6;font-weight:650}
|
||||||
|
.note p{margin:0 0 9px}.note p:last-child{margin:0}
|
||||||
|
table.m{border-collapse:collapse;font:11.5px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;margin-top:4px}
|
||||||
|
table.m th,table.m td{border:1px solid var(--ui-line);padding:4px 8px;text-align:left}
|
||||||
|
table.m th{color:var(--ui-dim);font-weight:600}
|
||||||
|
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:6px;
|
||||||
|
border:1px solid rgba(255,255,255,.18)}
|
||||||
|
.hidden{display:none}
|
||||||
|
.banner{border-left:2px solid var(--ui-hi);padding:8px 0 8px 12px;margin:0 0 14px;
|
||||||
|
font-size:12.5px;color:#b6c0cb}
|
||||||
|
.banner b{color:#e6eef6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<h1>SoulStream Android launcher icon — background layer decision</h1>
|
||||||
|
<span class="sub">real kit layers · true 108/72 adaptive geometry · nothing here is shipped</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<div class="grp"><label>Mask</label><div class="seg" id="segMask"></div></div>
|
||||||
|
<div class="grp"><label>Size</label><div class="seg" id="segSize"></div></div>
|
||||||
|
<div class="grp"><label>Mode</label><div class="seg" id="segMode"></div></div>
|
||||||
|
<label class="chk"><input type="checkbox" id="cbShadow" checked> launcher shadow</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="cbSafe"> safe-zone guides</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stdBanner" class="banner">
|
||||||
|
<b>A</b> is the kit as delivered. <b>B</b> and <b>C</b> are the same concept-04 gradient, differing only in
|
||||||
|
how the 1024 artwork is mapped onto the 108-unit layer. Reply with a letter.
|
||||||
|
</div>
|
||||||
|
<div id="thmBanner" class="banner hidden">
|
||||||
|
<b>Themed icons discard the background layer entirely.</b> Android 13+ draws only the
|
||||||
|
<code><monochrome></code> drawable, tinted, on a wallpaper-derived system ground — so A, B and C are
|
||||||
|
pixel-identical here. The decision below is not a background decision; it is only whether the monochrome
|
||||||
|
layer exists at all.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="stage"></div>
|
||||||
|
|
||||||
|
<h2 class="sec">Size ladder — same mask, same wallpaper</h2>
|
||||||
|
<div class="band neutral"><span class="bandname">Neutral ground</span><div id="ladder" class="ladder"></div></div>
|
||||||
|
|
||||||
|
<div class="note" id="notes"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const D = {"flat":"#05080B","fgPath":{"d":"M 471.889 863.531 C 470.033 861.073 467.485 856.812 466.226 854.062 C 464.398 850.070 463.857 847.418 463.544 840.920 C 463.249 834.800 463.512 831.540 464.601 827.793 C 466.399 821.609 469.407 815.555 473.409 810.066 C 476.304 806.095 476.482 805.474 476.562 799.067 C 476.630 793.639 477.162 790.964 479.217 785.732 C 483.406 775.062 489.001 766.958 499.269 756.689 C 504.437 751.520 515.049 742.428 522.967 736.383 C 530.854 730.363 540.573 722.201 544.565 718.246 C 548.557 714.290 553.986 707.799 556.630 703.821 C 566.285 689.294 570.061 677.926 570.109 663.251 C 570.167 645.247 564.288 631.978 550.406 618.778 C 543.711 612.413 537.141 607.939 527.046 602.871 C 511.185 594.907 499.224 590.830 464.632 581.594 C 429.650 572.254 407.140 565.108 384.291 556.087 C 369.696 550.324 349.615 540.740 338.462 534.213 C 320.047 523.435 302.208 509.772 290.291 497.319 C 282.487 489.163 272.391 476.452 268.043 469.309 C 260.079 456.225 252.284 438.708 251.281 431.640 C 250.907 429.006 251.062 428.044 251.860 428.044 C 252.460 428.044 257.063 430.702 262.090 433.950 C 279.955 445.495 298.689 456.033 313.781 463.025 L 321.990 466.828 L 315.175 459.916 C 306.482 451.099 297.522 440.054 290.373 429.342 C 283.846 419.562 275.156 402.180 271.326 391.240 C 268.303 382.605 266.682 375.526 264.934 363.320 C 263.614 354.107 263.278 340.053 264.377 340.053 C 264.755 340.053 271.343 346.050 279.018 353.379 C 305.154 378.337 323.767 394.790 344.545 411.305 C 357.025 421.223 379.468 437.524 391.447 445.369 C 421.469 465.031 452.755 481.034 483.387 492.398 C 493.465 496.137 514.890 502.670 542.833 510.526 C 553.736 513.591 567.823 518.023 574.138 520.375 C 589.578 526.124 605.702 534.093 616.621 541.371 C 626.525 547.973 639.041 559.257 644.889 566.856 C 650.348 573.951 654.995 583.053 657.601 591.759 C 659.560 598.303 659.892 600.978 659.957 610.795 C 660.037 622.827 659.257 627.864 655.632 638.715 C 653.007 646.577 649.606 653.442 644.525 661.136 C 642.375 664.394 640.265 667.630 639.838 668.328 C 639.410 669.026 642.266 666.701 646.183 663.161 C 658.976 651.602 669.377 635.863 674.360 620.525 C 680.887 600.431 681.418 582.436 676.073 562.442 C 672.127 547.683 662.390 530.337 650.734 517.305 C 640.568 505.936 625.125 494.022 607.956 484.301 C 589.833 474.040 573.381 467.503 530.216 453.412 C 512.773 447.718 493.356 440.996 487.066 438.473 C 474.757 433.536 464.611 428.344 455.790 422.469 C 449.119 418.025 438.217 408.277 432.454 401.601 C 422.380 389.933 413.615 372.247 410.336 356.975 C 409.168 351.535 408.804 346.280 408.840 335.400 C 408.881 323.303 409.202 319.739 410.863 312.979 C 414.453 298.361 419.429 287.287 428.148 274.505 C 434.444 265.277 446.706 252.323 456.519 244.535 C 483.937 222.774 516.119 207.844 564.045 194.653 C 579.471 190.408 592.384 187.409 620.816 181.469 C 654.191 174.497 674.652 168.373 699.416 157.943 C 728.416 145.729 764.550 125.102 787.619 107.593 C 791.507 104.642 792.482 104.208 792.476 105.431 C 792.471 106.273 791.534 108.965 790.394 111.414 C 785.297 122.364 772.485 139.362 759.953 151.803 C 750.130 161.555 738.729 170.675 725.744 179.167 C 715.779 185.684 694.819 196.809 683.162 201.768 C 680.040 203.097 677.586 204.285 677.710 204.409 C 678.135 204.834 706.608 197.159 714.223 194.567 C 728.618 189.666 742.169 183.962 756.021 176.972 C 763.653 173.121 770.077 170.148 770.294 170.366 C 771.203 171.274 761.797 182.974 752.317 192.729 C 741.008 204.364 729.933 213.624 716.943 222.304 C 698.650 234.528 680.678 243.412 657.959 251.461 C 642.229 257.034 624.511 261.607 598.311 266.858 C 587.608 269.003 574.363 271.878 568.878 273.248 C 555.300 276.639 537.612 282.530 526.395 287.397 C 507.859 295.440 490.988 307.165 482.040 318.224 C 479.729 321.080 476.651 325.830 475.199 328.779 C 473.747 331.728 471.958 337.085 471.225 340.682 C 470.121 346.096 470.058 348.298 470.860 353.458 C 472.774 365.768 478.100 375.203 489.472 386.432 C 502.963 399.753 527.921 414.250 558.969 426.799 C 566.647 429.902 581.496 435.667 591.966 439.609 C 638.284 457.049 660.959 467.018 682.072 479.225 C 693.287 485.709 708.151 495.840 717.893 503.640 C 729.475 512.914 743.275 526.023 750.422 534.542 C 762.716 549.194 773.237 567.193 778.922 583.298 C 782.840 594.397 785.607 607.564 786.657 620.110 C 787.335 628.206 787.324 633.770 786.618 641.059 C 783.472 673.507 771.319 700.824 748.857 725.937 C 745.773 729.385 740.336 734.824 736.774 738.023 C 710.587 761.547 675.649 780.088 633.589 792.780 C 625.820 795.125 606.899 800.277 591.543 804.231 C 558.928 812.627 544.532 816.705 530.869 821.417 C 510.985 828.275 497.176 835.191 487.628 843.074 C 480.759 848.745 477.646 854.394 477.088 862.205 C 476.860 865.392 476.356 868.000 475.968 868.000 C 475.580 868.000 473.745 865.989 471.889 863.531 Z M 507.752 803.633 C 514.224 802.436 520.624 799.550 524.691 795.993 C 526.496 794.414 530.427 789.316 533.427 784.662 C 536.427 780.009 539.690 775.298 540.676 774.193 L 542.471 772.184 L 540.355 772.Line truncated
|
||||||
|
|
||||||
|
/* ---------- Android adaptive geometry ----------------------------------
|
||||||
|
Layers are 108x108 dp. The launcher mask shows only the central 72x72 dp,
|
||||||
|
i.e. the layer is drawn at 1.5x the on-screen icon size and centre-cropped.
|
||||||
|
Every tile below is an <svg viewBox="18 18 72 72"> over 108-unit artwork,
|
||||||
|
which reproduces exactly that. The 66 dp circle is the content safe zone.
|
||||||
|
------------------------------------------------------------------------*/
|
||||||
|
const VIEWBOX = '18 18 72 72';
|
||||||
|
|
||||||
|
/* Mask outlines in a 0..100 box, mapped into the visible 72 window. */
|
||||||
|
const MASKS = {
|
||||||
|
circle: {label:'Circle', d:'M50,0 A50,50 0 1,1 50,100 A50,50 0 1,1 50,0 Z'},
|
||||||
|
squircle: {label:'Squircle', d:'M50,0 C10.05,0 0,10.05 0,50 C0,89.95 10.05,100 50,100 C89.95,100 100,89.95 100,50 C100,10.05 89.95,0 50,0 Z'},
|
||||||
|
rsquare: {label:'Rounded square',d:'M20,0 L80,0 A20,20 0 0,1 100,20 L100,80 A20,20 0 0,1 80,100 L20,100 A20,20 0 0,1 0,80 L0,20 A20,20 0 0,1 20,0 Z'},
|
||||||
|
teardrop: {label:'Teardrop (aggressive OEM)',d:'M0,20 A20,20 0 0,1 20,0 L50,0 A50,50 0 1,1 0,50 Z'}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZES = [
|
||||||
|
{px:24, cap:'24 dp · notification'},
|
||||||
|
{px:32, cap:'32 dp · recents chip'},
|
||||||
|
{px:48, cap:'48 dp · launcher grid'},
|
||||||
|
{px:64, cap:'64 dp · folder / tablet'},
|
||||||
|
{px:112, cap:'112 dp · inspect'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const VARIANTS = [
|
||||||
|
{id:'today', tag:'TODAY', sub:'shipping raster — Tauri placeholder on a white plate', today:true},
|
||||||
|
{id:'flat', tag:'A — FLAT', sub:'kit as delivered · solid ' + D.flat},
|
||||||
|
{id:'gradL', tag:'B — GRADIENT', sub:'concept 04 mapped 1024 → the whole 108 layer'},
|
||||||
|
{id:'gradM', tag:'C — GRADIENT', sub:'concept 04 mapped 1024 → the visible 72 window'}
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- defs ------------------------------------------------------ */
|
||||||
|
function stopsSVG(stops){
|
||||||
|
return stops.map(s =>
|
||||||
|
'<stop offset="'+s.o+'" stop-color="'+s.c+'"'+(s.a!=null?' stop-opacity="'+s.a+'"':'')+'/>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
function linSVG(id,g){
|
||||||
|
return '<linearGradient id="'+id+'" gradientUnits="userSpaceOnUse" color-interpolation="sRGB" '+
|
||||||
|
'x1="'+g.x1+'" y1="'+g.y1+'" x2="'+g.x2+'" y2="'+g.y2+'">'+stopsSVG(g.stops)+'</linearGradient>';
|
||||||
|
}
|
||||||
|
function radSVG(id,g){
|
||||||
|
return '<radialGradient id="'+id+'" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" '+
|
||||||
|
'gradientTransform="translate('+g.cx+' '+g.cy+') rotate('+g.rot+') scale('+g.r+')">'+
|
||||||
|
stopsSVG(g.stops)+'</radialGradient>';
|
||||||
|
}
|
||||||
|
function buildDefs(){
|
||||||
|
let s = '<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>';
|
||||||
|
s += linSVG('fgGrad', D.fgGrad);
|
||||||
|
s += linSVG('surfL', D.gradL.surf) + radSVG('glowL', D.gradL.glow);
|
||||||
|
s += linSVG('surfM', D.gradM.surf) + radSVG('glowM', D.gradM.glow);
|
||||||
|
for (const k in MASKS){
|
||||||
|
s += '<clipPath id="clip-'+k+'" clipPathUnits="userSpaceOnUse">'+
|
||||||
|
'<path transform="translate(18,18) scale(0.72)" d="'+MASKS[k].d+'"/></clipPath>';
|
||||||
|
}
|
||||||
|
s += '</defs></svg>';
|
||||||
|
const el = document.createElement('div'); el.innerHTML = s; document.body.appendChild(el.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- layers ---------------------------------------------------- */
|
||||||
|
const FG = '<path d="'+D.fgPath.d+'" fill="url(#fgGrad)" fill-rule="evenodd" transform="'+D.fgPath.t+'"/>';
|
||||||
|
function MONO(fillVar){
|
||||||
|
return '<path d="'+D.monoPath.d+'" fill="'+fillVar+'" fill-rule="evenodd" transform="'+D.monoPath.t+'"/>';
|
||||||
|
}
|
||||||
|
const RECT = '<rect x="0" y="0" width="108" height="108" ';
|
||||||
|
|
||||||
|
function layers(id){
|
||||||
|
switch(id){
|
||||||
|
case 'flat': return RECT+'fill="'+D.flat+'"/>' + FG;
|
||||||
|
case 'gradL': return RECT+'fill="url(#surfL)"/>' + RECT+'fill="url(#glowL)"/>' + FG;
|
||||||
|
case 'gradM': return RECT+'fill="url(#surfM)"/>' + RECT+'fill="url(#glowM)"/>' + FG;
|
||||||
|
case 'today': /* Launcher3 wraps a legacy raster: corner-extracted plate + icon at ~0.7 */
|
||||||
|
return RECT+'fill="#ffffff"/>' +
|
||||||
|
'<image href="'+D.todayPng+'" x="28.8" y="28.8" width="50.4" height="50.4" '+
|
||||||
|
'preserveAspectRatio="xMidYMid meet"/>';
|
||||||
|
case 'monoLight': return RECT+'fill="'+D.themed.light.ground+'"/>' + MONO(D.themed.light.ink);
|
||||||
|
case 'monoDark': return RECT+'fill="'+D.themed.dark.ground +'"/>' + MONO(D.themed.dark.ink);
|
||||||
|
case 'todayThemed': return layers('today');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE = '<g fill="none" stroke="#ff4d6d" stroke-opacity=".85">'+
|
||||||
|
'<circle cx="54" cy="54" r="33" stroke-width=".6" stroke-dasharray="2 2"/>'+
|
||||||
|
'<rect x="18" y="18" width="72" height="72" stroke-width=".6" stroke-opacity=".45"/></g>';
|
||||||
|
|
||||||
|
function tile(variantId, px, mask){
|
||||||
|
const sh = state.shadow
|
||||||
|
? 'filter:drop-shadow(0 '+(px/48).toFixed(2)+'px '+(px/26).toFixed(2)+'px rgba(0,0,0,.30)) '+
|
||||||
|
'drop-shadow(0 0 '+(px/40).toFixed(2)+'px rgba(0,0,0,.14));' : '';
|
||||||
|
return '<svg class="tile" width="'+px+'" height="'+px+'" viewBox="'+VIEWBOX+'" style="'+sh+'display:block">'+
|
||||||
|
'<g clip-path="url(#clip-'+mask+')">'+layers(variantId)+(state.safe?SAFE:'')+'</g></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- state + render -------------------------------------------- */
|
||||||
|
const state = {mask:'circle', size:48, mode:'standard', shadow:true, safe:false};
|
||||||
|
|
||||||
|
/* the hash makes any view reproducible: #mask=teardrop&size=112&mode=themed */
|
||||||
|
(function readHash(){
|
||||||
|
for (const kv of location.hash.replace(/^#/,'').split('&')){
|
||||||
|
const [k,v] = kv.split('=');
|
||||||
|
if (k === 'mask' && MASKS[v]) state.mask = v;
|
||||||
|
if (k === 'size' && SIZES.some(s => s.px === +v)) state.size = +v;
|
||||||
|
if (k === 'mode' && (v === 'themed' || v === 'standard')) state.mode = v;
|
||||||
|
if (k === 'shadow') state.shadow = v !== '0';
|
||||||
|
if (k === 'safe') state.safe = v === '1';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
function writeHash(){
|
||||||
|
history.replaceState(null,'','#mask='+state.mask+'&size='+state.size+'&mode='+state.mode+
|
||||||
|
'&shadow='+(state.shadow?1:0)+'&safe='+(state.safe?1:0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seg(host, items, key, onpick){
|
||||||
|
host.innerHTML = items.map(i =>
|
||||||
|
'<button data-v="'+i.v+'" aria-pressed="'+(state[key]===i.v)+'">'+i.t+'</button>').join('');
|
||||||
|
host.onclick = e => {
|
||||||
|
const b = e.target.closest('button'); if(!b) return;
|
||||||
|
state[key] = items.find(i => String(i.v) === b.dataset.v).v;
|
||||||
|
[...host.children].forEach(c => c.setAttribute('aria-pressed', c === b));
|
||||||
|
onpick && onpick(); render(); writeHash();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colHTML(v, px){
|
||||||
|
return '<div class="col'+(v.today?' today':'')+'">'+
|
||||||
|
'<div class="tilebox">'+tile(v.id, px, state.mask)+'</div>'+
|
||||||
|
'<div class="tag">'+v.tag+'</div><div class="tagsub">'+v.sub+'</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandStandard(cls, name){
|
||||||
|
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
|
||||||
|
h += colHTML(VARIANTS[0], state.size);
|
||||||
|
h += '<div class="divider"></div>';
|
||||||
|
for (let i=1;i<VARIANTS.length;i++) h += colHTML(VARIANTS[i], state.size);
|
||||||
|
return h + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandThemed(cls, name, which){
|
||||||
|
const t = which === 'light' ? D.themed.light : D.themed.dark;
|
||||||
|
const cols = [
|
||||||
|
{id:'todayThemed', tag:'TODAY', sub:'no monochrome layer — the launcher leaves it full-colour among themed neighbours', today:true},
|
||||||
|
{id: which==='light'?'monoLight':'monoDark', tag:'A / B / C', sub:'kit monochrome, tinted '+t.ink+' on system ground '+t.ground}
|
||||||
|
];
|
||||||
|
let h = '<div class="band '+cls+'"><span class="bandname">'+name+'</span><div class="cols">';
|
||||||
|
h += colHTML(cols[0], state.size) + '<div class="divider"></div>' + colHTML(cols[1], state.size);
|
||||||
|
return h + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(){
|
||||||
|
const stage = document.getElementById('stage');
|
||||||
|
const themed = state.mode === 'themed';
|
||||||
|
document.getElementById('stdBanner').classList.toggle('hidden', themed);
|
||||||
|
document.getElementById('thmBanner').classList.toggle('hidden', !themed);
|
||||||
|
|
||||||
|
if (!themed){
|
||||||
|
stage.innerHTML = bandStandard('dark','Dark wallpaper') + bandStandard('light','Light wallpaper');
|
||||||
|
} else {
|
||||||
|
stage.innerHTML = bandThemed('light','Light theme · wallpaper-derived ground','light') +
|
||||||
|
bandThemed('dark','Dark theme · wallpaper-derived ground','dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = themed
|
||||||
|
? [{id:'todayThemed',tag:'TODAY'},{id:'monoLight',tag:'THEMED · LIGHT'},{id:'monoDark',tag:'THEMED · DARK'}]
|
||||||
|
: VARIANTS;
|
||||||
|
let lh = '<div class="lhead">' + list.map(v=>'<span>'+v.tag+'</span>').join('') + '</div>';
|
||||||
|
for (const s of SIZES){
|
||||||
|
lh += '<div class="lrow">' + list.map(v =>
|
||||||
|
'<div class="lcell" style="width:118px"><div class="tilebox">'+tile(v.id, s.px, state.mask)+
|
||||||
|
'</div><div class="sz">'+s.cap+'</div></div>').join('') + '</div>';
|
||||||
|
}
|
||||||
|
document.getElementById('ladder').innerHTML = lh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- measurement: how much background does the falcon leave? ---- */
|
||||||
|
function coverage(maskKey){
|
||||||
|
const N = 512, c = document.createElement('canvas');
|
||||||
|
c.width = c.height = N; const x = c.getContext('2d');
|
||||||
|
const k = N/72; // 72 visible units -> N px
|
||||||
|
// mask
|
||||||
|
x.save(); x.scale(k,k); x.translate(-18,-18);
|
||||||
|
x.translate(18,18); x.scale(0.72,0.72);
|
||||||
|
x.fill(new Path2D(MASKS[maskKey].d)); x.restore();
|
||||||
|
const maskData = x.getImageData(0,0,N,N).data;
|
||||||
|
let maskPx = 0; for (let i=3;i<maskData.length;i+=4) if (maskData[i]>127) maskPx++;
|
||||||
|
// falcon inside the mask
|
||||||
|
x.globalCompositeOperation = 'source-in';
|
||||||
|
x.save(); x.scale(k,k); x.translate(-18,-18);
|
||||||
|
const t = D.fgPath.tParsed; // [tx,ty,s,tx2,ty2]
|
||||||
|
x.translate(t[0],t[1]); x.scale(t[2],t[2]); x.translate(t[3],t[4]);
|
||||||
|
x.fill(new Path2D(D.fgPath.d),'evenodd'); x.restore();
|
||||||
|
const fgData = x.getImageData(0,0,N,N).data;
|
||||||
|
let fgPx = 0; for (let i=3;i<fgData.length;i+=4) if (fgData[i]>127) fgPx++;
|
||||||
|
// the falcon on its own, to see how much the mask shaves off
|
||||||
|
const c2 = document.createElement('canvas'); c2.width = c2.height = N;
|
||||||
|
const y = c2.getContext('2d');
|
||||||
|
y.save(); y.scale(k,k); y.translate(-18,-18);
|
||||||
|
y.translate(t[0],t[1]); y.scale(t[2],t[2]); y.translate(t[3],t[4]);
|
||||||
|
y.fill(new Path2D(D.fgPath.d),'evenodd'); y.restore();
|
||||||
|
const allData = y.getImageData(0,0,N,N).data;
|
||||||
|
let allPx = 0; for (let i=3;i<allData.length;i+=4) if (allData[i]>127) allPx++;
|
||||||
|
return {mask:maskPx, fg:fgPx, pct: 100*fgPx/maskPx, kept: 100*fgPx/allPx,
|
||||||
|
maskOfSquare: 100*maskPx/(N*N)};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- notes ----------------------------------------------------- */
|
||||||
|
function notes(){
|
||||||
|
const cov = coverage(state.mask);
|
||||||
|
const rows = D.samples.map(r =>
|
||||||
|
'<tr><td>'+r.where+'</td>'+
|
||||||
|
r.v.map(v=>'<td><span class="sw" style="background:'+v+'"></span>'+v+'</td>').join('')+'</tr>').join('');
|
||||||
|
document.getElementById('notes').innerHTML =
|
||||||
|
'<p><b>Geometry.</b> Layers are 108 dp; the mask shows the central 72 dp, so every layer is drawn at '+
|
||||||
|
'1.5× the icon size and centre-cropped — that is what the <code>viewBox="18 18 72 72"</code> on each '+
|
||||||
|
'tile reproduces. The foreground and monochrome paths, including the kit\'s v1.1 optical offset '+
|
||||||
|
'<code>translate(-40.740741 20.370370)</code>, are copied verbatim from the kit SVGs and are '+
|
||||||
|
'<b>identical in A, B and C</b>. Only the background layer changes.</p>'+
|
||||||
|
'<p><b>Composited background colour</b> (surface + glow, under the falcon, sampled in layer units):</p>'+
|
||||||
|
'<table class="m"><tr><th>point in the visible 72 window</th><th>A flat</th><th>B layer-fit</th>'+
|
||||||
|
'<th>C mask-fit</th></tr>'+rows+'</table>'+
|
||||||
|
'<p style="margin-top:10px"><b>How much background is even visible?</b> Measured live on this page: '+
|
||||||
|
'under the '+MASKS[state.mask].label.toLowerCase()+' mask the falcon covers only <b>'+
|
||||||
|
cov.pct.toFixed(1)+'%</b> of the masked area, so the background owns the other '+
|
||||||
|
(100-cov.pct).toFixed(1)+'%. That mask keeps '+cov.maskOfSquare.toFixed(1)+'% of the 72 dp square and '+
|
||||||
|
cov.kept.toFixed(1)+'% of the falcon (the falcon\'s painted box is 46.6 × 65.8 of the 72 window, '+
|
||||||
|
'so even a circle shaves only the extreme tips).</p>'+
|
||||||
|
'<p><b>B vs C.</b> B maps the 1024 artwork onto the whole 108 layer, so the visible 72 window sees only '+
|
||||||
|
'the middle two-thirds of the gradient. C maps it onto the visible 72 window, so the masked result matches '+
|
||||||
|
'the desktop tile corner for corner — but C\'s outer 18 dp of bleed is flat padding, which is what a '+
|
||||||
|
'launcher reveals when it parallaxes the layers on a swipe. Under a circle the two are almost the same '+
|
||||||
|
'(#0F3643 vs #0E3442 at the NW rim); they separate only where a mask keeps the corners.</p>'+
|
||||||
|
'<p><b>Honesty notes.</b> 1 dp = 1 CSS px here; on a Pixel-class screen a 48 dp icon is ~132 physical '+
|
||||||
|
'pixels but only ~7.6 mm across. Mask outlines are the standard circle / squircle / rounded-square / '+
|
||||||
|
'teardrop families — OEM paths vary slightly. The launcher shadow is a Launcher3-style ambient+key pair, '+
|
||||||
|
'approximated in CSS, applied identically to every tile. Themed-icon ground and ink are representative '+
|
||||||
|
'Material You tones; the real values are derived from the user\'s wallpaper. The TODAY column is the '+
|
||||||
|
'actual shipping <code>mipmap-xxxhdpi/ic_launcher.png</code> with Launcher3\'s legacy plating '+
|
||||||
|
'(corner-extracted plate, icon at 0.7) applied.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- boot ------------------------------------------------------ */
|
||||||
|
buildDefs();
|
||||||
|
seg(document.getElementById('segMask'),
|
||||||
|
Object.keys(MASKS).map(k=>({v:k,t:MASKS[k].label})), 'mask', notes);
|
||||||
|
seg(document.getElementById('segSize'),
|
||||||
|
SIZES.map(s=>({v:s.px,t:s.px+' dp'})), 'size');
|
||||||
|
seg(document.getElementById('segMode'),
|
||||||
|
[{v:'standard',t:'Standard'},{v:'themed',t:'Themed (13+)'}], 'mode');
|
||||||
|
document.getElementById('cbShadow').checked = state.shadow;
|
||||||
|
document.getElementById('cbSafe').checked = state.safe;
|
||||||
|
document.getElementById('cbShadow').onchange = e => { state.shadow = e.target.checked; render(); writeHash(); };
|
||||||
|
document.getElementById('cbSafe').onchange = e => { state.safe = e.target.checked; render(); writeHash(); };
|
||||||
|
render(); notes(); writeHash();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
|
||||||
|
|
||||||
|
The desktop's two playlist pages on the phone: the shelf (pinned first,
|
||||||
|
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
|
||||||
|
chip, description, the meta line's warnings, Play / Shuffle / more, a
|
||||||
|
filter, rows tagged unresolved / set audio / file missing). Sheets for a
|
||||||
|
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
|
||||||
|
Add to playlist on every track and record sheet; a removal undoable for
|
||||||
|
seven seconds from the neutral toast (notice.svelte.js); stream rows play
|
||||||
|
through the resolver as path-less entries, a set slice starts and ends at
|
||||||
|
its cues; Add to library / Download all tracks with the desktop's bulk
|
||||||
|
rules; nav.replace for a review that became the thing it made.
|
||||||
|
|
||||||
|
The import doors that need no file — a link (YouTube · Spotify · Apple
|
||||||
|
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
|
||||||
|
review screen and the desktop's catalog verification pass. A share plugin
|
||||||
|
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
|
||||||
|
the text in a ShareActivity and the WebView hands it to the import when it
|
||||||
|
comes back to the front. You → Sources: the source-quality window and the
|
||||||
|
priority per mode.
|
||||||
|
|
||||||
|
Found by the walks and fixed: set_settings takes camelCase arguments, so
|
||||||
|
the phone's normalization and library-folder saves never landed;
|
||||||
|
Cover.svelte's non-eager wrapper has no layout box, so its intersection
|
||||||
|
observer never fired and row thumbnails never loaded (the Artists tab too);
|
||||||
|
a history push while a history.go was still in flight left the nav mirror
|
||||||
|
one entry ahead and the page blank after a confirmed delete — the mirror
|
||||||
|
now applies once per microtask and never pushes during a pending traversal.
|
||||||
|
|
||||||
|
The harness names unmocked commands, carries console warnings' frames (the
|
||||||
|
smoke bundle is built unminified), probes the history mirror on every step,
|
||||||
|
mocks app_status / play_local / waveform / lyrics_get / player_state and
|
||||||
|
applies settings patches, runs adb steps on the phone (the share intent),
|
||||||
|
and walks playlists, the imports, the share and the settings on both the
|
||||||
|
mock and the phone. ANDROID.md documents all of it.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
android: reorder from the sheet — a playlist row up, down or to the top; a queue row up or down
|
||||||
|
|
||||||
|
Without a drag: a playlist row's sheet moves it up, down or to the top and
|
||||||
|
persists the whole order in one call (playlist_set_order, as the desktop's
|
||||||
|
drop does); a queue row's sheet moves it up or down, never past the track
|
||||||
|
playing. Also src-tauri/android/ship.sh — one round end to end: the mock
|
||||||
|
walk, the embedded build installed, the phone walk, the commit, each step
|
||||||
|
gated on the one before.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
android: the room list filtered by name, the rest on demand
|
||||||
|
|
||||||
|
Soulseek lists hundreds of rooms; the phone showed the first 80 and no way
|
||||||
|
to the rest. Community → Rooms now has a filter by name and extends the
|
||||||
|
list 200 at a time.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
android: a playlist's cover from a photo on the phone
|
||||||
|
|
||||||
|
The storage plugin gains pick_image — the document picker limited to
|
||||||
|
images, the picked content copied into the app's cache so the Rust side
|
||||||
|
gets a plain path. playlist_cover_set(id, path) is the desktop pick's
|
||||||
|
install step split out and shared (squared, the master cover, the row's
|
||||||
|
revision moved). A playlist's sheet offers Choose cover photo and Remove
|
||||||
|
custom cover; the mock walk exercises both.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
android: hours on the library's count line, By format, Jump back in on the empty search
|
||||||
|
|
||||||
|
The desktop Library summary's hours on the phone's count line; a By format
|
||||||
|
order from the file stats (lossless by depth and rate first, then by
|
||||||
|
bitrate, the badge as the card's line); the empty Search page's Jump back
|
||||||
|
in — the last records played — as the desktop's empty search shows them.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
android: the nav's badges — transfers in flight on You, unread chats on Community
|
||||||
|
|
||||||
|
The desktop's title-bar download ring and its unread indicator, on the
|
||||||
|
phone's nav: a count on You while Save is fetching, a count on Community
|
||||||
|
when a chat has unread lines.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
android: the hub's filters on the network results, pasted lyrics
|
||||||
|
|
||||||
|
The raw results take the Soulseek hub's filters — a free slot only, FLAC
|
||||||
|
or MP3 320+ — as chips above the folders. The lyrics sheet has the
|
||||||
|
desktop's paste door: plain text, or LRC with timestamps, kept for the
|
||||||
|
track.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
android: downloads on Wi-Fi only
|
||||||
|
|
||||||
|
One switch the desktop never needed, off by default: on mobile data every
|
||||||
|
Save the phone can start — a record, a track, a folder, a file, a
|
||||||
|
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
|
||||||
|
streaming is never held back. The walk flips the switch, makes the
|
||||||
|
connection cellular and sees a download refused.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
android: the repair sheet, browse, every long-press sheet, You's last sections, the lyrics tools — and the walk's audio check made honest
|
||||||
|
|
||||||
|
The playlist repair (Find a local match: the library searched by the
|
||||||
|
row's name, a pick relinks it through playlist_relink_item; an unknown
|
||||||
|
row takes the track's identity). A peer's sheet on the raw results (play
|
||||||
|
or save the folder, browse their files, message, buddy) and the Browse
|
||||||
|
screen — the hub's Browse tab: a user's whole share with sizes and totals,
|
||||||
|
a folder played or saved whole; a share can hold thousands of folders, so
|
||||||
|
it filters and renders 80 at a time. "Look up a user" on Community →
|
||||||
|
Buddies. The sheets the phone lacked: a queue row's (Play now, Move to
|
||||||
|
next, Remove from queue), a catalog song's, a catalog card's and the
|
||||||
|
record page's own, a raw file's (in the results and in Browse), a room
|
||||||
|
name's, a buddy's, the now-playing Save (the desktop's Save menu for a
|
||||||
|
streamed track or record). The artwork viewer with pinch zoom, one layer
|
||||||
|
the back gesture peels. Search's recent searches and its spelling
|
||||||
|
suggestion from the names this phone knows. You → Streaming, Downloads,
|
||||||
|
Sharing, Lyrics, Album artwork, and Clear play history; a record's
|
||||||
|
Re-fetch the cover. The lyrics tools on the sheet's face: the offset
|
||||||
|
nudged and kept per track, another LRCLIB candidate chosen, the lookup
|
||||||
|
forgotten and done again (lyrics.svelte.js, shared by the face and the
|
||||||
|
lyrics sheet).
|
||||||
|
|
||||||
|
The harness: every CDP call times out and the reload step re-attaches
|
||||||
|
(a WebView that stopped answering hung a walk for ten minutes); the
|
||||||
|
audio check counts SoulStream's own tracks' underruns from the mixer dump
|
||||||
|
instead of the thread-wide counters that any app's notification moves —
|
||||||
|
the afternoon's "dropouts" were other apps' sounds, and the browse of a
|
||||||
|
49,544-file share measured at zero; __settle resets every tab to its root
|
||||||
|
through the nav; an adb step kind; the walks cover all of the above on
|
||||||
|
the mock and on the phone.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
android: a peer in full — their card, their interests, and what they have that you don't
|
||||||
|
|
||||||
|
The peer sheet's new "About this user" row opens a screen of its own: the
|
||||||
|
card their client serves (their own words, their picture, the upload slot
|
||||||
|
and the queue), the likes and hates they declared, and the comparison this
|
||||||
|
phone makes of their share against the library. The comparison is the
|
||||||
|
point of opening a stranger — every record they have by an artist in your
|
||||||
|
library that you don't, each row opening the search that finds it, and a
|
||||||
|
way to forget the whole comparison again. Nothing is uploaded to work it
|
||||||
|
out. Their picture opens in the artwork viewer, which now takes a data:
|
||||||
|
key beside a library path and an https URL.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
android: a name of your own for anyone on the network
|
||||||
|
|
||||||
|
The desktop's local contact metadata on the phone: a peer's sheet ends in
|
||||||
|
Name and notes — a petname, a group, a private note and the Favorite and
|
||||||
|
Trusted marks, kept in the app's own config and published nowhere. The
|
||||||
|
petname stands in for the username on the buddy list, in the chat list, on
|
||||||
|
a thread's bar, in the peer sheet, in a room's roster and on a room line,
|
||||||
|
and the real username stays with it — on the line beneath, or beside the
|
||||||
|
name in a room — so a stranger is never quietly turned into someone you
|
||||||
|
know. Favourites sit at the top of the buddy list, and clearing a person's
|
||||||
|
details drops the entry rather than storing an empty one — the backend's
|
||||||
|
own rule.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
android: your own Soulseek interests, and who the server says is like you
|
||||||
|
|
||||||
|
Community's fourth tab is Taste: the likes and hates this client
|
||||||
|
publishes to the Soulseek server (slsk_interests_mine, slsk_interest_set),
|
||||||
|
added and taken back as chips, and the people the server ranks as close to
|
||||||
|
them (slsk_similar_users) — a row each into the peer sheet the app already
|
||||||
|
has. Every declaration asks the server again and the chips stay quiet
|
||||||
|
until it has answered, the way the desktop does, because the ranking only
|
||||||
|
moves when its input does. The lists live in taste.svelte.js, read once
|
||||||
|
per account when the tab is opened; the mock walk declares a like, watches
|
||||||
|
the match grow, declares a hate and takes the like back.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy
|
||||||
|
|
||||||
|
The editor the desktop drives with a mouse is better under a thumb: the
|
||||||
|
song plays, the line to time sits large in the middle of the screen with
|
||||||
|
the one behind and the one ahead faint around it, and a tap anywhere
|
||||||
|
stamps it. One layer the back gesture peels, and a set left mid-tap is
|
||||||
|
offered back by the row that opened it; Save writes the track's own
|
||||||
|
document, lines never tapped left out of it and the button saying how
|
||||||
|
many are kept. The lyrics sheet also gains the per-track auto-sync switch
|
||||||
|
and the desktop's reduced-vocals copy — the same title, artist and
|
||||||
|
duration, so the words keep following it, and one row puts the original
|
||||||
|
back.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
android: a folder of audio files, and a paste of track links
|
||||||
|
|
||||||
|
The paste door now reads itself the way the desktop's does: one track link
|
||||||
|
in the paste and every line is looked up on its own public page
|
||||||
|
(playlist_import_track_links), which is the way past Spotify's 100-track
|
||||||
|
public ceiling — the wait screen counts them as they arrive. A fourth door
|
||||||
|
imports a folder of audio files off this phone, picked with the storage
|
||||||
|
plugin's own document-tree picker; playlist_import_folder grew a path
|
||||||
|
argument for it, because on Android it had no dialog to open and always
|
||||||
|
answered nothing. A YouTube playlist link needed no door at all — the link
|
||||||
|
one already dispatches it.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
android: the catalog's numbers — Popular's own lane, a count per row, the artist's photo
|
||||||
|
|
||||||
|
Popular is asked on its own as well as with the page, so the section can
|
||||||
|
paint before the full page is built and an artist whose page came back
|
||||||
|
with none still gets one. A catalog record's rows that the album's own
|
||||||
|
artist anchor found no count for — a feature's guest, a compilation's
|
||||||
|
stranger — get the deferred Last.fm pass by their own artist id. The
|
||||||
|
artist page's ⋯ is its own sheet now: choose a hero photo from this phone,
|
||||||
|
or put the catalog's own back; set_artist_hero is pick_artist_hero's
|
||||||
|
install step split out and shared, so the desktop's dialog keeps its door.
|
||||||
|
You gains the desktop's Refresh artist portraits, and a data: URL is the
|
||||||
|
image itself in the art gate — a chosen hero comes back from Rust as one,
|
||||||
|
and the thumbnail fetch used to throw it away.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
android: a streamed file copied into a folder, the listening port, the dev build's diagnostics
|
||||||
|
|
||||||
|
The now-playing sheet's streamed track can be copied into a folder picked
|
||||||
|
on the phone, as the file it is — the cache written out, nothing downloaded
|
||||||
|
again, no library row (copy_stream_to_folder, which the desktop has had a
|
||||||
|
command for and no UI). You → Sharing carries the listening port, what
|
||||||
|
peers dial when they download from you: unset is the client's own 50300,
|
||||||
|
the app asks the router to forward it over UPnP and falls back to the
|
||||||
|
server relaying, and a change applies on the next connect. The dev flavour
|
||||||
|
gets the desktop's debug overlay as a screen under This phone — CPU,
|
||||||
|
memory, the network, the stream and its loudness, what the pre-buffer is
|
||||||
|
fetching, the size of the play history — behind devBuild in util.js, which
|
||||||
|
is Vite's own DEV flag, so the embedded build the phone keeps has no row
|
||||||
|
to tap.
|
||||||
Loaded 100 of 630 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user