fix(client/android): a link must not end the stream it isn't allowed to preempt

Found on glass. "A URL may never preempt a live session" was enforced inside
the composition — which is the one place that can't see it happen. With
`launchMode = standard` a `punktfunk://` link arrives as a SECOND activity
instance in its own task; that instance's session state is empty, so it
routed the link as a fresh connect, and the streaming task being backgrounded
ended the session it was supposed to protect. The rule held only for the rare
`FLAG_ACTIVITY_SINGLE_TOP` caller that lands on `onNewIntent`.

The live session is now published process-wide, and `onCreate` refuses there
— before this instance is ever resumed, so finishing it leaves the streaming
task in front, untouched. Static state is what crosses the gap between two
activity instances that know nothing about each other; the process dying
resets it, which is also the right answer.

Verified on the emulator against a real host: with a stream up, a link naming
a DIFFERENT host now leaves it running (same session, same HUD, no second
connect) instead of tearing it down.
This commit is contained in:
2026-07-29 12:17:18 +02:00
parent 8172e819a4
commit 42bd5be941
2 changed files with 65 additions and 4 deletions
@@ -31,6 +31,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -67,10 +68,19 @@ fun App(forceGamepadUi: Boolean = false) {
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
// A `punktfunk://` URL that arrived while a session is live is REFUSED, never honoured: a URL
// may not preempt a stream (design/client-deep-links.md §3.2). Pointing at the host already
// being streamed is the one exception, and its right answer is to do nothing — the intent has
// already brought the app forward, which is exactly what "focus it" means here.
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
// destroyed mid-stream doesn't leave a ghost that blocks every future link.
DisposableEffect(session) {
MainActivity.liveStream = session?.let { MainActivity.LiveStream(it.hostId) }
onDispose { MainActivity.liveStream = null }
}
// The same rule for the rare in-instance case (a caller that set FLAG_ACTIVITY_SINGLE_TOP, so
// the link reached `onNewIntent` on the streaming activity itself). Pointing at the host
// already being streamed is the one exception, and its right answer is to do nothing — the
// intent has already brought the app forward, which is exactly what "focus it" means here.
val pendingLink = activity?.pendingDeepLink
LaunchedEffect(pendingLink, session) {
val url = pendingLink ?: return@LaunchedEffect
@@ -13,6 +13,7 @@ import android.view.InputDevice
import android.view.KeyCharacterMap
import android.view.KeyEvent
import android.view.MotionEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
@@ -27,6 +28,10 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.security.KnownHostStore
/** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */
private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION"
@@ -136,6 +141,27 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// A URL may never preempt a live session (design/client-deep-links.md §3.2). With
// `launchMode = standard` a link normally arrives as a NEW activity instance in a new task
// — the streaming one gets backgrounded, and backgrounding ends a session — so the refusal
// has to happen HERE, before this instance is resumed, not inside the composition (which
// only ever sees the rare `onNewIntent` case). Finishing now leaves the streaming task in
// front, untouched.
val live = liveStream
if (live != null && deepLinkFrom(intent) != null) {
// Pointing at the host already being streamed is the one exception, and its right
// answer is to do nothing: the intent has already brought the app forward, which is
// what "focus it" means here.
if (!targetsHost(intent, live)) {
Toast.makeText(
this,
"Already streaming — end this session first.",
Toast.LENGTH_LONG,
).show()
}
finish()
return
}
pendingDeepLink = deepLinkFrom(intent)
lastPadIsGamepad = !isTvDevice(this)
lastPadStyle = Gamepad.styleFor(Gamepad.firstPad())
@@ -567,4 +593,29 @@ class MainActivity : ComponentActivity() {
-> true
else -> KeyEvent.isGamepadButton(kc)
}
/** Does [intent]'s link resolve to the host [live] is already streaming? */
private fun targetsHost(intent: Intent?, live: LiveStream): Boolean {
val url = deepLinkFrom(intent) ?: return false
val parsed = DeepLinks.parse(url) as? DeepLinkResult.Parsed ?: return false
val target = DeepLinks.resolveHost(parsed.link, KnownHostStore(this).all())
return target is HostResolution.Known && target.host.id == live.hostId
}
/** The host a live stream is on — see [liveStream]. */
data class LiveStream(val hostId: String?)
companion object {
/**
* The live stream, PROCESS-wide (null = not streaming), published by the composition that
* owns it.
*
* Deliberately not per-instance state: `launchMode` is `standard`, so a `punktfunk://`
* link arrives as a second activity instance that knows nothing about the first — and the
* one thing it must know is that a session is already running. Static state is what
* crosses that gap; the process dying resets it, which is also correct.
*/
@Volatile
var liveStream: LiveStream? = null
}
}