android: the downloads say so in the shade

A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:44:11 -05:00
1 parent 4fc6e9c630
commit 5738c2d7bf
31 files changed
+1358 -6

No files matched your search

@@ -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,28 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.soulstream.transfernotice"
compileSdk = 36
defaultConfig {
minSdk = 26
}
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,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>
@@ -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)
}
}
@@ -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,
)
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- the status-bar glyph: an arrow into a tray, white on transparent, as
the shade wants. Its own copy on purpose — R.drawable.ic_soulstream_note
belongs to the media-session module's R class and is not visible here -->
<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="M11,3h2v7h3.5L12,15.5 7.5,10H11V3z" />
<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"]
@@ -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"]
@@ -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()
}