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
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:
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
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.material.icons.filled.Add
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -207,12 +209,32 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
|
|||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
status?.let {
|
status?.let {
|
||||||
|
// 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(
|
Text(
|
||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import androidx.activity.SystemBarStyle
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.unom.punktfunk.kit.Gamepad
|
import io.unom.punktfunk.kit.Gamepad
|
||||||
import io.unom.punktfunk.kit.Keymap
|
import io.unom.punktfunk.kit.Keymap
|
||||||
@@ -38,7 +36,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
|
||||||
)
|
)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
PunktfunkTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) { App() }
|
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.Gamepad
|
||||||
import io.unom.punktfunk.kit.GamepadFeedback
|
import io.unom.punktfunk.kit.GamepadFeedback
|
||||||
import io.unom.punktfunk.kit.NativeBridge
|
import io.unom.punktfunk.kit.NativeBridge
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.roundToInt
|
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) {
|
DisposableEffect(handle) {
|
||||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
controller?.let {
|
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.
|
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
|
||||||
val feedback = GamepadFeedback(handle).also { it.start() }
|
val feedback = GamepadFeedback(handle).also { it.start() }
|
||||||
onDispose {
|
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
|
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?.reset() // release-all so nothing sticks on the host
|
||||||
activity?.axisMapper = null
|
activity?.axisMapper = null
|
||||||
@@ -112,10 +120,16 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
|
|||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
|
||||||
|
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
// 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.nativeStopMic(handle)
|
||||||
NativeBridge.nativeStopAudio(handle)
|
NativeBridge.nativeStopAudio(handle)
|
||||||
NativeBridge.nativeStopVideo(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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user