feat(android): native display resolution + Settings screen
apple / swift (push) Successful in 53s
android / android (push) Failing after 1m15s
ci / rust (push) Failing after 43s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m43s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m53s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m44s
deb / build-publish (push) Successful in 6m52s
docker / deploy-docs (push) Successful in 22s

The connect mode was hardcoded to 720p60 — violating the "native client resolution, no
scaling" invariant. Derive the device's real display mode (landscape, long edge = width) and
add a Settings screen to tune the stream, mirroring the Linux/Apple clients.

- crates/punktfunk-android: nativeConnect gains bitrateKbps + compositorPref + gamepadPref
  (CompositorPref/GamepadPref wire bytes via from_u8); these were hardcoded Auto/Auto/0.
- app/Settings.kt: Settings (width/height/hz/bitrate/compositor/gamepad; 0 = native/auto) +
  a SharedPreferences store + nativeDisplayMode (Display.mode, landscape-swapped) +
  effectiveMode + the UI option tables.
- app/SettingsScreen.kt: dropdowns for resolution / refresh / bitrate / compositor / controller.
- MainActivity: App owns the settings + a Settings screen; ConnectScreen resolves the effective
  mode (Native = the display), shows it on the Connect button, and threads the prefs through
  nativeConnect.

Mic + codec selection deferred (mic uplink isn't wired yet; the decoder is HEVC-only).

Verified live (emulator pf_phone -> home-worker-2): default -> host mode=2400x1080@60 (the
emulator's native display, was 720p); Settings 1920x1080 + 20 Mbps + DualSense -> host
mode=1920x1080, requested_kbps=20000, gamepad=dualsense (host created a UHID DualSense).
Settings persist across screens; pinned reconnect stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:13:55 +02:00
parent 262305b771
commit 3bcc36c801
5 changed files with 290 additions and 16 deletions
@@ -137,11 +137,9 @@ class MainActivity : ComponentActivity() {
}
}
/** Scaffold mode requested from the host (WxH@Hz). TODO: derive from the display. */
private val REQUEST_MODE = Triple(1280, 720, 60)
private sealed interface Screen {
data object Connect : Screen
data object Settings : Screen
data class Stream(val handle: Long) : Screen
}
@@ -163,15 +161,27 @@ private data class PendingTrust(
@Composable
private fun App() {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var screen by remember { mutableStateOf<Screen>(Screen.Connect) }
when (val s = screen) {
Screen.Connect -> ConnectScreen(onConnected = { handle -> screen = Screen.Stream(handle) })
Screen.Connect -> ConnectScreen(
settings = settings,
onConnected = { handle -> screen = Screen.Stream(handle) },
onOpenSettings = { screen = Screen.Settings },
)
Screen.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { screen = Screen.Connect },
)
is Screen.Stream -> StreamScreen(s.handle, onDisconnect = { screen = Screen.Connect })
}
}
@Composable
private fun ConnectScreen(onConnected: (Long) -> Unit) {
private fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit, onOpenSettings: () -> Unit) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
@@ -179,7 +189,8 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
var connecting by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(null) }
val abi = remember { runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) }
val (w, h, hz) = REQUEST_MODE
// The host streams at exactly this mode; "Native" settings resolve from the device display.
val (w, h, hz) = settings.effectiveMode(context)
// mDNS discovery scoped to this screen; NsdManager callbacks arrive on the main thread, so the
// onChange callback can set Compose state directly. (Emulator SLIRP drops multicast → empty.)
@@ -225,6 +236,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
NativeBridge.nativeConnect(
targetHost, targetPort, w, h, hz,
id.certPem, id.privateKeyPem, pinHex ?: "",
settings.bitrateKbps, settings.compositor, settings.gamepad,
)
}
connecting = false
@@ -311,6 +323,7 @@ private fun ConnectScreen(onConnected: (Long) -> Unit) {
enabled = !connecting && host.isNotBlank() && port.isNotBlank(),
onClick = { connect(host.trim(), port.toInt()) },
) { Text(if (connecting) "Connecting…" else "Connect ($w×$h@$hz)") }
TextButton(enabled = !connecting, onClick = onOpenSettings) { Text("Settings") }
status?.let {
Spacer(Modifier.height(12.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
@@ -0,0 +1,125 @@
package io.unom.punktfunk
import android.content.Context
/**
* User-tunable stream settings, persisted in `SharedPreferences`. A `0` resolution/refresh means
* "native display mode" (resolved at connect time from [nativeDisplayMode]); `0` bitrate means the
* host's default. [compositor]/[gamepad] are the `CompositorPref`/`GamepadPref` wire bytes the host
* understands (0 = Auto). Mirrors the Linux/Apple clients' settings.
*/
data class Settings(
val width: Int = 0,
val height: Int = 0,
val hz: Int = 0,
val bitrateKbps: Int = 0,
val compositor: Int = 0,
val gamepad: Int = 0,
)
/** Loads/saves [Settings] in the app-private `punktfunk_settings` prefs. */
class SettingsStore(context: Context) {
private val prefs =
context.applicationContext.getSharedPreferences("punktfunk_settings", Context.MODE_PRIVATE)
fun load(): Settings = Settings(
width = prefs.getInt(K_W, 0),
height = prefs.getInt(K_H, 0),
hz = prefs.getInt(K_HZ, 0),
bitrateKbps = prefs.getInt(K_BITRATE, 0),
compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0),
)
fun save(s: Settings) {
prefs.edit()
.putInt(K_W, s.width)
.putInt(K_H, s.height)
.putInt(K_HZ, s.hz)
.putInt(K_BITRATE, s.bitrateKbps)
.putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad)
.apply()
}
private companion object {
const val K_W = "width"
const val K_H = "height"
const val K_HZ = "hz"
const val K_BITRATE = "bitrate_kbps"
const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad"
}
}
/**
* The device's native display mode as a landscape `(width, height, hz)` — the long edge is the
* width, since we stream a desktop. Falls back to 1920×1080@60 if the display can't be read.
* [context] must be a visual (Activity) context.
*/
fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
// getDisplay() throws on a non-visual context rather than returning null — guard it.
val display = runCatching { context.display }.getOrNull() ?: return Triple(1920, 1080, 60)
val mode = display.mode
val w = mode.physicalWidth
val h = mode.physicalHeight
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
return Triple(maxOf(w, h), minOf(w, h), hz)
}
/** Resolve [Settings] (with its 0=native placeholders) to the concrete mode to request. */
fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
val native = nativeDisplayMode(context)
val w = if (width > 0) width else native.first
val h = if (height > 0) height else native.second
val hz = if (hz > 0) hz else native.third
return Triple(w, h, hz)
}
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
/** (width, height, label). `(0,0)` = native display. */
val RESOLUTION_OPTIONS = listOf(
Triple(0, 0, "Native display"),
Triple(1280, 720, "1280 × 720"),
Triple(1920, 1080, "1920 × 1080"),
Triple(2560, 1440, "2560 × 1440"),
Triple(3840, 2160, "3840 × 2160"),
)
/** (hz, label). `0` = native refresh. */
val REFRESH_OPTIONS = listOf(
0 to "Native",
30 to "30 Hz",
60 to "60 Hz",
90 to "90 Hz",
120 to "120 Hz",
144 to "144 Hz",
165 to "165 Hz",
240 to "240 Hz",
)
/** (kbps, label). `0` = host default. */
val BITRATE_OPTIONS = listOf(
0 to "Automatic",
10_000 to "10 Mbps",
20_000 to "20 Mbps",
50_000 to "50 Mbps",
100_000 to "100 Mbps",
)
/** index = CompositorPref wire byte. */
val COMPOSITOR_OPTIONS = listOf(
"Automatic",
"KWin (KDE Plasma)",
"wlroots (Sway / Hyprland)",
"Mutter (GNOME)",
"gamescope",
)
/** index = GamepadPref wire byte. */
val GAMEPAD_OPTIONS = listOf(
"Automatic",
"Xbox 360",
"DualSense",
)
@@ -0,0 +1,125 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
/**
* Stream settings. Edits are persisted immediately via [onChange]; [onBack] returns to the connect
* screen. Resolution/refresh "Native" resolve from the device display at connect time.
*/
@Composable
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
var s by remember { mutableStateOf(initial) }
val context = LocalContext.current
fun update(next: Settings) {
s = next
onChange(next)
}
BackHandler(onBack = onBack)
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineMedium)
val (nw, nh, nhz) = nativeDisplayMode(context)
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
},
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
SettingDropdown(
label = "Refresh rate",
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl (${nhz} Hz)" else lbl) },
selected = s.hz,
) { hz -> update(s.copy(hz = hz)) }
SettingDropdown(
label = "Bitrate",
options = BITRATE_OPTIONS,
selected = s.bitrateKbps,
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
SettingDropdown(
label = "Compositor (virtual-display host backend)",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor,
) { c -> update(s.copy(compositor = c)) }
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.gamepad,
) { g -> update(s.copy(gamepad = g)) }
Spacer(Modifier.height(8.dp))
TextButton(onClick = onBack) { Text("Done") }
}
}
/** A labelled read-only dropdown over [options] (value → label); calls [onSelect] on a pick. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun <T> SettingDropdown(
label: String,
options: List<Pair<T, String>>,
selected: T,
onSelect: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
val selectedLabel = options.firstOrNull { it.first == selected }?.second
?: options.firstOrNull()?.second.orEmpty()
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(
value = selectedLabel,
onValueChange = {},
readOnly = true,
label = { Text(label) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
.fillMaxWidth(),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (value, lbl) ->
DropdownMenuItem(
text = { Text(lbl) },
onClick = {
onSelect(value)
expanded = false
},
)
}
}
}
}
@@ -27,7 +27,10 @@ object NativeBridge {
/**
* Connect, presenting [certPem]/[keyPem] (both empty = anonymous) and pinning [pinHex] (empty =
* trust-on-first-use — read [nativeHostFingerprint] after; else 64-hex host SHA-256, mismatch →
* `0`). Returns an opaque session handle, or `0` on failure. Pair with exactly one [nativeClose].
* `0`). [width]/[height]/[refreshHz] are the requested virtual-output mode (the host streams at
* exactly this); [bitrateKbps] 0 = host default; [compositorPref]/[gamepadPref] are the
* `CompositorPref`/`GamepadPref` wire bytes (0 = Auto). Returns an opaque session handle, or `0`
* on failure. Pair with exactly one [nativeClose].
*/
external fun nativeConnect(
host: String,
@@ -38,6 +41,9 @@ object NativeBridge {
certPem: String,
keyPem: String,
pinHex: String,
bitrateKbps: Int,
compositorPref: Int,
gamepadPref: Int,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */