package dev.stave import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection import android.content.pm.PackageManager import android.content.res.Configuration import android.net.wifi.WifiManager import android.os.Bundle import android.os.IBinder import android.view.KeyEvent import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint import dev.stave.playback.PlaybackService import dev.stave.ui.nav.EarhornNavHost import dev.stave.ui.theme.EarhornTheme import dev.stave.upnp.UpnpServiceHolder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jupnp.android.AndroidUpnpService import org.jupnp.android.AndroidUpnpServiceImpl import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { @Inject lateinit var upnpServiceHolder: UpnpServiceHolder @Inject lateinit var castIntegration: dev.stave.cast.CastIntegration @Inject lateinit var settingsStore: dev.stave.data.SettingsStore @Inject lateinit var rawCastObserver: dev.stave.cast.raw.RawCastObserver @Inject lateinit var playbackGate: dev.stave.playback.LocalPlaybackGate /** Reattach to a persisted observe target on EVERY return to * foreground, not just process restart. Covers the case where the * socket died while backgrounded (Wi-Fi power-save, doze) and the * observer's in-session reconnect supervisor exhausted its attempts * and fell back to local: the persisted target survives that, and * the moment the user comes back we quietly resume following — if * the CCA session is still live. reattachIfPersisted() is a cheap * no-op when already observing or nothing is persisted. */ override fun onResume() { super.onResume() // Idle-release watchdog input: a person looking at Now Playing keeps // an idle session held; backgrounded, it becomes eligible for // release after the shared session-dead debounce. rawCastObserver.setUiForeground(true) } override fun onPause() { rawCastObserver.setUiForeground(false) super.onPause() } override fun onStart() { super.onStart() // RECEIVER_EXPORTED is mandatory: this receives another app's // broadcast, and on API 33+ NOT_EXPORTED would silently drop it — // reproducing exactly the silent failure this channel fixes. ContextCompat.registerReceiver( this, observeBroadcastReceiver, IntentFilter(ACTION_OBSERVE), ContextCompat.RECEIVER_EXPORTED, ) lifecycleScope.launch(Dispatchers.IO) { runCatching { rawCastObserver.reattachIfPersisted() } .onFailure { android.util.Log.w("EarhornCast", "onStart reattach failed", it) } } } override fun onStop() { runCatching { unregisterReceiver(observeBroadcastReceiver) } super.onStop() } private var multicastLock: WifiManager.MulticastLock? = null /** Route hardware volume buttons to the Cast speaker when a Cast * session is active. Foreground-only — the ForwardingPlayer's * device volume overrides in PlaybackService handle the background * case via the MediaSession. No double-fire because this returns * true (consuming the event) so the system doesn't also route it * through the session. */ override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (castIntegration.isCasting.value) { when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> { castIntegration.setDeviceVolume(castIntegration.getDeviceVolume() + 0.05) return true } KeyEvent.KEYCODE_VOLUME_DOWN -> { castIntegration.setDeviceVolume(castIntegration.getDeviceVolume() - 0.05) return true } } } // Maintainer, 2026-10-29: while following (observing) a Cast session the // volume keys changed this device's own volume, not the group's. Step // the observed session's volume by 5 — the same call the on-screen // volume uses while observing (NowPlayingViewModel.adjustVolume). if (rawCastObserver.observing.value) { val delta = when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> 5 KeyEvent.KEYCODE_VOLUME_DOWN -> -5 else -> 0 } if (delta != 0) { lifecycleScope.launch { val cur = rawCastObserver.playbackState.value.volumePercent rawCastObserver.setVolume((cur + delta).coerceIn(0, 100)) } return true } } return super.onKeyDown(keyCode, event) } /** Stream of (route, optional argument) pairs emitted whenever * the activity is launched or re-launched (singleTop) with * EXTRA_NAV_ROUTE set in the intent. EarhornNavHost collects * this and navigates accordingly. Replay buffer of zero — the * flow is purely event-based; if no collector is attached when * emit is called, the event is dropped, which is correct * behavior because there shouldn't be any window where the host * isn't collecting while the activity is alive. extraBufferCapacity * ensures tryEmit succeeds without suspending. */ private val navCommands = MutableSharedFlow>( replay = 0, extraBufferCapacity = 4, ) private val upnpConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { (binder as? AndroidUpnpService)?.let { upnpServiceHolder.attach(it) } } override fun onServiceDisconnected(name: ComponentName?) { upnpServiceHolder.detach() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Force landscape only on TV form factors. Phones and Android Auto // need portrait/auto-rotate support. Detection via the LEANBACK // system feature — present on all Android TV devices. val isTv = packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) if (isTv) { requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE } WindowCompat.setDecorFitsSystemWindows(window, false) // Maintainer, 2026-10-29: the screen was kept on unconditionally, so a // phone never slept (D12). TV: always on. Other devices: only while // following a Cast session (other screens follow the group constantly). val isTvUiMode = (resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_TELEVISION if (isTv || isTvUiMode) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { rawCastObserver.observing.collect { observing -> if (observing) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } } } // design decision 2026-09-02 (reverting an earlier one): every // device gets the UPnP engine AND kiosk automation at the same time, // unconditionally, exactly as 0.4.15. The gating on the automation // switch is gone. runCatching { acquireMulticastLock() } .onFailure { android.util.Log.w("EarhornUpnp", "Multicast lock unavailable", it) } // Restrict jUPnP's SSDP multicast bind to the actual WiFi/Ethernet interface // BEFORE binding the service. Without this, on phones with a VPN active // (NetGuard, ProtonVPN, Tailscale, etc.) jUPnP binds multicast to the VPN's // tun interface and never sees servers on the LAN. Read-once: this property // is consumed when AndroidUpnpServiceImpl constructs its NetworkAddressFactory, // so it must be set before bindService() below. dev.stave.upnp.NetworkInterfaceResolver.applyToSystemProperties(applicationContext) // Binding to jUPnP's AndroidUpnpServiceImpl can fail on some devices (Jetty transport // init, servlet class resolution). Let the UI render either way — the Servers screen // will just show an empty list instead of crashing the app. runCatching { bindService( Intent(this, AndroidUpnpServiceImpl::class.java), upnpConnection, Context.BIND_AUTO_CREATE ) }.onFailure { android.util.Log.e("EarhornUpnp", "bindService(AndroidUpnpServiceImpl) failed", it) } setContent { val useFraunces by settingsStore.useFraunces .collectAsState(initial = true) EarhornTheme(useFraunces = useFraunces) { EarhornNavHost(navCommands = navCommands) } } // Initial-intent extras: if the activity is launched COLD via a // notification deep-link tap, the extras arrive in the intent // that started the activity. handleNavExtras drains them into // the navCommands flow. Subsequent (warm-launch) intents flow // through onNewIntent below. Done AFTER setContent so // the LaunchedEffect collector inside EarhornNavHost has had // a chance to subscribe — otherwise the event would be dropped. handleNavExtras(intent) handleObserveIntent(intent) startKioskHandbackMonitor() startTerminalObserveFailureMonitor() startReturnAfterLocalMonitor() } /** design decision 2026-09-02: a fixed screen that was disarmed by a * confirmed "play here" goes back to kiosk when that local playback * stops. The gate reports the package; we re-arm and hand back. */ private fun startReturnAfterLocalMonitor() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { playbackGate.returnToHausAfterLocal.collectLatest { pkg -> if (pkg.isNullOrBlank()) return@collectLatest playbackGate.consumeReturnToHaus() android.util.Log.i("EarhornKiosk", "local playback stopped — returning to $pkg") runCatching { settingsStore.setKioskReturnPackage(pkg) } handBackBounded(pkg) } } } } /** F1 (maintainer spec 2026-09-02). The observer signals when a group * could not be reached inside its window. Then: * - ARMED (kiosk's OBSERVE set the return package this cycle): go * back to kiosk, retry until the launch lands. * - NOT ARMED (someone is using this device by hand, any mode): say * so once and stay put. Nothing is remembered, nothing overrides * the arming or the automation switch. * collectLatest: a cleared signal (fresh request, attach) cancels a * hand-back still retrying. */ private fun startTerminalObserveFailureMonitor() { val monitorStartedAt = android.os.SystemClock.elapsedRealtime() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { rawCastObserver.terminalObserveFailure .collectLatest { failure -> val failedGroup = failure?.first ?: return@collectLatest // A StateFlow keeps its last value: on every RESUME // this collector re-read a stale failure and handed // back to kiosk again — Earhorn vanished behind kiosk // each time it returned to the foreground (two // screens, 2026-09-02). Ignore anything from before // this monitor started, and consume what we act on. if (failure.second < monitorStartedAt) return@collectLatest rawCastObserver.consumeTerminalObserveFailure() val pkg = runCatching { settingsStore.kioskReturnPackage.first() }.getOrNull() if (pkg.isNullOrBlank()) { android.util.Log.w("EarhornKiosk", "observe of '$failedGroup' failed — not armed; staying put") android.widget.Toast.makeText( this@MainActivity, "Couldn't reach $failedGroup", android.widget.Toast.LENGTH_LONG, ).show() return@collectLatest } android.util.Log.w("EarhornKiosk", "observe of '$failedGroup' failed — armed; handing back to $pkg") handBackBounded(pkg) } } } } /** Receives intents for an already-running activity (launchMode * singleTop / singleTask). The default Android launcher intent * has FLAG_SINGLE_TOP, so a notification action that re-launches * our app while it's already on top arrives here rather than * re-creating the activity. */ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Update the activity's stored intent so getIntent() reflects // the new extras for any code path that reads it later. setIntent(intent) handleNavExtras(intent) handleObserveIntent(intent) } /** * E1 entry point for the voice-cast auto-observe pipeline: FaveatHaus * (or `adb shell am start -a com.faveat.earhorn.action.OBSERVE --es * group "0Up"`, or the earhorn://observe?group=… deep link) names a Cast * group; we open Now Playing observing it. * * The heavy lifting — discovery by friendly name, idempotence, the * grace window for a launch that races the cast — lives in * [dev.stave.cast.raw.RawCastObserver.requestObserveByName], which is * fire-and-forget on an application-scoped coroutine: the attach outcome * must not depend on this activity staying resumed (FaveatHaus will take * the foreground back on "stopped", and we tolerate that silently — * we never re-launch ourselves or otherwise fight for focus). * * The handled action/data/extra are consumed so a config-change * recreation (rotation on phone form factors re-runs onCreate with the * original intent) doesn't replay the navigation. */ private fun handleObserveIntent(intent: Intent?) { intent ?: return val isAction = intent.action == ACTION_OBSERVE val isLink = intent.action == Intent.ACTION_VIEW && intent.data?.scheme == OBSERVE_SCHEME && intent.data?.host == OBSERVE_HOST if (!isAction && !isLink) return val group: String? = if (isAction) intent.getStringExtra(EXTRA_OBSERVE_GROUP) else intent.data?.getQueryParameter(EXTRA_OBSERVE_GROUP) // Maintainer, 2026-10-29: the earhorn://observe link is BROWSABLE, so any // web page can fire it — its returnTo would arm hand-back to an // arbitrary package. Honour returnTo only on the explicit OBSERVE // action (and the broadcast), FaveatHaus's contract (D10). val returnTo: String? = if (isAction) intent.getStringExtra(EXTRA_OBSERVE_RETURN_TO) else null val name = group?.trim().orEmpty() if (name.isEmpty()) return intent.action = null intent.data = null intent.removeExtra(EXTRA_OBSERVE_GROUP) intent.removeExtra(EXTRA_OBSERVE_RETURN_TO) applyObserveRequest(name, returnTo) } /** Shared endpoint for both OBSERVE deliveries — the activity intent * (cold-start path) and the runtime broadcast (foreground path, memo * 2026-08-29). Arms hand-back ONLY when the launcher asked for it, so * a user opening Earhorn by hand can never be yanked to another app; * persisted so process death mid-observation keeps kiosk semantics. * Routes into the idempotent observe path: same grace window, same * newest-wins, no new state. */ private var lastObserveGroup: String? = null private var lastObserveAtMs = 0L private fun applyObserveRequest(group: String, returnTo: String?) { // Dual delivery is the NORM while Earhorn is foreground: the OS // permits FaveatHaus's startActivity into the already-foreground // task, so the broadcast AND the activity intent both land, ~20 ms // to ~600 ms apart (log, 2026-08-29). requestObserveByName is // idempotent when attached, but two requests racing BEFORE // attachment each spin the newest-wins machinery — one of the two // ingredients of the orphaned-lineage storm. Collapse them here. val now = android.os.SystemClock.elapsedRealtime() if (group == lastObserveGroup && now - lastObserveAtMs < 2_000) { android.util.Log.i("EarhornKiosk", "duplicate OBSERVE for '$group' within 2s — collapsed") return } lastObserveGroup = group lastObserveAtMs = now lifecycleScope.launch { val flip = runCatching { settingsStore.kioskFlipEnabled.first() }.getOrDefault(true) if (!flip) { android.util.Log.i("EarhornKiosk", "OBSERVE for '$group' ignored — kiosk flip disabled on this device") // Maintainer, 2026-10-29: the activity was brought forward, so say // why nothing follows (D11). lifecycleScope runs on Main. android.widget.Toast.makeText( this@MainActivity, "Haus automation is off on this device", android.widget.Toast.LENGTH_LONG, ).show() return@launch } applyObserveRequestGated(group, returnTo) } } private fun applyObserveRequestGated(group: String, returnTo: String?) { returnTo?.trim()?.takeIf { it.isNotEmpty() }?.let { pkg -> lifecycleScope.launch(Dispatchers.IO) { runCatching { settingsStore.setKioskReturnPackage(pkg) } } } rawCastObserver.requestObserveByName(group) emitNavCommand(PlaybackService.NAV_NOW_PLAYING, null) } /** Foreground OBSERVE delivery. While Earhorn is showing, the OS drops * FaveatHaus's startActivity, so FaveatHaus also broadcasts the SAME * action with the same extras (agreed contract: one string, two * deliveries). Registered onStart / unregistered onStop; a broadcast * arriving while unregistered is covered by the activity path. */ private val observeBroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action != ACTION_OBSERVE) return val group = intent.getStringExtra(EXTRA_OBSERVE_GROUP)?.trim().orEmpty() if (group.isEmpty()) return val returnTo = intent.getStringExtra(EXTRA_OBSERVE_RETURN_TO) android.util.Log.i("EarhornKiosk", "broadcast OBSERVE received: group='$group' returnTo='$returnTo'") applyObserveRequest(group, returnTo) } } /** Deliver a nav command reliably even on a COLD launch. navCommands has * replay=0, and setContent returns before first composition — so an * emit from onCreate races the NavHost collector's subscription, and a * tryEmit into a replay-0 SharedFlow with zero subscribers is silently * dropped. Field symptom of exactly that: a cold OBSERVE launch * attached fine but landed on Browse with the observed track only in * the bar. Await the first subscriber, then emit; on the warm path * subscriptionCount is already ≥ 1 and this delivers immediately. */ private fun emitNavCommand(route: String, arg: String?) { lifecycleScope.launch { navCommands.subscriptionCount.first { it > 0 } navCommands.emit(route to arg) } } // ── Kiosk hand-back ────────────────────────────────────────── // // The other half of the voice-cast pipeline's focus choreography. The // division of labor exists because of the Android 10+ / ChromeOS ARC // background-activity-start restriction: an app may only bring an // activity forward while it is itself foreground. So FaveatHaus // (foreground) launches us on "playing", and WE (foreground, holding the // live session socket — the ground truth) launch FaveatHaus back when // the session genuinely ends. Neither app ever needs HOME status or an // overlay permission, on Android TV or ChromeOS. // // "Genuinely ends" is the load-bearing phrase. CCA streams flap: // Wi-Fi blips, group-leader handoffs, sub-second IDLE at track // boundaries, receiver reload between albums. The monitor therefore // requires the session to be continuously absent for // KIOSK_HANDBACK_DEBOUNCE_MS before acting — long enough to outlast the // observer's own reconnect supervisor (~15 s worst case), so a dropped // TLS connection that recovers never bounces the screen. Any sign of // life (MEDIA_STATUS with a non-IDLE session, or a fresh observe attempt // pending) cancels the countdown and restarts it from zero next time. // // Scoped to RESUMED: if the user (or FaveatHaus, or anything else) has // already taken the screen, we are not the foreground owner and do // nothing — never fight for focus, in either direction. /** Quiet period with no live session before handing the screen back — * the fleet-shared definition of "genuinely dead". See * [dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS]. */ private val kioskHandbackDebounceMs = dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS private var lastHandbackAt = 0L private val HANDBACK_ATTEMPTS = 3 private val HANDBACK_RETRY_MS = 30_000L private fun startKioskHandbackMonitor() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { // distinctUntilChanged is load-bearing: DataStore's data // flow re-emits on ANY preference write (e.g. the observer // persisting its reattach target), and collectLatest would // restart the inner countdown each time — silently making // the debounce unbounded. // The switch governs this rule too: with automation off, // a remembered kiosk is never acted on (decision, 2026-09-02: // both screens were sent to kiosk 20 s after a manual open // with the switch off, because this rule only ever looked // at the remembered package). combine( settingsStore.kioskReturnPackage, settingsStore.kioskFlipEnabled, ) { pkg, on -> if (on) pkg else null } .distinctUntilChanged() .collectLatest { pkg -> if (pkg.isNullOrBlank()) return@collectLatest combine( rawCastObserver.sessionLive, rawCastObserver.pendingTargetName, rawCastObserver.uiPanelOpen, ) { live, pending, panelOpen -> // "Present": a live (playing/paused/buffering) // session, an attach attempt still inside its grace // window, or a person actively using the Dom-Etha // panel. Merely-connected-but-idle does NOT count — // a finished queue is exactly what hand-back is for. live || pending != null || panelOpen } .distinctUntilChanged() .collectLatest { present -> if (present) return@collectLatest delay(kioskHandbackDebounceMs) // Retry until it lands or presence returns. // collectLatest cancels this loop the moment a // session comes back; a successful launch // disarms, which cancels via the outer settings // collector. A FAILED launch must NOT end the // attempt — that was the strand: one failure // used to eat the arming forever. handBackBounded(pkg) } } } } } /** One hand-back attempt. Returns true when the launch actually * happened (only then is the arming cleared); false = stay armed so * the caller retries. The original order — disarm FIRST, launch * second — converted any launch failure into a permanently stranded * screen: arming eaten, no FaveatHaus, and since Earhorn remains * foreground the OS drops every subsequent OBSERVE, so nothing could * ever re-arm. Observed on three devices at once, 2026-08-28: leader * freeze → supervisor exhausted → local fallback (persisted queue, * paused mid-track) → simultaneous hand-back attempts → failed launch * → silent permanent strand. Disarm now happens strictly AFTER a * successful startActivity. */ /** design decision 2026-09-02: "not trying every 30 seconds for very * long. If kiosk isn't there, it isn't there." HANDBACK_ATTEMPTS tries, * HANDBACK_RETRY_MS apart; then stop, leave Browse on the screen (the * failure branch of fireKioskHandback already put it there) and log once. */ private suspend fun handBackBounded(pkg: String) { // Never with the switch off, whatever remembered kiosk led here. val on = runCatching { settingsStore.kioskFlipEnabled.first() }.getOrDefault(false) if (!on) { android.util.Log.i("EarhornKiosk", "automation off — not going back to $pkg") return } repeat(HANDBACK_ATTEMPTS) { attempt -> if (fireKioskHandback(pkg)) return if (attempt < HANDBACK_ATTEMPTS - 1) delay(HANDBACK_RETRY_MS) } android.util.Log.e("EarhornKiosk", "could not launch $pkg after $HANDBACK_ATTEMPTS attempts — staying on Browse; not retrying") } private suspend fun fireKioskHandback(pkg: String): Boolean { val now = android.os.SystemClock.elapsedRealtime() if (now - lastHandbackAt < 10_000) return false lastHandbackAt = now android.util.Log.i("EarhornKiosk", "observed session over — handing back to $pkg") // design decision 2026-09-02 (I.9): open kiosk FIRST. The old order // disconnected and wiped the screen, then tried the launch; a failed // launch left the screen blank for the 30 s retry cycle. Now the // observation stands down only after kiosk is actually on its way, // and a failed launch shows Browse — never a blank screen. val launch = packageManager.getLaunchIntentForPackage(pkg) ?: packageManager.getLeanbackLaunchIntentForPackage(pkg) val launched = if (launch == null) { android.util.Log.w("EarhornKiosk", "no launch intent for $pkg (not installed / not visible) — staying ARMED, will retry") false } else { runCatching { startActivity(launch) } .onFailure { android.util.Log.w("EarhornKiosk", "hand-back launch failed — staying ARMED, will retry", it) } .isSuccess } if (launched) { // Reattach must not resurrect an idle observer; idempotent. runCatching { rawCastObserver.disconnect(clearPersisted = true) } runCatching { settingsStore.setKioskReturnPackage(null) } } else { // kiosk won't open: the session is over, so release it, but put // Browse on the screen instead of nothing while we keep trying. runCatching { rawCastObserver.disconnect(clearPersisted = true) } emitNavCommand(PlaybackService.NAV_BROWSE, null) } return launched } companion object { /** Exported observe action — shared contract with FaveatHaus. The * string is part of the group wire protocol; do NOT rename to match * the applicationId. */ const val ACTION_OBSERVE = "com.faveat.earhorn.action.OBSERVE" /** String extra / query parameter: Cast group friendly name, exactly * as Google Home spells it (UTF-8 — spaces and umlauts intact, * never ASCII-folded). */ const val EXTRA_OBSERVE_GROUP = "group" /** Optional String extra / query parameter: package name to bring * forward when the observed session genuinely ends (kiosk * hand-back). Omitted → Earhorn never changes foreground apps. */ const val EXTRA_OBSERVE_RETURN_TO = "returnTo" const val OBSERVE_SCHEME = "earhorn" const val OBSERVE_HOST = "observe" } /** Drain notification deep-link extras into the navCommands flow. * Removes the extras after reading so a subsequent rotation / * config-change recreation doesn't replay the same nav. The * initial-cold-launch path may pass null (no intent yet, or * intent has no extras of interest); the warm-relaunch path * through onNewIntent always passes non-null. */ private fun handleNavExtras(intent: Intent?) { val route = intent?.getStringExtra(PlaybackService.EXTRA_NAV_ROUTE) ?: return val arg = intent.getStringExtra(PlaybackService.EXTRA_NAV_ARG) intent.removeExtra(PlaybackService.EXTRA_NAV_ROUTE) intent.removeExtra(PlaybackService.EXTRA_NAV_ARG) emitNavCommand(route, arg) } private fun acquireMulticastLock() { val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return multicastLock = wifi.createMulticastLock("earhorn-ssdp").apply { setReferenceCounted(false) acquire() } } override fun onDestroy() { runCatching { unbindService(upnpConnection) } multicastLock?.takeIf { it.isHeld }?.release() multicastLock = null super.onDestroy() } }