Library: find save states in both data roots, and restore swipe-to-dismiss

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.
This commit is contained in:
jpolo1224
2026-08-21 10:47:35 -04:00
parent 0f0f719bce
commit caaf80749e
2 changed files with 63 additions and 8 deletions
@@ -29,9 +29,21 @@ object SaveSlotLookup {
*/ */
fun slotsFor(context: Context, serial: String?): List<Slot> { fun slotsFor(context: Context, serial: String?): List<Slot> {
if (serial.isNullOrBlank()) return emptyList() if (serial.isNullOrBlank()) return emptyList()
val root = runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull() ?: return emptyList() // ★ BOTH roots, not one.
return listOf("sstates", "savestates") //
.map { File(root, it) } // 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 } .filter { it.isDirectory }
.flatMap { dir -> .flatMap { dir ->
runCatching { runCatching {
@@ -46,6 +58,9 @@ object SaveSlotLookup {
?: return@mapNotNull null ?: return@mapNotNull null
Slot(slot, file, file.lastModified()) 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 } .distinctBy { it.slot }
.sortedBy { it.slot } .sortedBy { it.slot }
} }
@@ -18,6 +18,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf 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.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -220,13 +225,48 @@ fun PadModalHost() {
val anchor = entry.anchor.value val anchor = entry.anchor.value
// Absorb taps on the panel itself, or the scrim's dismiss fires through it and // Absorb taps on the panel itself, or the scrim's dismiss fires through it and
// the modal closes as you press its own buttons. // 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 -> val absorbTaps = @Composable { inner: @Composable () -> Unit ->
var dragOffset by remember { mutableFloatStateOf(0f) }
val dismissThresholdPx = with(LocalDensity.current) { 110.dp.toPx() }
Box( Box(
Modifier.clickable( Modifier
interactionSource = remember { MutableInteractionSource() }, .clickable(
indication = null, interactionSource = remember { MutableInteractionSource() },
onClick = {}, 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() } CompositionLocalProvider(LocalNavLayer provides entry.key) { inner() }
} }