fix(android): launch games from the library

The library browser was browse-only — the A button (and a tap) did nothing.
Wire it to connect + boot straight into the selected title: thread a `launch`
id (the store-qualified library id `steam:<appid>` / `custom:<id>`) through
nativeConnect → NativeClient's Hello.launch (was hardcoded None), add a shared
connectToHost() the ConnectScreen and the library launcher both use, and have
LibraryScreen dial the host with launch=game.id on A / tap — with a launching
overlay + an "A Launch" hint. Verified: native compiles (cargo-ndk arm64),
app+kit Kotlin compiles (gradle, 3 ABIs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 12:59:39 +00:00
parent 2c1bb4de93
commit 8df6d07cc6
6 changed files with 137 additions and 34 deletions
@@ -208,6 +208,8 @@ fun GamepadShell(
GamepadScreen.Library -> libraryHost?.let { host -> GamepadScreen.Library -> libraryHost?.let { host ->
LibraryScreen( LibraryScreen(
host = host, host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null }, onBack = { screen = GamepadScreen.Home; libraryHost = null },
navActive = s == screen, navActive = s == screen,
) )
@@ -63,9 +63,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** Handshake budget for a normal connect (the prior hardcoded value, now passed explicitly). */
private const val CONNECT_TIMEOUT_MS = 10_000
/** /**
* Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park * Handshake budget for the no-PIN "request access" connect. Must exceed the host's approval-park
* window (~180 s) so a slow operator approval still lands on this same parked connection rather than * window (~180 s) so a slow operator approval still lands on this same parked connection rather than
@@ -181,25 +178,10 @@ fun ConnectScreen(
// it survives a DHCP address change; else by address:port). Mirrors the Apple client. // it survives a DHCP address change; else by address:port). Mirrors the Apple client.
val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } } val discoveredUnsaved = discovered.filter { dh -> savedHosts.none { it.matches(dh) } }
// The one place the full nativeConnect is issued (shared by the normal connect and the // Issue the native connect (shared by the normal connect and the request-access path). A plain
// request-access path), including the HDR/gamepad derivation both need. // desktop connect (no library launch) — the library launcher calls [connectToHost] with an id.
suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long { suspend fun connectNative(id: ClientIdentity, targetHost: String, targetPort: Int, pinHex: String, timeoutMs: Int): Long =
// Advertise HDR only when the user enabled it AND this device's display can present it connectToHost(context, settings, id, targetHost, targetPort, pinHex, launch = null, timeoutMs = timeoutMs)
// (else the host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID
// (Android exposes no controller-type enum) — parity with the Linux/Apple clients. An
// explicit choice is passed through unchanged.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
return withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
)
}
}
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it // the host presented (as an unpaired known host) so the next connect goes straight through and it
@@ -0,0 +1,45 @@
package io.unom.punktfunk
import android.content.Context
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Handshake budget for a normal / library-launch connect (not the long request-access park). */
const val CONNECT_TIMEOUT_MS = 10_000
/**
* The one place [NativeBridge.nativeConnect] is assembled — shared by [ConnectScreen] and the library
* launcher ([LibraryScreen]). Derives the mode / HDR / gamepad settings the host needs from
* [settings]. [pinHex] is the pinned fingerprint (empty ⇒ TOFU). [launch] is a store-qualified library
* id (`steam:<appid>` / `custom:<id>`) to boot straight into a game, or `null` for the desktop.
* Returns the session handle, or `0` on failure. Call off the main thread.
*/
suspend fun connectToHost(
context: Context,
settings: Settings,
identity: ClientIdentity,
host: String,
port: Int,
pinHex: String,
launch: String?,
timeoutMs: Int = CONNECT_TIMEOUT_MS,
): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it (else the
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val (w, h, hz) = settings.effectiveMode(context)
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
return withContext(Dispatchers.IO) {
NativeBridge.nativeConnect(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels, settings.preferredCodec(), timeoutMs,
launch,
)
}
}
@@ -1,8 +1,10 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -57,6 +59,7 @@ import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult import io.unom.punktfunk.kit.library.LibraryResult
import io.unom.punktfunk.kit.library.mtlsHttpClient import io.unom.punktfunk.kit.library.mtlsHttpClient
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity import io.unom.punktfunk.kit.security.obtainIdentity
@@ -73,17 +76,27 @@ import kotlinx.coroutines.withContext
private sealed class LibState { private sealed class LibState {
object Loading : LibState() object Loading : LibState()
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState() // Carries the client identity so a launch can dial the host over the same pinned mTLS trust.
data class Ready(val games: List<GameEntry>, val loader: ImageLoader, val identity: ClientIdentity) : LibState()
data class Message(val text: String) : LibState() // unauthorized / empty / error data class Message(val text: String) : LibState() // unauthorized / empty / error
} }
@Composable @Composable
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) { fun LibraryScreen(
host: KnownHost,
settings: Settings,
onLaunched: (Long) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
) {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
var state by remember { mutableStateOf<LibState>(LibState.Loading) } var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
LaunchedEffect(host.address, host.port, host.fpHex) { LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading state = LibState.Loading
@@ -101,7 +114,7 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
LibState.Message("No games found on this host.") LibState.Message("No games found on this host.")
} else { } else {
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex) val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build()) LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build(), id)
} }
is LibraryResult.Unauthorized -> LibState.Message(res.message) is LibraryResult.Unauthorized -> LibState.Message(res.message)
is LibraryResult.Error -> LibState.Message(res.message) is LibraryResult.Error -> LibState.Message(res.message)
@@ -118,11 +131,45 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
when (val s = state) { when (val s = state) {
is LibState.Loading -> LoadingState() is LibState.Loading -> LoadingState()
is LibState.Message -> MessageState(s.text) is LibState.Message -> MessageState(s.text)
is LibState.Ready -> Coverflow(s.games, s.loader, navActive) is LibState.Ready -> Coverflow(s.games, s.loader, navActive && !launching) { game ->
if (!launching) {
launching = true
scope.launch {
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
if (handle != 0L) onLaunched(handle)
else Toast.makeText(
context,
"Launch failed — check the host and try again.",
Toast.LENGTH_LONG,
).show()
}
}
}
} }
} }
} }
} }
// Launching overlay — the connect + host-side game boot takes a moment; block the pad while it runs.
if (launching) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
CircularProgressIndicator(color = Color.White)
Text("Launching…", color = Color.White, style = MaterialTheme.typography.bodyLarge)
}
}
}
// Floating legend at the shared spot — same landscape-aware inset as every other console // Floating legend at the shared spot — same landscape-aware inset as every other console
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target). // screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
Box( Box(
@@ -130,7 +177,13 @@ fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true
.then(if (landscape) Modifier else Modifier.systemBarsPadding()) .then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset), .padding(ConsoleLegendInset),
) { ) {
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState) GamepadHintBar(
buildList {
if (state is LibState.Ready) add(PadGlyph.hint('A', "Launch"))
add(PadGlyph.hint('B', "Close", onClick = onBack))
},
hazeState = hazeState,
)
} }
} }
} }
@@ -155,7 +208,12 @@ private fun MessageState(text: String) {
} }
@Composable @Composable
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) { private fun Coverflow(
games: List<GameEntry>,
loader: ImageLoader,
navActive: Boolean,
onLaunch: (GameEntry) -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen. // Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp) val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
@@ -167,16 +225,15 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage } LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = games.getOrNull(navTarget) val current = games.getOrNull(navTarget)
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a // Controller nav: the pad drives the coverflow. Left/right steps a coalesced target the pager
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes // chases; A launches the centred title; B closes via the screen's BackHandler.
// via the screen's BackHandler.
GamepadNavEffect( GamepadNavEffect(
active = navActive && games.isNotEmpty(), active = navActive && games.isNotEmpty(),
onMove = { dir -> onMove = { dir ->
val t = (navTarget + dir).coerceIn(0, games.lastIndex) val t = (navTarget + dir).coerceIn(0, games.lastIndex)
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } } if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
}, },
onActivate = { /* launch a title — browse-only for now */ }, onActivate = { games.getOrNull(navTarget)?.let(onLaunch) },
) )
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
@@ -198,6 +255,11 @@ private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Bo
.zIndex(-d) // centred cover on top, neighbours stacked behind .zIndex(-d) // centred cover on top, neighbours stacked behind
.width(coverWidth) .width(coverWidth)
.height(coverHeight) .height(coverHeight)
// Touch: tap the centred cover to launch it; tap a neighbour to bring it centre.
.clickable {
if (page == pagerState.currentPage) onLaunch(games[page])
else scope.launch { pagerState.animateScrollToPage(page) }
}
.graphicsLayer { .graphicsLayer {
// Centre at full size; EVERY neighbour settles to one size, so an even pitch // Centre at full size; EVERY neighbour settles to one size, so an even pitch
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow — // yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
@@ -51,6 +51,9 @@ object NativeBridge {
/** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */ /** Preferred video codec as a `quic::CODEC_*` bit (`0` = auto). Soft — the host falls back. */
preferredCodec: Int, preferredCodec: Int,
timeoutMs: Int, timeoutMs: Int,
/** Store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game,
* or `null`/empty for a plain desktop connect. Rides the Hello as `launch`. */
launch: String?,
): Long ): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */ /** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
+11 -2
View File
@@ -33,7 +33,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeGenerateIde
} }
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps, /// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs): Long`. /// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch): Long`.
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
/// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty /// `certPem`/`keyPem` empty = anonymous, else presented as the persistent identity. `pinHex` empty
/// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0). /// = TOFU (read `nativeHostFingerprint` after), else 64-hex SHA-256 to pin the host (mismatch → 0).
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref` /// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
@@ -63,6 +64,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
audio_channels: jint, audio_channels: jint,
preferred_codec: jint, preferred_codec: jint,
timeout_ms: jint, timeout_ms: jint,
launch: JString<'local>,
) -> jlong { ) -> jlong {
let host: String = match env.get_string(&host) { let host: String = match env.get_string(&host) {
Ok(s) => s.into(), Ok(s) => s.into(),
@@ -74,6 +76,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
.unwrap_or_default(); .unwrap_or_default();
let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default(); let key: String = env.get_string(&key_pem).map(Into::into).unwrap_or_default();
let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default(); let pin_hex: String = env.get_string(&pin_hex).map(Into::into).unwrap_or_default();
// A store-qualified library id (`steam:<appid>` / `custom:<id>`) to boot straight into a game;
// null / empty ⇒ None (a plain desktop connect). Rides the Hello as `launch`.
let launch: Option<String> = env
.get_string(&launch)
.map(Into::into)
.ok()
.filter(|s: &String| !s.is_empty());
let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() { let identity: Option<(String, String)> = if cert.is_empty() || key.is_empty() {
None None
@@ -124,7 +133,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below. // + the soft `preferred_codec` and echoes it in `connector.codec`, which drives the mime below.
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC, punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_HEVC,
preferred_codec.clamp(0, u8::MAX as jint) as u8, preferred_codec.clamp(0, u8::MAX as jint) as u8,
None, // launch: default app launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous) identity, // owned (cert, key) PEM, or None (anonymous)
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access" // Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"