MainActivity.kt
638 lines · 32.0 KB
Changed in: 0.5.0
| 1 | package dev.stave |
| 2 | |
| 3 | import android.content.BroadcastReceiver |
| 4 | import android.content.ComponentName |
| 5 | import android.content.Context |
| 6 | import android.content.Intent |
| 7 | import android.content.IntentFilter |
| 8 | import android.content.ServiceConnection |
| 9 | import android.content.pm.PackageManager |
| 10 | import android.content.res.Configuration |
| 11 | import android.net.wifi.WifiManager |
| 12 | import android.os.Bundle |
| 13 | import android.os.IBinder |
| 14 | import android.view.KeyEvent |
| 15 | import android.view.WindowManager |
| 16 | import androidx.activity.ComponentActivity |
| 17 | import androidx.activity.compose.setContent |
| 18 | import androidx.compose.runtime.collectAsState |
| 19 | import androidx.compose.runtime.getValue |
| 20 | import androidx.core.content.ContextCompat |
| 21 | import androidx.core.view.WindowCompat |
| 22 | import androidx.lifecycle.Lifecycle |
| 23 | import androidx.lifecycle.lifecycleScope |
| 24 | import androidx.lifecycle.repeatOnLifecycle |
| 25 | import dagger.hilt.android.AndroidEntryPoint |
| 26 | import dev.stave.playback.PlaybackService |
| 27 | import dev.stave.ui.nav.EarhornNavHost |
| 28 | import dev.stave.ui.theme.EarhornTheme |
| 29 | import dev.stave.upnp.UpnpServiceHolder |
| 30 | import kotlinx.coroutines.Dispatchers |
| 31 | import kotlinx.coroutines.delay |
| 32 | import kotlinx.coroutines.flow.MutableSharedFlow |
| 33 | import kotlinx.coroutines.flow.collectLatest |
| 34 | import kotlinx.coroutines.flow.combine |
| 35 | import kotlinx.coroutines.flow.distinctUntilChanged |
| 36 | import kotlinx.coroutines.flow.first |
| 37 | import kotlinx.coroutines.launch |
| 38 | import org.jupnp.android.AndroidUpnpService |
| 39 | import org.jupnp.android.AndroidUpnpServiceImpl |
| 40 | import javax.inject.Inject |
| 41 | |
| 42 | @AndroidEntryPoint |
| 43 | class MainActivity : ComponentActivity() { |
| 44 | |
| 45 | @Inject lateinit var upnpServiceHolder: UpnpServiceHolder |
| 46 | @Inject lateinit var castIntegration: dev.stave.cast.CastIntegration |
| 47 | @Inject lateinit var settingsStore: dev.stave.data.SettingsStore |
| 48 | @Inject lateinit var rawCastObserver: dev.stave.cast.raw.RawCastObserver |
| 49 | @Inject lateinit var playbackGate: dev.stave.playback.LocalPlaybackGate |
| 50 | |
| 51 | /** Reattach to a persisted observe target on EVERY return to |
| 52 | * foreground, not just process restart. Covers the case where the |
| 53 | * socket died while backgrounded (Wi-Fi power-save, doze) and the |
| 54 | * observer's in-session reconnect supervisor exhausted its attempts |
| 55 | * and fell back to local: the persisted target survives that, and |
| 56 | * the moment the user comes back we quietly resume following — if |
| 57 | * the CCA session is still live. reattachIfPersisted() is a cheap |
| 58 | * no-op when already observing or nothing is persisted. */ |
| 59 | override fun onResume() { |
| 60 | super.onResume() |
| 61 | // Idle-release watchdog input: a person looking at Now Playing keeps |
| 62 | // an idle session held; backgrounded, it becomes eligible for |
| 63 | // release after the shared session-dead debounce. |
| 64 | rawCastObserver.setUiForeground(true) |
| 65 | } |
| 66 | |
| 67 | override fun onPause() { |
| 68 | rawCastObserver.setUiForeground(false) |
| 69 | super.onPause() |
| 70 | } |
| 71 | |
| 72 | override fun onStart() { |
| 73 | super.onStart() |
| 74 | // RECEIVER_EXPORTED is mandatory: this receives another app's |
| 75 | // broadcast, and on API 33+ NOT_EXPORTED would silently drop it — |
| 76 | // reproducing exactly the silent failure this channel fixes. |
| 77 | ContextCompat.registerReceiver( |
| 78 | this, |
| 79 | observeBroadcastReceiver, |
| 80 | IntentFilter(ACTION_OBSERVE), |
| 81 | ContextCompat.RECEIVER_EXPORTED, |
| 82 | ) |
| 83 | lifecycleScope.launch(Dispatchers.IO) { |
| 84 | runCatching { rawCastObserver.reattachIfPersisted() } |
| 85 | .onFailure { android.util.Log.w("EarhornCast", "onStart reattach failed", it) } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | override fun onStop() { |
| 90 | runCatching { unregisterReceiver(observeBroadcastReceiver) } |
| 91 | super.onStop() |
| 92 | } |
| 93 | |
| 94 | private var multicastLock: WifiManager.MulticastLock? = null |
| 95 | |
| 96 | /** Route hardware volume buttons to the Cast speaker when a Cast |
| 97 | * session is active. Foreground-only — the ForwardingPlayer's |
| 98 | * device volume overrides in PlaybackService handle the background |
| 99 | * case via the MediaSession. No double-fire because this returns |
| 100 | * true (consuming the event) so the system doesn't also route it |
| 101 | * through the session. */ |
| 102 | override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { |
| 103 | if (castIntegration.isCasting.value) { |
| 104 | when (keyCode) { |
| 105 | KeyEvent.KEYCODE_VOLUME_UP -> { |
| 106 | castIntegration.setDeviceVolume(castIntegration.getDeviceVolume() + 0.05) |
| 107 | return true |
| 108 | } |
| 109 | KeyEvent.KEYCODE_VOLUME_DOWN -> { |
| 110 | castIntegration.setDeviceVolume(castIntegration.getDeviceVolume() - 0.05) |
| 111 | return true |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | // Maintainer, 2026-10-29: while following (observing) a Cast session the |
| 116 | // volume keys changed this device's own volume, not the group's. Step |
| 117 | // the observed session's volume by 5 — the same call the on-screen |
| 118 | // volume uses while observing (NowPlayingViewModel.adjustVolume). |
| 119 | if (rawCastObserver.observing.value) { |
| 120 | val delta = when (keyCode) { |
| 121 | KeyEvent.KEYCODE_VOLUME_UP -> 5 |
| 122 | KeyEvent.KEYCODE_VOLUME_DOWN -> -5 |
| 123 | else -> 0 |
| 124 | } |
| 125 | if (delta != 0) { |
| 126 | lifecycleScope.launch { |
| 127 | val cur = rawCastObserver.playbackState.value.volumePercent |
| 128 | rawCastObserver.setVolume((cur + delta).coerceIn(0, 100)) |
| 129 | } |
| 130 | return true |
| 131 | } |
| 132 | } |
| 133 | return super.onKeyDown(keyCode, event) |
| 134 | } |
| 135 | |
| 136 | /** Stream of (route, optional argument) pairs emitted whenever |
| 137 | * the activity is launched or re-launched (singleTop) with |
| 138 | * EXTRA_NAV_ROUTE set in the intent. EarhornNavHost collects |
| 139 | * this and navigates accordingly. Replay buffer of zero — the |
| 140 | * flow is purely event-based; if no collector is attached when |
| 141 | * emit is called, the event is dropped, which is correct |
| 142 | * behavior because there shouldn't be any window where the host |
| 143 | * isn't collecting while the activity is alive. extraBufferCapacity |
| 144 | * ensures tryEmit succeeds without suspending. */ |
| 145 | private val navCommands = MutableSharedFlow<Pair<String, String?>>( |
| 146 | replay = 0, |
| 147 | extraBufferCapacity = 4, |
| 148 | ) |
| 149 | |
| 150 | private val upnpConnection = object : ServiceConnection { |
| 151 | override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { |
| 152 | (binder as? AndroidUpnpService)?.let { upnpServiceHolder.attach(it) } |
| 153 | } |
| 154 | |
| 155 | override fun onServiceDisconnected(name: ComponentName?) { |
| 156 | upnpServiceHolder.detach() |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | override fun onCreate(savedInstanceState: Bundle?) { |
| 161 | super.onCreate(savedInstanceState) |
| 162 | |
| 163 | // Force landscape only on TV form factors. Phones and Android Auto |
| 164 | // need portrait/auto-rotate support. Detection via the LEANBACK |
| 165 | // system feature — present on all Android TV devices. |
| 166 | val isTv = packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) |
| 167 | if (isTv) { |
| 168 | requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE |
| 169 | } |
| 170 | |
| 171 | WindowCompat.setDecorFitsSystemWindows(window, false) |
| 172 | // Maintainer, 2026-10-29: the screen was kept on unconditionally, so a |
| 173 | // phone never slept (D12). TV: always on. Other devices: only while |
| 174 | // following a Cast session (other screens follow the group constantly). |
| 175 | val isTvUiMode = (resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK) == |
| 176 | Configuration.UI_MODE_TYPE_TELEVISION |
| 177 | if (isTv || isTvUiMode) { |
| 178 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) |
| 179 | } else { |
| 180 | lifecycleScope.launch { |
| 181 | repeatOnLifecycle(Lifecycle.State.STARTED) { |
| 182 | rawCastObserver.observing.collect { observing -> |
| 183 | if (observing) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) |
| 184 | else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // design decision 2026-09-02 (reverting an earlier one): every |
| 191 | // device gets the UPnP engine AND kiosk automation at the same time, |
| 192 | // unconditionally, exactly as 0.4.15. The gating on the automation |
| 193 | // switch is gone. |
| 194 | runCatching { acquireMulticastLock() } |
| 195 | .onFailure { android.util.Log.w("EarhornUpnp", "Multicast lock unavailable", it) } |
| 196 | |
| 197 | // Restrict jUPnP's SSDP multicast bind to the actual WiFi/Ethernet interface |
| 198 | // BEFORE binding the service. Without this, on phones with a VPN active |
| 199 | // (NetGuard, ProtonVPN, Tailscale, etc.) jUPnP binds multicast to the VPN's |
| 200 | // tun interface and never sees servers on the LAN. Read-once: this property |
| 201 | // is consumed when AndroidUpnpServiceImpl constructs its NetworkAddressFactory, |
| 202 | // so it must be set before bindService() below. |
| 203 | dev.stave.upnp.NetworkInterfaceResolver.applyToSystemProperties(applicationContext) |
| 204 | |
| 205 | // Binding to jUPnP's AndroidUpnpServiceImpl can fail on some devices (Jetty transport |
| 206 | // init, servlet class resolution). Let the UI render either way — the Servers screen |
| 207 | // will just show an empty list instead of crashing the app. |
| 208 | runCatching { |
| 209 | bindService( |
| 210 | Intent(this, AndroidUpnpServiceImpl::class.java), |
| 211 | upnpConnection, |
| 212 | Context.BIND_AUTO_CREATE |
| 213 | ) |
| 214 | }.onFailure { android.util.Log.e("EarhornUpnp", "bindService(AndroidUpnpServiceImpl) failed", it) } |
| 215 | |
| 216 | setContent { |
| 217 | val useFraunces by settingsStore.useFraunces |
| 218 | .collectAsState(initial = true) |
| 219 | EarhornTheme(useFraunces = useFraunces) { |
| 220 | EarhornNavHost(navCommands = navCommands) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | // Initial-intent extras: if the activity is launched COLD via a |
| 225 | // notification deep-link tap, the extras arrive in the intent |
| 226 | // that started the activity. handleNavExtras drains them into |
| 227 | // the navCommands flow. Subsequent (warm-launch) intents flow |
| 228 | // through onNewIntent below. Done AFTER setContent so |
| 229 | // the LaunchedEffect collector inside EarhornNavHost has had |
| 230 | // a chance to subscribe — otherwise the event would be dropped. |
| 231 | handleNavExtras(intent) |
| 232 | handleObserveIntent(intent) |
| 233 | startKioskHandbackMonitor() |
| 234 | startTerminalObserveFailureMonitor() |
| 235 | startReturnAfterLocalMonitor() |
| 236 | } |
| 237 | |
| 238 | /** design decision 2026-09-02: a fixed screen that was disarmed by a |
| 239 | * confirmed "play here" goes back to kiosk when that local playback |
| 240 | * stops. The gate reports the package; we re-arm and hand back. */ |
| 241 | private fun startReturnAfterLocalMonitor() { |
| 242 | lifecycleScope.launch { |
| 243 | repeatOnLifecycle(Lifecycle.State.RESUMED) { |
| 244 | playbackGate.returnToHausAfterLocal.collectLatest { pkg -> |
| 245 | if (pkg.isNullOrBlank()) return@collectLatest |
| 246 | playbackGate.consumeReturnToHaus() |
| 247 | android.util.Log.i("EarhornKiosk", "local playback stopped — returning to $pkg") |
| 248 | runCatching { settingsStore.setKioskReturnPackage(pkg) } |
| 249 | handBackBounded(pkg) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /** F1 (maintainer spec 2026-09-02). The observer signals when a group |
| 256 | * could not be reached inside its window. Then: |
| 257 | * - ARMED (kiosk's OBSERVE set the return package this cycle): go |
| 258 | * back to kiosk, retry until the launch lands. |
| 259 | * - NOT ARMED (someone is using this device by hand, any mode): say |
| 260 | * so once and stay put. Nothing is remembered, nothing overrides |
| 261 | * the arming or the automation switch. |
| 262 | * collectLatest: a cleared signal (fresh request, attach) cancels a |
| 263 | * hand-back still retrying. */ |
| 264 | private fun startTerminalObserveFailureMonitor() { |
| 265 | val monitorStartedAt = android.os.SystemClock.elapsedRealtime() |
| 266 | lifecycleScope.launch { |
| 267 | repeatOnLifecycle(Lifecycle.State.RESUMED) { |
| 268 | rawCastObserver.terminalObserveFailure |
| 269 | .collectLatest { failure -> |
| 270 | val failedGroup = failure?.first ?: return@collectLatest |
| 271 | // A StateFlow keeps its last value: on every RESUME |
| 272 | // this collector re-read a stale failure and handed |
| 273 | // back to kiosk again — Earhorn vanished behind kiosk |
| 274 | // each time it returned to the foreground (two |
| 275 | // screens, 2026-09-02). Ignore anything from before |
| 276 | // this monitor started, and consume what we act on. |
| 277 | if (failure.second < monitorStartedAt) return@collectLatest |
| 278 | rawCastObserver.consumeTerminalObserveFailure() |
| 279 | val pkg = runCatching { settingsStore.kioskReturnPackage.first() }.getOrNull() |
| 280 | if (pkg.isNullOrBlank()) { |
| 281 | android.util.Log.w("EarhornKiosk", |
| 282 | "observe of '$failedGroup' failed — not armed; staying put") |
| 283 | android.widget.Toast.makeText( |
| 284 | this@MainActivity, |
| 285 | "Couldn't reach $failedGroup", |
| 286 | android.widget.Toast.LENGTH_LONG, |
| 287 | ).show() |
| 288 | return@collectLatest |
| 289 | } |
| 290 | android.util.Log.w("EarhornKiosk", |
| 291 | "observe of '$failedGroup' failed — armed; handing back to $pkg") |
| 292 | handBackBounded(pkg) |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | /** Receives intents for an already-running activity (launchMode |
| 299 | * singleTop / singleTask). The default Android launcher intent |
| 300 | * has FLAG_SINGLE_TOP, so a notification action that re-launches |
| 301 | * our app while it's already on top arrives here rather than |
| 302 | * re-creating the activity. */ |
| 303 | override fun onNewIntent(intent: Intent) { |
| 304 | super.onNewIntent(intent) |
| 305 | // Update the activity's stored intent so getIntent() reflects |
| 306 | // the new extras for any code path that reads it later. |
| 307 | setIntent(intent) |
| 308 | handleNavExtras(intent) |
| 309 | handleObserveIntent(intent) |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * E1 entry point for the voice-cast auto-observe pipeline: FaveatHaus |
| 314 | * (or `adb shell am start -a com.faveat.earhorn.action.OBSERVE --es |
| 315 | * group "0Up"`, or the earhorn://observe?group=… deep link) names a Cast |
| 316 | * group; we open Now Playing observing it. |
| 317 | * |
| 318 | * The heavy lifting — discovery by friendly name, idempotence, the |
| 319 | * grace window for a launch that races the cast — lives in |
| 320 | * [dev.stave.cast.raw.RawCastObserver.requestObserveByName], which is |
| 321 | * fire-and-forget on an application-scoped coroutine: the attach outcome |
| 322 | * must not depend on this activity staying resumed (FaveatHaus will take |
| 323 | * the foreground back on "stopped", and we tolerate that silently — |
| 324 | * we never re-launch ourselves or otherwise fight for focus). |
| 325 | * |
| 326 | * The handled action/data/extra are consumed so a config-change |
| 327 | * recreation (rotation on phone form factors re-runs onCreate with the |
| 328 | * original intent) doesn't replay the navigation. |
| 329 | */ |
| 330 | private fun handleObserveIntent(intent: Intent?) { |
| 331 | intent ?: return |
| 332 | val isAction = intent.action == ACTION_OBSERVE |
| 333 | val isLink = intent.action == Intent.ACTION_VIEW && |
| 334 | intent.data?.scheme == OBSERVE_SCHEME && |
| 335 | intent.data?.host == OBSERVE_HOST |
| 336 | if (!isAction && !isLink) return |
| 337 | val group: String? = |
| 338 | if (isAction) intent.getStringExtra(EXTRA_OBSERVE_GROUP) |
| 339 | else intent.data?.getQueryParameter(EXTRA_OBSERVE_GROUP) |
| 340 | // Maintainer, 2026-10-29: the earhorn://observe link is BROWSABLE, so any |
| 341 | // web page can fire it — its returnTo would arm hand-back to an |
| 342 | // arbitrary package. Honour returnTo only on the explicit OBSERVE |
| 343 | // action (and the broadcast), FaveatHaus's contract (D10). |
| 344 | val returnTo: String? = |
| 345 | if (isAction) intent.getStringExtra(EXTRA_OBSERVE_RETURN_TO) |
| 346 | else null |
| 347 | val name = group?.trim().orEmpty() |
| 348 | if (name.isEmpty()) return |
| 349 | intent.action = null |
| 350 | intent.data = null |
| 351 | intent.removeExtra(EXTRA_OBSERVE_GROUP) |
| 352 | intent.removeExtra(EXTRA_OBSERVE_RETURN_TO) |
| 353 | applyObserveRequest(name, returnTo) |
| 354 | } |
| 355 | |
| 356 | /** Shared endpoint for both OBSERVE deliveries — the activity intent |
| 357 | * (cold-start path) and the runtime broadcast (foreground path, memo |
| 358 | * 2026-08-29). Arms hand-back ONLY when the launcher asked for it, so |
| 359 | * a user opening Earhorn by hand can never be yanked to another app; |
| 360 | * persisted so process death mid-observation keeps kiosk semantics. |
| 361 | * Routes into the idempotent observe path: same grace window, same |
| 362 | * newest-wins, no new state. */ |
| 363 | private var lastObserveGroup: String? = null |
| 364 | private var lastObserveAtMs = 0L |
| 365 | |
| 366 | private fun applyObserveRequest(group: String, returnTo: String?) { |
| 367 | // Dual delivery is the NORM while Earhorn is foreground: the OS |
| 368 | // permits FaveatHaus's startActivity into the already-foreground |
| 369 | // task, so the broadcast AND the activity intent both land, ~20 ms |
| 370 | // to ~600 ms apart (log, 2026-08-29). requestObserveByName is |
| 371 | // idempotent when attached, but two requests racing BEFORE |
| 372 | // attachment each spin the newest-wins machinery — one of the two |
| 373 | // ingredients of the orphaned-lineage storm. Collapse them here. |
| 374 | val now = android.os.SystemClock.elapsedRealtime() |
| 375 | if (group == lastObserveGroup && now - lastObserveAtMs < 2_000) { |
| 376 | android.util.Log.i("EarhornKiosk", |
| 377 | "duplicate OBSERVE for '$group' within 2s — collapsed") |
| 378 | return |
| 379 | } |
| 380 | lastObserveGroup = group |
| 381 | lastObserveAtMs = now |
| 382 | lifecycleScope.launch { |
| 383 | val flip = runCatching { |
| 384 | settingsStore.kioskFlipEnabled.first() |
| 385 | }.getOrDefault(true) |
| 386 | if (!flip) { |
| 387 | android.util.Log.i("EarhornKiosk", |
| 388 | "OBSERVE for '$group' ignored — kiosk flip disabled on this device") |
| 389 | // Maintainer, 2026-10-29: the activity was brought forward, so say |
| 390 | // why nothing follows (D11). lifecycleScope runs on Main. |
| 391 | android.widget.Toast.makeText( |
| 392 | this@MainActivity, |
| 393 | "Haus automation is off on this device", |
| 394 | android.widget.Toast.LENGTH_LONG, |
| 395 | ).show() |
| 396 | return@launch |
| 397 | } |
| 398 | applyObserveRequestGated(group, returnTo) |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | private fun applyObserveRequestGated(group: String, returnTo: String?) { |
| 403 | returnTo?.trim()?.takeIf { it.isNotEmpty() }?.let { pkg -> |
| 404 | lifecycleScope.launch(Dispatchers.IO) { |
| 405 | runCatching { settingsStore.setKioskReturnPackage(pkg) } |
| 406 | } |
| 407 | } |
| 408 | rawCastObserver.requestObserveByName(group) |
| 409 | emitNavCommand(PlaybackService.NAV_NOW_PLAYING, null) |
| 410 | } |
| 411 | |
| 412 | /** Foreground OBSERVE delivery. While Earhorn is showing, the OS drops |
| 413 | * FaveatHaus's startActivity, so FaveatHaus also broadcasts the SAME |
| 414 | * action with the same extras (agreed contract: one string, two |
| 415 | * deliveries). Registered onStart / unregistered onStop; a broadcast |
| 416 | * arriving while unregistered is covered by the activity path. */ |
| 417 | private val observeBroadcastReceiver = object : BroadcastReceiver() { |
| 418 | override fun onReceive(context: Context, intent: Intent) { |
| 419 | if (intent.action != ACTION_OBSERVE) return |
| 420 | val group = intent.getStringExtra(EXTRA_OBSERVE_GROUP)?.trim().orEmpty() |
| 421 | if (group.isEmpty()) return |
| 422 | val returnTo = intent.getStringExtra(EXTRA_OBSERVE_RETURN_TO) |
| 423 | android.util.Log.i("EarhornKiosk", |
| 424 | "broadcast OBSERVE received: group='$group' returnTo='$returnTo'") |
| 425 | applyObserveRequest(group, returnTo) |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | /** Deliver a nav command reliably even on a COLD launch. navCommands has |
| 430 | * replay=0, and setContent returns before first composition — so an |
| 431 | * emit from onCreate races the NavHost collector's subscription, and a |
| 432 | * tryEmit into a replay-0 SharedFlow with zero subscribers is silently |
| 433 | * dropped. Field symptom of exactly that: a cold OBSERVE launch |
| 434 | * attached fine but landed on Browse with the observed track only in |
| 435 | * the bar. Await the first subscriber, then emit; on the warm path |
| 436 | * subscriptionCount is already ≥ 1 and this delivers immediately. */ |
| 437 | private fun emitNavCommand(route: String, arg: String?) { |
| 438 | lifecycleScope.launch { |
| 439 | navCommands.subscriptionCount.first { it > 0 } |
| 440 | navCommands.emit(route to arg) |
| 441 | } |
| 442 | } |
| 443 | |
| 444 | // ── Kiosk hand-back ────────────────────────────────────────── |
| 445 | // |
| 446 | // The other half of the voice-cast pipeline's focus choreography. The |
| 447 | // division of labor exists because of the Android 10+ / ChromeOS ARC |
| 448 | // background-activity-start restriction: an app may only bring an |
| 449 | // activity forward while it is itself foreground. So FaveatHaus |
| 450 | // (foreground) launches us on "playing", and WE (foreground, holding the |
| 451 | // live session socket — the ground truth) launch FaveatHaus back when |
| 452 | // the session genuinely ends. Neither app ever needs HOME status or an |
| 453 | // overlay permission, on Android TV or ChromeOS. |
| 454 | // |
| 455 | // "Genuinely ends" is the load-bearing phrase. CCA streams flap: |
| 456 | // Wi-Fi blips, group-leader handoffs, sub-second IDLE at track |
| 457 | // boundaries, receiver reload between albums. The monitor therefore |
| 458 | // requires the session to be continuously absent for |
| 459 | // KIOSK_HANDBACK_DEBOUNCE_MS before acting — long enough to outlast the |
| 460 | // observer's own reconnect supervisor (~15 s worst case), so a dropped |
| 461 | // TLS connection that recovers never bounces the screen. Any sign of |
| 462 | // life (MEDIA_STATUS with a non-IDLE session, or a fresh observe attempt |
| 463 | // pending) cancels the countdown and restarts it from zero next time. |
| 464 | // |
| 465 | // Scoped to RESUMED: if the user (or FaveatHaus, or anything else) has |
| 466 | // already taken the screen, we are not the foreground owner and do |
| 467 | // nothing — never fight for focus, in either direction. |
| 468 | |
| 469 | /** Quiet period with no live session before handing the screen back — |
| 470 | * the fleet-shared definition of "genuinely dead". See |
| 471 | * [dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS]. */ |
| 472 | private val kioskHandbackDebounceMs = |
| 473 | dev.stave.cast.raw.RawCastObserver.SESSION_DEAD_DEBOUNCE_MS |
| 474 | |
| 475 | private var lastHandbackAt = 0L |
| 476 | private val HANDBACK_ATTEMPTS = 3 |
| 477 | private val HANDBACK_RETRY_MS = 30_000L |
| 478 | |
| 479 | private fun startKioskHandbackMonitor() { |
| 480 | lifecycleScope.launch { |
| 481 | repeatOnLifecycle(Lifecycle.State.RESUMED) { |
| 482 | // distinctUntilChanged is load-bearing: DataStore's data |
| 483 | // flow re-emits on ANY preference write (e.g. the observer |
| 484 | // persisting its reattach target), and collectLatest would |
| 485 | // restart the inner countdown each time — silently making |
| 486 | // the debounce unbounded. |
| 487 | // The switch governs this rule too: with automation off, |
| 488 | // a remembered kiosk is never acted on (decision, 2026-09-02: |
| 489 | // both screens were sent to kiosk 20 s after a manual open |
| 490 | // with the switch off, because this rule only ever looked |
| 491 | // at the remembered package). |
| 492 | combine( |
| 493 | settingsStore.kioskReturnPackage, |
| 494 | settingsStore.kioskFlipEnabled, |
| 495 | ) { pkg, on -> if (on) pkg else null } |
| 496 | .distinctUntilChanged() |
| 497 | .collectLatest { pkg -> |
| 498 | if (pkg.isNullOrBlank()) return@collectLatest |
| 499 | combine( |
| 500 | rawCastObserver.sessionLive, |
| 501 | rawCastObserver.pendingTargetName, |
| 502 | rawCastObserver.uiPanelOpen, |
| 503 | ) { live, pending, panelOpen -> |
| 504 | // "Present": a live (playing/paused/buffering) |
| 505 | // session, an attach attempt still inside its grace |
| 506 | // window, or a person actively using the Dom-Etha |
| 507 | // panel. Merely-connected-but-idle does NOT count — |
| 508 | // a finished queue is exactly what hand-back is for. |
| 509 | live || pending != null || panelOpen |
| 510 | } |
| 511 | .distinctUntilChanged() |
| 512 | .collectLatest { present -> |
| 513 | if (present) return@collectLatest |
| 514 | delay(kioskHandbackDebounceMs) |
| 515 | // Retry until it lands or presence returns. |
| 516 | // collectLatest cancels this loop the moment a |
| 517 | // session comes back; a successful launch |
| 518 | // disarms, which cancels via the outer settings |
| 519 | // collector. A FAILED launch must NOT end the |
| 520 | // attempt — that was the strand: one failure |
| 521 | // used to eat the arming forever. |
| 522 | handBackBounded(pkg) |
| 523 | } |
| 524 | } |
| 525 | } |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | /** One hand-back attempt. Returns true when the launch actually |
| 530 | * happened (only then is the arming cleared); false = stay armed so |
| 531 | * the caller retries. The original order — disarm FIRST, launch |
| 532 | * second — converted any launch failure into a permanently stranded |
| 533 | * screen: arming eaten, no FaveatHaus, and since Earhorn remains |
| 534 | * foreground the OS drops every subsequent OBSERVE, so nothing could |
| 535 | * ever re-arm. Observed on three devices at once, 2026-08-28: leader |
| 536 | * freeze → supervisor exhausted → local fallback (persisted queue, |
| 537 | * paused mid-track) → simultaneous hand-back attempts → failed launch |
| 538 | * → silent permanent strand. Disarm now happens strictly AFTER a |
| 539 | * successful startActivity. */ |
| 540 | /** design decision 2026-09-02: "not trying every 30 seconds for very |
| 541 | * long. If kiosk isn't there, it isn't there." HANDBACK_ATTEMPTS tries, |
| 542 | * HANDBACK_RETRY_MS apart; then stop, leave Browse on the screen (the |
| 543 | * failure branch of fireKioskHandback already put it there) and log once. */ |
| 544 | private suspend fun handBackBounded(pkg: String) { |
| 545 | // Never with the switch off, whatever remembered kiosk led here. |
| 546 | val on = runCatching { settingsStore.kioskFlipEnabled.first() }.getOrDefault(false) |
| 547 | if (!on) { |
| 548 | android.util.Log.i("EarhornKiosk", "automation off — not going back to $pkg") |
| 549 | return |
| 550 | } |
| 551 | repeat(HANDBACK_ATTEMPTS) { attempt -> |
| 552 | if (fireKioskHandback(pkg)) return |
| 553 | if (attempt < HANDBACK_ATTEMPTS - 1) delay(HANDBACK_RETRY_MS) |
| 554 | } |
| 555 | android.util.Log.e("EarhornKiosk", |
| 556 | "could not launch $pkg after $HANDBACK_ATTEMPTS attempts — staying on Browse; not retrying") |
| 557 | } |
| 558 | |
| 559 | private suspend fun fireKioskHandback(pkg: String): Boolean { |
| 560 | val now = android.os.SystemClock.elapsedRealtime() |
| 561 | if (now - lastHandbackAt < 10_000) return false |
| 562 | lastHandbackAt = now |
| 563 | android.util.Log.i("EarhornKiosk", "observed session over — handing back to $pkg") |
| 564 | // design decision 2026-09-02 (I.9): open kiosk FIRST. The old order |
| 565 | // disconnected and wiped the screen, then tried the launch; a failed |
| 566 | // launch left the screen blank for the 30 s retry cycle. Now the |
| 567 | // observation stands down only after kiosk is actually on its way, |
| 568 | // and a failed launch shows Browse — never a blank screen. |
| 569 | val launch = packageManager.getLaunchIntentForPackage(pkg) |
| 570 | ?: packageManager.getLeanbackLaunchIntentForPackage(pkg) |
| 571 | val launched = if (launch == null) { |
| 572 | android.util.Log.w("EarhornKiosk", |
| 573 | "no launch intent for $pkg (not installed / not visible) — staying ARMED, will retry") |
| 574 | false |
| 575 | } else { |
| 576 | runCatching { startActivity(launch) } |
| 577 | .onFailure { android.util.Log.w("EarhornKiosk", "hand-back launch failed — staying ARMED, will retry", it) } |
| 578 | .isSuccess |
| 579 | } |
| 580 | if (launched) { |
| 581 | // Reattach must not resurrect an idle observer; idempotent. |
| 582 | runCatching { rawCastObserver.disconnect(clearPersisted = true) } |
| 583 | runCatching { settingsStore.setKioskReturnPackage(null) } |
| 584 | } else { |
| 585 | // kiosk won't open: the session is over, so release it, but put |
| 586 | // Browse on the screen instead of nothing while we keep trying. |
| 587 | runCatching { rawCastObserver.disconnect(clearPersisted = true) } |
| 588 | emitNavCommand(PlaybackService.NAV_BROWSE, null) |
| 589 | } |
| 590 | return launched |
| 591 | } |
| 592 | |
| 593 | companion object { |
| 594 | /** Exported observe action — shared contract with FaveatHaus. The |
| 595 | * string is part of the group wire protocol; do NOT rename to match |
| 596 | * the applicationId. */ |
| 597 | const val ACTION_OBSERVE = "com.faveat.earhorn.action.OBSERVE" |
| 598 | /** String extra / query parameter: Cast group friendly name, exactly |
| 599 | * as Google Home spells it (UTF-8 — spaces and umlauts intact, |
| 600 | * never ASCII-folded). */ |
| 601 | const val EXTRA_OBSERVE_GROUP = "group" |
| 602 | /** Optional String extra / query parameter: package name to bring |
| 603 | * forward when the observed session genuinely ends (kiosk |
| 604 | * hand-back). Omitted → Earhorn never changes foreground apps. */ |
| 605 | const val EXTRA_OBSERVE_RETURN_TO = "returnTo" |
| 606 | const val OBSERVE_SCHEME = "earhorn" |
| 607 | const val OBSERVE_HOST = "observe" |
| 608 | } |
| 609 | |
| 610 | /** Drain notification deep-link extras into the navCommands flow. |
| 611 | * Removes the extras after reading so a subsequent rotation / |
| 612 | * config-change recreation doesn't replay the same nav. The |
| 613 | * initial-cold-launch path may pass null (no intent yet, or |
| 614 | * intent has no extras of interest); the warm-relaunch path |
| 615 | * through onNewIntent always passes non-null. */ |
| 616 | private fun handleNavExtras(intent: Intent?) { |
| 617 | val route = intent?.getStringExtra(PlaybackService.EXTRA_NAV_ROUTE) ?: return |
| 618 | val arg = intent.getStringExtra(PlaybackService.EXTRA_NAV_ARG) |
| 619 | intent.removeExtra(PlaybackService.EXTRA_NAV_ROUTE) |
| 620 | intent.removeExtra(PlaybackService.EXTRA_NAV_ARG) |
| 621 | emitNavCommand(route, arg) |
| 622 | } |
| 623 | |
| 624 | private fun acquireMulticastLock() { |
| 625 | val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return |
| 626 | multicastLock = wifi.createMulticastLock("earhorn-ssdp").apply { |
| 627 | setReferenceCounted(false) |
| 628 | acquire() |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | override fun onDestroy() { |
| 633 | runCatching { unbindService(upnpConnection) } |
| 634 | multicastLock?.takeIf { it.isHeld }?.release() |
| 635 | multicastLock = null |
| 636 | super.onDestroy() |
| 637 | } |
| 638 | } |


