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,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>