EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

MainActivity.kt

638 lines · 32.0 KB

Changed in: 0.5.0

1package dev.stave
2
3import android.content.BroadcastReceiver
4import android.content.ComponentName
5import android.content.Context
6import android.content.Intent
7import android.content.IntentFilter
8import android.content.ServiceConnection
9import android.content.pm.PackageManager
10import android.content.res.Configuration
11import android.net.wifi.WifiManager
12import android.os.Bundle
13import android.os.IBinder
14import android.view.KeyEvent
15import android.view.WindowManager
16import androidx.activity.ComponentActivity
17import androidx.activity.compose.setContent
18import androidx.compose.runtime.collectAsState
19import androidx.compose.runtime.getValue
20import androidx.core.content.ContextCompat
21import androidx.core.view.WindowCompat
22import androidx.lifecycle.Lifecycle
23import androidx.lifecycle.lifecycleScope
24import androidx.lifecycle.repeatOnLifecycle
25import dagger.hilt.android.AndroidEntryPoint
26import dev.stave.playback.PlaybackService
27import dev.stave.ui.nav.EarhornNavHost
28import dev.stave.ui.theme.EarhornTheme
29import dev.stave.upnp.UpnpServiceHolder
30import kotlinx.coroutines.Dispatchers
31import kotlinx.coroutines.delay
32import kotlinx.coroutines.flow.MutableSharedFlow
33import kotlinx.coroutines.flow.collectLatest
34import kotlinx.coroutines.flow.combine
35import kotlinx.coroutines.flow.distinctUntilChanged
36import kotlinx.coroutines.flow.first
37import kotlinx.coroutines.launch
38import org.jupnp.android.AndroidUpnpService
39import org.jupnp.android.AndroidUpnpServiceImpl
40import javax.inject.Inject
41
42@AndroidEntryPoint
43class 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}