MainActivity.kt
Each line, with the release that last changed it.
| 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 | } |


