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