Files
SoulStream/handoff/rounds/ins-r29-service-kt.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

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