From caaf80749e86fe6a5e4fd9851bb4793edcef1884 Mon Sep 17 00:00:00 2001 From: jpolo1224 Date: Fri, 21 Aug 2026 10:47:35 -0400 Subject: [PATCH] Library: find save states in both data roots, and restore swipe-to-dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from testing the long-press menu. ★ SaveSlotLookup only searched ONE root. A device with a configured system directory has two — assetCopyRoot resolves to that one (typically the SD card, where ROMs and most saves live) while others stay under getExternalFilesDir. On the test device 13 states sat in one and 5 in the other, so either root alone under-reports, and the failure is silent: it reads as 'this game has no save states' rather than as a bug. This is the same two-root trap that once made a patches investigation report a false 'clean'. Both are searched now, and when a slot exists in both the newer file wins. Swipe-to-dismiss is back for bottom-aligned modals. PadModal replaced ModalBottomSheet because that is its own focused Android window and every row inside it was unreachable by pad; the swipe was the one thing given up in the trade. But a panel that rises from the bottom edge with a rounded top and a drag handle is PROMISING a swipe, so its absence reads as broken rather than as a deliberate omission. The panel now follows the finger and dismisses past a threshold, without giving up focus ownership. Downward only, and only for BottomCenter: dragging a bottom sheet up should not lift it off the edge it is anchored to, and on centred or anchored menus a vertical drag means nothing and would fight scrolling inside them. Worth recording that the originally reported symptom was NOT a bug: God of War II has no save states on the test device, so an absent row was correct. The root bug was real but found by inspection while checking that. --- .../main/java/com/armsx2/SaveSlotLookup.kt | 21 ++++++-- .../java/com/armsx2/ui/common/PadModal.kt | 50 +++++++++++++++++-- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/platforms/android/app/src/main/java/com/armsx2/SaveSlotLookup.kt b/platforms/android/app/src/main/java/com/armsx2/SaveSlotLookup.kt index 9a9e6f48e1..f76be47ed0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SaveSlotLookup.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SaveSlotLookup.kt @@ -29,9 +29,21 @@ object SaveSlotLookup { */ fun slotsFor(context: Context, serial: String?): List { if (serial.isNullOrBlank()) return emptyList() - val root = runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull() ?: return emptyList() - return listOf("sstates", "savestates") - .map { File(root, it) } + // ★ BOTH roots, not one. + // + // A device with a configured system directory has two: assetCopyRoot resolves to that + // (typically the SD card, where ROMs and most saves live) while some states stay under + // getExternalFilesDir. On the test device 13 states were on one and 5 on the other, so + // checking either alone silently under-reports — which reads as "this game has no save + // states" rather than as a bug. + val roots = buildList { + runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull()?.let(::add) + runCatching { context.getExternalFilesDir(null)?.absolutePath }.getOrNull()?.let(::add) + }.distinct() + if (roots.isEmpty()) return emptyList() + + return roots + .flatMap { root -> listOf("sstates", "savestates").map { File(root, it) } } .filter { it.isDirectory } .flatMap { dir -> runCatching { @@ -46,6 +58,9 @@ object SaveSlotLookup { ?: return@mapNotNull null Slot(slot, file, file.lastModified()) } + // Same slot in both roots: keep the newer file, since that is the one the emulator + // most recently wrote. + .sortedByDescending { it.modified } .distinctBy { it.slot } .sortedBy { it.slot } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/common/PadModal.kt b/platforms/android/app/src/main/java/com/armsx2/ui/common/PadModal.kt index 4acbeb6a2f..0089af38c7 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/common/PadModal.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/common/PadModal.kt @@ -18,6 +18,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment @@ -220,13 +225,48 @@ fun PadModalHost() { val anchor = entry.anchor.value // Absorb taps on the panel itself, or the scrim's dismiss fires through it and // the modal closes as you press its own buttons. + // Swipe-to-dismiss, for bottom-aligned panels only. + // + // PadModal replaced ModalBottomSheet because that is its own focused Android + // window and every row inside it was unreachable by pad. The one thing lost in the + // trade was the swipe — and a panel that rises from the bottom edge with a rounded + // top and a drag handle is *promising* a swipe, so its absence reads as broken + // rather than as a deliberate omission. Restored here without giving up focus. + // + // Only for BottomCenter: on a centred or anchored menu a downward drag means + // nothing, and hijacking it would break scrolling inside those panels. + val bottomAligned = entry.alignment.value == Alignment.BottomCenter val absorbTaps = @Composable { inner: @Composable () -> Unit -> + var dragOffset by remember { mutableFloatStateOf(0f) } + val dismissThresholdPx = with(LocalDensity.current) { 110.dp.toPx() } Box( - Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), + Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + .then( + if (!bottomAligned) Modifier + else Modifier + // Follows the finger, so the gesture is visibly doing + // something before it commits. + .offset { IntOffset(0, dragOffset.roundToInt()) } + .pointerInput(entry.key) { + detectVerticalDragGestures( + onDragEnd = { + if (dragOffset > dismissThresholdPx) PadModals.dismissTop() + dragOffset = 0f + }, + onDragCancel = { dragOffset = 0f }, + ) { change, delta -> + change.consume() + // Downward only — dragging a bottom sheet UP should + // not lift it off the edge it is anchored to. + dragOffset = (dragOffset + delta).coerceAtLeast(0f) + } + } + ), ) { CompositionLocalProvider(LocalNavLayer provides entry.key) { inner() } }