diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 5b225d2..ca080d1 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -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)) } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index b18ca69..7833c9e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -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() } } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 153f715..12af3ae 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -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) + } } }) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt new file mode 100644 index 0000000..9424209 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/Theme.kt @@ -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) +}