package com.sptmobile.link 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.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.sptmobile.pairing.HostStore import com.sptmobile.voice.RoomVoiceSpool import com.sptmobile.voice.VoiceForwarder import com.sptmobile.voice.VoiceOutcome import com.sptmobile.voice.VoiceSettings import com.sptmobile.voice.WebhookHandler import com.sptmobile.voice.WebhookServer import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext // [impl->REQ-PUSH-FOREGROUND-SERVICE] /** * The one lifetime that holds every paired host's device link (DESIGN.md * ruling 7). All link policy lives in [LinkSupervisor]; this service only * provides the foreground lifetime, the status notification, and the wiring * from [HostStore] changes into [LinkSupervisor.setHosts]. The webhook * receiver joins this same lifetime in the voice-pipe plan. * * Type `dataSync` (persistent network link). Android 15 caps dataSync at 6h * per day for targetSdk 35 — revisit the type when targetSdk moves. */ /** What the Hosts-screen voice card renders (VOICE-PLAN scope 5). */ data class VoiceState( val url: String?, val bindError: String?, val token: String?, val pending: Int, val lastOutcome: VoiceOutcome?, // [impl->REQ-HAZARD-RECEIVER-DOWN] receiver liveness on the card: bound = // socket up, lastReceivedAt = when a request last landed (null = never // hit). A dead/never-hit receiver is now visible, not a silent refusal. val bound: Boolean = false, val lastReceivedAt: Long? = null, ) class LinkService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private lateinit var supervisor: LinkSupervisor private var webhook: WebhookServer? = null override fun onCreate() { super.onCreate() LinkNative.init(this) createChannel() ServiceCompat.startForeground( this, NOTIF_ID, buildNotification("starting…"), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) supervisor = LinkSupervisor(scope, NativeLinkConnector) _shared.value = supervisor val store = HostStore(applicationContext) scope.launch { store.hosts.collect { supervisor.setHosts(it) } } scope.launch { supervisor.states.collect { states -> val up = states.values.count { it is HostLinkState.Connected } notifyStatus( if (states.isEmpty()) "no hosts paired" else "$up/${states.size} hosts linked" ) } } scope.launch { drainLoop(store) } startVoicePipe(store) } // [impl->REQ-VOICE-PIPE] // [impl->REQ-PUSH-FOREGROUND-SERVICE] /** * The webhook receiver + spool forwarder join THIS lifetime (ruling 7: * "one lifetime, two jobs" — the service that holds the iroh conns is * the one Pebble posts to). Voice state (URL/token/pending/outcome) is * published for the Hosts-screen card. */ private fun startVoicePipe(store: HostStore) { val spool = RoomVoiceSpool(applicationContext) val settings = VoiceSettings(applicationContext) // reportStale = supervisor::reportStale runs on this same Main scope as // setHosts (the forwarder drains on `scope`), so the not-thread-safe // supervisor is only ever touched from one thread. val forwarder = VoiceForwarder( spool, store.hosts, supervisor.states, reportStale = supervisor::reportStale, ) forwarder.start(scope) // The socket thread reads the token synchronously; feed it a cache. var cachedToken: String? = null val server = WebhookServer( WebhookServer.DEFAULT_PORT, WebhookHandler(spool, { cachedToken }), ) webhook = server val bindError = try { server.start() null } catch (e: IOException) { "webhook bind failed: ${e.message}" } scope.launch { settings.ensureToken() combine( settings.token, spool.pending, forwarder.lastOutcome, server.lastReceivedAt, ) { token, pending, outcome, lastReceivedAt -> cachedToken = token VoiceState( url = if (bindError == null) "http://127.0.0.1:${WebhookServer.DEFAULT_PORT}/" else null, bindError = bindError, token = token, pending = pending, lastOutcome = outcome, bound = server.bound, lastReceivedAt = lastReceivedAt, ) }.collect { _voice.value = it } } } // [impl->REQ-INBOUND-NOTIFS] /** * Inbound pull loop (ruling 7): every linked host spools inbound * ``s for this device; this loop drains EACH of them (union, not * failover — each host holds its own queue) and surfaces the new entries * as notifications. The host commits removal only after its reply is on * the wire, so a drop mid-drain re-delivers — [SpoolInbox] dedups by * msg-id. Doze stretches the cadence into maintenance windows: late, * never lost. The drain key ([DeviceName]) is exactly what `pair` * registered. */ private suspend fun drainLoop(store: HostStore) { val inbox = SpoolInbox() val notifier = InboundNotifier(this) while (true) { delay(DRAIN_INTERVAL_MS) val hosts = try { store.hosts.first() } catch (e: RuntimeException) { if (e is CancellationException) throw e continue } val device = DeviceName.value(this) for ((host, handle) in DialList.linked(hosts, supervisor.states.value)) { try { val entries = withContext(LinkIo.dispatcher) { SpoolInbox.parse(LinkNative.spoolDrain(handle, device)) } notifier.notify(inbox.accept(entries)) } catch (e: RuntimeException) { if (e is CancellationException) throw e // [impl->REQ-HAZARD-STALE-LINK-STALL] The drain threw on a // Connected handle — report it stale so the supervisor // redials NOW rather than leaving a dead handle showing // "up" until the 30s keep-alive; next tick retries. supervisor.reportStale(host.node, handle, "spoolDrain: ${e.message}") } } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { _shared.value = null _voice.value = null webhook?.stop() supervisor.shutdown() scope.cancel() super.onDestroy() } private fun createChannel() { val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel( NotificationChannel( CHANNEL_ID, "Device link", NotificationManager.IMPORTANCE_LOW, ) ) } private fun buildNotification(text: String): Notification { val launch = packageManager.getLaunchIntentForPackage(packageName) val tap = launch?.let { PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE) } return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync_noanim) .setContentTitle("spt-mobile") .setContentText(text) .setContentIntent(tap) .setOngoing(true) .build() } private fun notifyStatus(text: String) { getSystemService(NotificationManager::class.java) .notify(NOTIF_ID, buildNotification(text)) } companion object { private const val CHANNEL_ID = "device_link" private const val NOTIF_ID = 1 private const val DRAIN_INTERVAL_MS = 15_000L private val _shared = MutableStateFlow(null) private val _voice = MutableStateFlow(null) /** * The running service's supervisor, null while the service is down. * ViewModels consume link state and handles through this — the * service is start-only (no binder ceremony for an in-process * singleton). */ val shared: StateFlow = _shared /** Voice-pipe status for the Hosts-screen card; null = service down. */ val voice: StateFlow = _voice fun start(context: Context) { ContextCompat.startForegroundService( context, Intent(context, LinkService::class.java), ) } } }