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:
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>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.soulstream.transfernotice
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
|
||||
/** What the notice says — the Rust `TransferNotice`, camelCase. */
|
||||
@InvokeArg
|
||||
class NoticeArgs {
|
||||
/** how many files are still in flight; zero means take the notice down */
|
||||
var count: Int = 0
|
||||
var title: String? = null
|
||||
var line: String? = null
|
||||
var percent: Int = 0
|
||||
/** nobody has stated a size yet, so the bar has no end to walk towards */
|
||||
var indeterminate: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge between the WebView and the service that owns the notice.
|
||||
* `update` mirrors what Save is fetching (and starts the service the first
|
||||
* time); `clear` takes the notice down. Its buttons come back as the plugin
|
||||
* event `action` with `{ action }` — "cancel" or "open".
|
||||
*/
|
||||
@TauriPlugin
|
||||
class TransferNoticePlugin(private val activity: Activity) : Plugin(activity) {
|
||||
private var askedForNotifications = false
|
||||
|
||||
init {
|
||||
TransferHub.onAction = { action ->
|
||||
val payload = JSObject()
|
||||
payload.put("action", action)
|
||||
trigger("action", payload)
|
||||
}
|
||||
/* the channel exists from app start, so a user can silence the
|
||||
downloads on their own before the first one ever runs */
|
||||
TransferHub.ensureChannel(activity.applicationContext)
|
||||
}
|
||||
|
||||
@Command
|
||||
fun update(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NoticeArgs::class.java)
|
||||
ensureNotificationPermission()
|
||||
TransferHub.update(activity.applicationContext, args)
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
@Command
|
||||
fun clear(invoke: Invoke) {
|
||||
TransferHub.clear(activity.applicationContext)
|
||||
invoke.resolve()
|
||||
}
|
||||
|
||||
/** Android 13+: the notice is a notification and needs the permission;
|
||||
* asked once, the first time something is fetched. Its own request code
|
||||
* — 4711 is the media card's, and two requests that share one would
|
||||
* answer each other. The service runs either way; without the
|
||||
* permission it simply has nothing to show. */
|
||||
private fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT < 33 || askedForNotifications) return
|
||||
askedForNotifications = true
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 4712)
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.soulstream.transfernotice
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* What is being fetched, shared between the plugin (which hears the
|
||||
* WebView) and the service (which owns the notice). The service is started
|
||||
* on the first update and refreshed on every one after; a count of zero is
|
||||
* a clear, since the phone's own rule is that an empty queue has no notice.
|
||||
*/
|
||||
object TransferHub {
|
||||
const val CHANNEL = "downloads"
|
||||
|
||||
/** 1 is the media card's, and ids are per-package */
|
||||
const val NOTIFICATION_ID = 2
|
||||
|
||||
const val ACTION_CANCEL = "com.soulstream.transfernotice.CANCEL"
|
||||
const val ACTION_OPEN = "com.soulstream.transfernotice.OPEN"
|
||||
|
||||
@Volatile var onAction: ((String) -> Unit)? = null
|
||||
@Volatile var state: NoticeArgs? = null
|
||||
@Volatile var service: TransferService? = null
|
||||
|
||||
/** Its own channel, low importance and no badge: a download is not an
|
||||
* alarm, and silencing it must not silence the player's card. A
|
||||
* filename is not for a locked screen, so the notice is private there. */
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < 26) return
|
||||
val manager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL) != null) return
|
||||
val channel = NotificationChannel(CHANNEL, "Downloads", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.description = "What Save is fetching into your library"
|
||||
channel.setShowBadge(false)
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun update(ctx: Context, np: NoticeArgs) {
|
||||
if (np.count <= 0) {
|
||||
clear(ctx)
|
||||
return
|
||||
}
|
||||
state = np
|
||||
val running = service
|
||||
if (running == null) {
|
||||
/* Android 12+ refuses a foreground start from the background. A
|
||||
Save is always begun from the foreground, so the first start
|
||||
lands; one that cannot simply leaves the fetch without a card */
|
||||
try {
|
||||
ContextCompat.startForegroundService(ctx, Intent(ctx, TransferService::class.java))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SoulStream", "transfer notice could not start: $e")
|
||||
}
|
||||
} else {
|
||||
running.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
state = null
|
||||
val running = service
|
||||
/* refresh() on a null state is the one path that takes the notice
|
||||
down and stops the service; stopService is for a service that was
|
||||
never here */
|
||||
if (running != null) running.refresh() else ctx.stopService(Intent(ctx, TransferService::class.java))
|
||||
}
|
||||
|
||||
fun act(action: String) {
|
||||
onAction?.invoke(action)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The foreground service behind the notice: a progress notification with
|
||||
* Stop all and Open on it, and — the point — the reason the process is not
|
||||
* frozen while a Save runs with nothing playing. The fetches themselves
|
||||
* stay where they are, on the Rust core's own connections; this only says
|
||||
* what they are doing.
|
||||
*/
|
||||
class TransferService : Service() {
|
||||
private var inForeground = false
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
TransferHub.ensureChannel(this)
|
||||
TransferHub.service = this
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
/* Started with startForegroundService, so Android demands
|
||||
startForeground() promptly WHATEVER the state — a queue that
|
||||
emptied while the start was in flight included — or it kills the
|
||||
whole app about ten seconds later. MediaService learned this the
|
||||
same way, on 2026-09-13, and it was a Save that died. Promote
|
||||
first, with the notice there is or a plain one; refresh() steps
|
||||
back at once if there is nothing left to say. */
|
||||
if (!inForeground) promote(build(TransferHub.state ?: NoticeArgs()))
|
||||
when (intent?.action) {
|
||||
TransferHub.ACTION_CANCEL -> TransferHub.act("cancel")
|
||||
TransferHub.ACTION_OPEN -> {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
try { startActivity(it) } catch (_: Exception) { /* no launcher activity */ }
|
||||
}
|
||||
TransferHub.act("open")
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/** Mirror TransferHub.state into the notice — or take it down. */
|
||||
fun refresh() {
|
||||
val np = TransferHub.state
|
||||
if (np == null) {
|
||||
/* the queue emptied: the notice goes, whole */
|
||||
if (inForeground) {
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
inForeground = false
|
||||
}
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
/* promote() on an already-promoted service is just a notify */
|
||||
promote(build(np))
|
||||
}
|
||||
|
||||
private fun promote(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ServiceCompat.startForeground(
|
||||
this, TransferHub.NOTIFICATION_ID, notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
startForeground(TransferHub.NOTIFICATION_ID, notification)
|
||||
}
|
||||
inForeground = true
|
||||
}
|
||||
|
||||
/** Android 15 caps a dataSync service at six hours a day and kills the
|
||||
* app if it is still up when this returns. Take the notice down: the
|
||||
* fetches are the Rust core's own and Transfers still lists them. */
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
TransferHub.clear(applicationContext)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
TransferHub.service = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun build(np: NoticeArgs): Notification {
|
||||
val open = serviceIntent(11, TransferHub.ACTION_OPEN)
|
||||
val cancel = serviceIntent(12, TransferHub.ACTION_CANCEL)
|
||||
return NotificationCompat.Builder(this, TransferHub.CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_soulstream_down)
|
||||
.setContentTitle(np.title ?: "Saving")
|
||||
.setContentText(np.line ?: "")
|
||||
.setProgress(100, np.percent.coerceIn(0, 100), np.indeterminate)
|
||||
.setContentIntent(open)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
|
||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.setOngoing(true)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop all", cancel)
|
||||
.addAction(android.R.drawable.ic_menu_upload, "Open", open)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** The notice only exists while this service is in the foreground, so
|
||||
* its own buttons are never a background service start. The request
|
||||
* codes are 11 and 12 on purpose — the media plugin uses 0 in the same
|
||||
* package, and two PendingIntents that filterEqual overwrite each other. */
|
||||
private fun serviceIntent(code: Int, action: String): PendingIntent {
|
||||
val i = Intent(this, TransferService::class.java).setAction(action)
|
||||
return PendingIntent.getForegroundService(
|
||||
this, code, i,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -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>
|
||||
Reference in new issue
Block a user