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
@@ -7,7 +7,6 @@ rust-version.workspace = true
|
||||
[dependencies]
|
||||
futures = "0.3"
|
||||
image.workspace = true
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -15,3 +14,12 @@ thiserror.workspace = true
|
||||
log.workspace = true
|
||||
# RFC-7231 Date parsing for preview-lease math (tiny, no deps of its own).
|
||||
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 builder = rodio::OutputStreamBuilder::from_device(device)
|
||||
.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
|
||||
.open_stream_or_fallback()
|
||||
.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,28 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.soulstream.mediasession"
|
||||
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")
|
||||
// MediaSessionCompat, the MediaStyle notification, MediaButtonReceiver
|
||||
implementation("androidx.media:media:1.7.0")
|
||||
implementation(project(":tauri-android"))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="com.soulstream.mediasession.MediaService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<!-- lets MediaButtonReceiver forward headset and card buttons here -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.media.session.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
package com.soulstream.mediasession
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Base64
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media.session.MediaButtonReceiver
|
||||
|
||||
/**
|
||||
* What is playing, shared between the plugin (which hears the WebView) and
|
||||
* the service (which owns the session). The service is started on the first
|
||||
* update and refreshed on every one after.
|
||||
*/
|
||||
object MediaHub {
|
||||
@Volatile var onAction: ((String, Double) -> Unit)? = null
|
||||
@Volatile var state: NowPlayingArgs? = null
|
||||
@Volatile var art: Bitmap? = null
|
||||
@Volatile var artKey: String? = null
|
||||
@Volatile var service: MediaService? = null
|
||||
|
||||
fun update(ctx: Context, np: NowPlayingArgs) {
|
||||
if (np.artKey != artKey) {
|
||||
artKey = np.artKey
|
||||
art = null
|
||||
}
|
||||
np.art?.let { b64 ->
|
||||
try {
|
||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
art = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) {
|
||||
/* an unreadable cover is no cover */
|
||||
}
|
||||
}
|
||||
state = np
|
||||
val running = service
|
||||
if (running == null) {
|
||||
/* Android 12+ refuses a foreground start from the background; the
|
||||
card simply waits for the next update from the foreground */
|
||||
try {
|
||||
ContextCompat.startForegroundService(ctx, Intent(ctx, MediaService::class.java))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "media service could not start: $e")
|
||||
}
|
||||
} else {
|
||||
running.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
state = null
|
||||
val running = service
|
||||
if (running != null) running.stopSelf() else ctx.stopService(Intent(ctx, MediaService::class.java))
|
||||
}
|
||||
|
||||
fun act(action: String, position: Double = -1.0) {
|
||||
onAction?.invoke(action, position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The foreground service: a MediaSession the system reads for the lock
|
||||
* screen and the shade, a MediaStyle notification with the transport, audio
|
||||
* focus (pause when another app takes the output, or the headphones come
|
||||
* out), and the reason the process is not frozen in the background. The
|
||||
* audio never passes through here.
|
||||
*/
|
||||
class MediaService : Service() {
|
||||
companion object {
|
||||
const val CHANNEL = "playback"
|
||||
const val NOTIFICATION_ID = 1
|
||||
}
|
||||
|
||||
private lateinit var session: MediaSessionCompat
|
||||
private var focus: AudioFocusRequest? = null
|
||||
private var pausedByFocus = false
|
||||
private var noisyRegistered = false
|
||||
private var inForeground = false
|
||||
|
||||
private val noisy = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) MediaHub.act("pause")
|
||||
}
|
||||
}
|
||||
|
||||
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
|
||||
when (change) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
pausedByFocus = false
|
||||
MediaHub.act("pause")
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
if (MediaHub.state?.playing == true) {
|
||||
pausedByFocus = true
|
||||
MediaHub.act("pause")
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
if (pausedByFocus) {
|
||||
pausedByFocus = false
|
||||
MediaHub.act("play")
|
||||
}
|
||||
}
|
||||
/* CAN_DUCK: the framework ducks us on its own */
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
session = MediaSessionCompat(this, "SoulStream")
|
||||
session.setCallback(object : MediaSessionCompat.Callback() {
|
||||
override fun onPlay() { MediaHub.act("play") }
|
||||
override fun onPause() { MediaHub.act("pause") }
|
||||
override fun onSkipToNext() { MediaHub.act("next") }
|
||||
override fun onSkipToPrevious() { MediaHub.act("prev") }
|
||||
override fun onStop() { MediaHub.act("stop") }
|
||||
override fun onSeekTo(pos: Long) { MediaHub.act("seek", pos / 1000.0) }
|
||||
})
|
||||
session.isActive = true
|
||||
createChannel()
|
||||
/* a system broadcast reaches a non-exported receiver; Android 14 wants
|
||||
the flag said out loud */
|
||||
ContextCompat.registerReceiver(
|
||||
this, noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
noisyRegistered = true
|
||||
MediaHub.service = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
/* a headset or card button arrives as a MEDIA_BUTTON intent */
|
||||
MediaButtonReceiver.handleIntent(session, intent)
|
||||
/* Started with startForegroundService, so Android demands
|
||||
startForeground() promptly WHATEVER the state — a paused restore
|
||||
and an empty bar included — or it kills the whole app
|
||||
(ForegroundServiceDidNotStartInTimeException: on 2026-09-13 a Save
|
||||
died with the process, 4 of 14 tracks in). Promote first, with the
|
||||
card there is or a plain one; refresh() may step back at once. */
|
||||
if (!inForeground) promote(buildNotification(MediaHub.state ?: NowPlayingArgs()))
|
||||
refresh()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun promote(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
inForeground = true
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (noisyRegistered) {
|
||||
try { unregisterReceiver(noisy) } catch (_: Exception) {}
|
||||
noisyRegistered = false
|
||||
}
|
||||
abandonFocus()
|
||||
session.isActive = false
|
||||
session.release()
|
||||
MediaHub.service = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** Mirror MediaHub.state into the session and the notification. */
|
||||
fun refresh() {
|
||||
val np = MediaHub.state
|
||||
if (np == null) {
|
||||
if (inForeground) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
}
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
val meta = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, np.title ?: "")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, np.artist ?: "")
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, np.album ?: "")
|
||||
np.duration?.let { meta.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, (it * 1000).toLong()) }
|
||||
MediaHub.art?.let {
|
||||
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it)
|
||||
meta.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it)
|
||||
}
|
||||
session.setMetadata(meta.build())
|
||||
|
||||
var actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SEEK_TO or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
if (np.hasNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
|
||||
if (np.hasPrev) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
|
||||
val state = PlaybackStateCompat.Builder()
|
||||
.setActions(actions)
|
||||
.setState(
|
||||
if (np.playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
|
||||
(np.position * 1000).toLong(),
|
||||
if (np.playing) 1f else 0f,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
.build()
|
||||
session.setPlaybackState(state)
|
||||
|
||||
if (np.playing) requestFocus()
|
||||
|
||||
val notification = buildNotification(np)
|
||||
if (np.playing) {
|
||||
promote(notification)
|
||||
} else {
|
||||
/* paused: the card stays, the service steps back so the system
|
||||
may reclaim it and the card can be swiped away */
|
||||
if (inForeground) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH)
|
||||
inForeground = false
|
||||
}
|
||||
try {
|
||||
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
|
||||
} catch (_: SecurityException) {
|
||||
/* no notification permission: the session alone still serves the lock screen */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(np: NowPlayingArgs): Notification {
|
||||
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val open = launch?.let {
|
||||
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
val prev = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)
|
||||
val toggle = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)
|
||||
val next = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
|
||||
val stop = MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP)
|
||||
return NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_soulstream_note)
|
||||
.setContentTitle(np.title ?: "SoulStream")
|
||||
.setContentText(np.artist ?: "")
|
||||
.setSubText(np.album)
|
||||
.setLargeIcon(MediaHub.art)
|
||||
.setContentIntent(open)
|
||||
.setDeleteIntent(stop)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setOngoing(np.playing)
|
||||
.addAction(android.R.drawable.ic_media_previous, "Previous", prev)
|
||||
.addAction(
|
||||
if (np.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
|
||||
if (np.playing) "Pause" else "Play",
|
||||
toggle,
|
||||
)
|
||||
.addAction(android.R.drawable.ic_media_next, "Next", next)
|
||||
.setStyle(
|
||||
androidx.media.app.NotificationCompat.MediaStyle()
|
||||
.setMediaSession(session.sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL) != null) return
|
||||
val channel = NotificationChannel(CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.description = "What is playing"
|
||||
channel.setShowBadge(false)
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun requestFocus() {
|
||||
if (focus != null) return
|
||||
val manager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build(),
|
||||
)
|
||||
.setOnAudioFocusChangeListener(focusListener)
|
||||
.build()
|
||||
if (manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) focus = request
|
||||
}
|
||||
|
||||
private fun abandonFocus() {
|
||||
val request = focus ?: return
|
||||
(getSystemService(Context.AUDIO_SERVICE) as AudioManager).abandonAudioFocusRequest(request)
|
||||
focus = null
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun update(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NowPlayingArgs::class.java)
|
||||
ensureNotificationPermission()
|
||||
MediaHub.update(activity.applicationContext, args)
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun clear(invoke: Invoke) {
|
||||
MediaHub.clear(activity.applicationContext)
|
||||
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)
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- the status-bar glyph: a note, white on transparent, as the shade wants -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h4V3H12zM10,19a2,2 0,1 1,0 -4a2,2 0,0 1,0 4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
// 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.
|
||||
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).
|
||||
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,127 @@
|
||||
## 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-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-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-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 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-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-registerListener`\n- `allow-removeListener`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("media-session")
|
||||
.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.mediasession", "MediaSessionPlugin")?;
|
||||
app.manage(MediaSession(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,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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The three 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"];
|
||||
|
||||
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-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"]
|
||||
+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,99 @@
|
||||
## Default Permission
|
||||
|
||||
The phone's storage: pick a library folder (a Storage Access Framework tree,
|
||||
resolved to a path when the volume allows), 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`
|
||||
|
||||
## 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-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-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,9 @@
|
||||
"$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), 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"]
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"$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_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 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), and Android's all-files access —\nwhether 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`",
|
||||
"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), and Android's all-files access —\nwhether 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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Three commands: `pick_folder` → `{ uri, path }` (path null when the tree
|
||||
//! is not on a volume with a path), `all_files_access` → `{ granted }`, and
|
||||
//! `request_all_files_access`, which opens the system screen. 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,
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("storage")
|
||||
.invoke_handler(tauri::generate_handler![pick_folder, all_files_access, request_all_files_access])
|
||||
.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()
|
||||
}
|
||||
Reference in new issue
Block a user