android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
1 parent
972dd103cb
commit
bb828ff2dc
102 files changed
+12258
-830
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/build
|
||||
/.tauri
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.soulstream.storage"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
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,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>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
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.Settings
|
||||
import androidx.activity.result.ActivityResult
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
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
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user