fix(android): crash on back-while-streaming (UAF) + Material You theme & connect polish
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 32s
ci / docs-site (push) Successful in 35s
android / android (push) Successful in 4m23s
deb / build-publish (push) Successful in 2m37s
decky / build-publish (push) Successful in 24s
ci / bench (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m45s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Failing after 3m21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 23s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m28s
docker / deploy-docs (push) Has been skipped
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m52s

Crash: DisposableEffect.onDispose called nativeClose(handle) (Box::from_raw frees
the SessionHandle) while the SurfaceView's surfaceDestroyed independently called
nativeStopVideo/Audio/Mic on the same handle -- whichever ran after the close
dereferenced freed memory (SIGSEGV: the consistent back-navigation crash). Add a
one-shot `closed` guard: onDispose marks it before freeing; surfaceDestroyed skips
the native calls once closed (backgrounding still stops the threads when it wins).

Polish:
- Branded Material You theme (Theme.kt): dynamic colour on Android 12+, punktfunk
  brand violets as the pre-12 fallback, replacing the generic darkColorScheme().
- ConnectScreen: "Connecting..." was rendered in error-red with no spinner; now a
  neutral spinner while connecting, red reserved for actual errors.

Verified locally: ./gradlew :app:assembleDebug BUILD SUCCESSFUL (both ABIs + the
Compose changes), debug APK assembles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 22:49:51 +00:00
parent 55cd58e487
commit f39230e8f4
4 changed files with 89 additions and 12 deletions
@@ -21,6 +21,7 @@ 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.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -32,6 +33,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -207,12 +209,32 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
Spacer(Modifier.height(24.dp))
status?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
// While connecting it's progress (spinner, neutral); otherwise it's a
// result/error (red). Previously every status showed in error-red, so a
// normal "Connecting…" looked like a failure.
if (connecting) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
}
}
@@ -9,9 +9,7 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Keymap
@@ -38,7 +36,7 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
PunktfunkTheme {
Surface(modifier = Modifier.fillMaxSize()) { App() }
}
}
@@ -39,6 +39,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.NativeBridge
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay
import kotlin.math.abs
import kotlin.math.roundToInt
@@ -70,6 +71,12 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
}
}
// One-shot teardown guard. Both the SurfaceView callback and DisposableEffect tear down on the
// way out, but `nativeClose` frees the handle — so once it's closed, NO path may touch the handle
// again (use-after-free → SIGSEGV: the consistent back-while-streaming crash). Both run on the
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) }
DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
controller?.let {
@@ -81,6 +88,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
feedback.stop() // stop + join the poll threads BEFORE nativeClose frees the handle
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
@@ -112,9 +120,15 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
// Surface gone (backgrounding, or on the way out). Stop the threads that
// render to it — but only while the session is still open. Once
// DisposableEffect has closed it, the handle is freed; dereferencing it
// here is the use-after-free that crashed on back-navigation.
if (!closed.get()) {
NativeBridge.nativeStopMic(handle)
NativeBridge.nativeStopAudio(handle)
NativeBridge.nativeStopVideo(handle)
}
}
})
}
@@ -0,0 +1,43 @@
package io.unom.punktfunk
import android.os.Build
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
// punktfunk brand violets (from the app icon: #6C5BF3 / #A79FF8 / #D2C9FB on a #16132A indigo).
// Used as the fallback dark scheme on pre-Android-12 devices; on 12+ we defer to Material You.
private val BrandDark = darkColorScheme(
primary = Color(0xFFA79FF8),
onPrimary = Color(0xFF1B1442),
primaryContainer = Color(0xFF4C3FB3),
onPrimaryContainer = Color(0xFFE5E0FF),
secondary = Color(0xFFC8C2EC),
onSecondary = Color(0xFF2E2A4D),
tertiary = Color(0xFF8FD0E8),
onTertiary = Color(0xFF053543),
background = Color(0xFF131129),
onBackground = Color(0xFFE5E1F2),
surface = Color(0xFF1A1733),
onSurface = Color(0xFFE5E1F2),
surfaceVariant = Color(0xFF2A2647),
onSurfaceVariant = Color(0xFFC7C2DE),
)
/**
* App theme — always dark (a streaming client reads best on a dark canvas, and the immersive
* stream view assumes it), but uses **Material You** dynamic colour on Android 12+ so the UI
* harmonises with the user's wallpaper, falling back to the punktfunk brand violets below that.
*/
@Composable
fun PunktfunkTheme(content: @Composable () -> Unit) {
val scheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dynamicDarkColorScheme(LocalContext.current)
} else {
BrandDark
}
MaterialTheme(colorScheme = scheme, content = content)
}