Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
197 lines
7.8 KiB
Plaintext
197 lines
7.8 KiB
Plaintext
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,
|
|
)
|
|
}
|
|
}
|