093d52874539
M-07: transient background-lock suppression carries a deadline; expired flag never suppresses a real lock
- time
- 2026-07-29 15:02 UTC · 2 months ago
- machine
- koh2
- agent
- claude-opus-4
- session
- audit-remediation-2026-07-29
- parent
- 20500a0671be
↓ peach-android-093d5287.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+28 −6app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+27 −0app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+28 −6
@@ -8,7 +8,9 @@88 import java.security.SecureRandom99 import java.text.Normalizer1010 import kotlinx.serialization.json.Json11+import android.os.SystemClock1112 import android.util.Log13+import androidx.annotation.VisibleForTesting1214 import com.peachpasswords.BuildConfig1315 import com.peachpasswords.sync.SyncLimits1416 import kotlinx.serialization.json.JsonElement@@ -31,6 +33,13 @@3133 private const val PASSWORD_HISTORY_MAX = 503234 private const val VAULT_HISTORY_MAX = 53335 36+// How long the transient-background-lock suppression stays valid. If the37+// launched system Activity never reports the expected lifecycle stop (picker38+// crashed, user backed out into another app, OEM killed the picker process),39+// the flag expires and the next genuine background event locks the vault40+// rather than consuming a stale suppression.41+private const val BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS = 30_000L42+3443 sealed interface UnlockResult {3544 data object Success : UnlockResult3645 data object WrongPassword : UnlockResult@@ -746,8 +755,13 @@746755 }747756 }748757 749- @Volatile private var suppressNextBackgroundLock = false758+ @Volatile private var suppressNextBackgroundLockUntil: Long = 0L750759 760+ // Test seam so the expiry branch is reachable from plain JUnit (SystemClock761+ // is a stub off-device).762+ @VisibleForTesting763+ internal var nowMs: () -> Long = { SystemClock.elapsedRealtime() }764+751765 /**752766 * Call immediately before launching a system Activity (a SAF file753767 * picker/save dialog, a share sheet) that briefly takes the whole process@@ -756,16 +770,24 @@756770 * two apart on its own (it fires for both), and without this, exporting or757771 * importing a file silently locked and wiped the vault mid-flow the moment758772 * the picker took over, before its result ever reached the app.773+ *774+ * The suppression auto-expires after [BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS]775+ * (M-07); a never-consumed stale flag cannot suppress a later real lock.759776 */760777 fun expectTransientBackground() {761- suppressNextBackgroundLock = true778+ suppressNextBackgroundLockUntil = nowMs() + BACKGROUND_LOCK_SUPPRESSION_TIMEOUT_MS762779 }763780 764- /** Consumed once by the next background-lock check; see [expectTransientBackground]. */781+ /**782+ * Consumed once by the next background-lock check; see [expectTransientBackground].783+ * Returns false (and clears the flag) if the suppression has expired or was784+ * never set — a genuine background event must always lock.785+ */765786 fun consumeTransientBackgroundSuppression(): Boolean {766- val suppressed = suppressNextBackgroundLock767- suppressNextBackgroundLock = false768- return suppressed787+ val until = suppressNextBackgroundLockUntil788+ val active = until != 0L && nowMs() < until789+ suppressNextBackgroundLockUntil = 0L790+ return active769791 }770792 771793 // ---- Sync identity ----------------------------------------------------app/src/test/java/com/peachpasswords/vault/VaultRepositoryTest.kt+27 −0
@@ -463,4 +463,31 @@463463 assertNull(storage.slotA)464464 assertFalse(repo.hasVault())465465 }466+467+ // M-07: the transient-background suppression must expire so a stale flag468+ // can never suppress a later real lock. Drives the clock via the visible-469+ // for-testing seam in VaultRepository.470+ @Test fun transient_background_suppression_expires() = runBlocking {471+ val storage = FakeStorage()472+ val repo = VaultRepository(storage)473+ var fakeNow = 0L474+ repo.nowMs = { fakeNow }475+476+ // Never set — must always report not-suppressed.477+ assertFalse(repo.consumeTransientBackgroundSuppression())478+479+ // Set, still within the window — must report suppressed and clear.480+ repo.expectTransientBackground()481+ assertTrue(repo.consumeTransientBackgroundSuppression())482+ assertFalse(repo.consumeTransientBackgroundSuppression())483+484+ // Set, then advance the clock past the 30s timeout — must report485+ // not-suppressed (would-have-been-stale flag cannot suppress a real486+ // background event) and clear.487+ fakeNow = 0L488+ repo.expectTransientBackground()489+ fakeNow += 30_000L490+ assertFalse(repo.consumeTransientBackgroundSuppression())491+ assertFalse(repo.consumeTransientBackgroundSuppression())492+ }466493 }