Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
b050837ade feat: add App Lock requiring biometrics or device PIN
Adds an opt-in App Lock that gates the whole app behind the device's
biometrics or PIN/pattern/password. The app locks when:

- it is opened (cold start) with the feature enabled,
- it returns to the foreground after 5+ minutes in the background.

Short app switches under the threshold do not re-prompt, so quick
context switches stay frictionless.

Implementation:
- AppLockController: testable pure-state core (cold-start lock +
  5-minute inactivity), mirroring AppForegroundCounter. AppLockState
  holds the process-wide instance; AppLockLifecycleHook feeds it
  foreground/background transitions (registered in Amethyst.onCreate).
- AppLockGate: full-screen overlay mounted at the activity root that
  reuses the existing biometric-or-keyguard authenticate() helper
  (BIOMETRIC_STRONG | DEVICE_CREDENTIAL). It swallows pointer events so
  content underneath can't be touched while locked.
- New app-wide UiSettings.appLock toggle, persisted in the shared
  DataStore, wired through UiSettingsFlow/UISharedPreferences and
  surfaced as a switch in UI Preferences. AppModules seeds the
  controller from the setting at startup and keeps it in sync.

No custom PIN is stored: the lock reuses the device credential, so
there is nothing extra to secure. Devices without a secure lock screen
auto-approve and are never stranded.

Adds AppLockControllerTest covering the timing/enable/unlock paths.

https://claude.ai/code/session_01DAvegeioRffVYaL9x2Rtv3
2026-06-14 16:27:33 +00:00
13 changed files with 605 additions and 1 deletions

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst
import android.app.Application
import com.vitorpamplona.amethyst.service.applock.AppLockLifecycleHook
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log
@@ -53,6 +54,12 @@ class Amethyst : Application() {
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
// App-lock gate: re-lock the app when it returns to the foreground after
// more than 5 minutes in the background. The enabled flag and the
// cold-start lock are wired from the persisted setting in
// AppModules.initiate(). See AppLockController for the threshold rationale.
registerActivityLifecycleCallbacks(AppLockLifecycleHook())
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug

View File

@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.applock.AppLockState
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
@@ -725,6 +726,15 @@ class AppModules(
// forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs
// App-lock gate: seed the controller from the persisted setting synchronously
// (before the first activity starts) so a cold start with the feature enabled
// paints the lock screen, then keep it in sync as the toggle changes.
AppLockState.setEnabled(uiPrefs.value.appLock.value)
AppLockState.lockIfEnabled()
applicationIOScope.launch {
uiPrefs.value.appLock.collect { AppLockState.setEnabled(it) }
}
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// Sets Coil - Tor - OkHttp link

View File

@@ -53,6 +53,7 @@ data class UiSettings(
val showProfileZapReceivedFeed: Boolean = true,
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
val appLock: Boolean = false,
)
enum class ThemeType(

View File

@@ -53,6 +53,7 @@ class UiSettingsFlow(
val showProfileZapReceivedFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
val appLock: MutableStateFlow<Boolean> = MutableStateFlow(false),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -80,6 +81,7 @@ class UiSettingsFlow(
showProfileZapReceivedFeed,
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
appLock,
)
// emits at every change in any of the propertyes.
@@ -111,6 +113,7 @@ class UiSettingsFlow(
flows[21] as Boolean,
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as Boolean,
)
}
@@ -140,6 +143,7 @@ class UiSettingsFlow(
showProfileZapReceivedFeed.value,
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
appLock.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -241,6 +245,10 @@ class UiSettingsFlow(
dontShowOnchainPublicWarning.tryEmit(torSettings.dontShowOnchainPublicWarning)
any = true
}
if (appLock.value != torSettings.appLock) {
appLock.tryEmit(torSettings.appLock)
any = true
}
return any
}
@@ -290,6 +298,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileZapReceivedFeed),
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.appLock),
)
}
}

View File

@@ -117,6 +117,7 @@ class UiSharedPreferences(
val UI_SHOW_PROFILE_ZAP_RECEIVED_FEED = booleanPreferencesKey("ui.show_profile_zap_received_feed")
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
val UI_APP_LOCK = booleanPreferencesKey("ui.app_lock")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -152,6 +153,7 @@ class UiSharedPreferences(
showProfileZapReceivedFeed = preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] ?: true,
showProfileFollowersFeed = preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] ?: true,
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
appLock = preferences[UI_APP_LOCK] ?: false,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -200,6 +202,7 @@ class UiSharedPreferences(
preferences[UI_SHOW_PROFILE_ZAP_RECEIVED_FEED] = sharedSettings.showProfileZapReceivedFeed
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
preferences[UI_APP_LOCK] = sharedSettings.appLock
}
} catch (e: Exception) {
if (e is CancellationException) throw e

View File

@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.applock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Pure-state core of the app-lock gate, decoupled from `android.app.Activity`
* so the unit tests can drive it without Robolectric or Mockito.
*
* See [AppLockLifecycleHook] / [AppLockState] for the production wiring — this
* class is just the testable core. It mirrors the structure of
* [com.vitorpamplona.amethyst.service.nests.AppForegroundCounter].
*
* When the feature is [enabled], the app becomes [isLocked] when:
* - it cold-starts ([lockIfEnabled] is called once the persisted setting loads), or
* - it returns to the foreground after spending at least [inactivityTimeoutMs]
* in the background.
*
* A short background (notification pull-down, the biometric/keyguard prompt itself,
* a quick app switch) under the threshold does NOT re-lock — otherwise the
* "5 minutes since last usage" rule would be meaningless and every glance at
* another app would demand a fingerprint.
*
* Threading: all state-mutating methods are documented to run on the Android main
* thread (Application lifecycle callbacks fire there); tests call them serially,
* so no synchronisation is needed.
*/
class AppLockController(
val inactivityTimeoutMs: Long = DEFAULT_INACTIVITY_TIMEOUT_MS,
private val nowMillis: () -> Long = { System.currentTimeMillis() },
) {
private val _isLocked = MutableStateFlow(false)
val isLocked: StateFlow<Boolean> = _isLocked
@Volatile
var enabled: Boolean = false
private set
private var startedActivities = 0
private var lastBackgroundedAtMillis: Long = -1L
/**
* Enable or disable the gate. Disabling immediately clears any active lock so the
* user isn't stranded on the lock screen after turning the feature off.
*/
fun setEnabled(value: Boolean) {
enabled = value
if (!value) _isLocked.value = false
}
/** Lock now if the feature is on. Called at cold start to gate the first paint. */
fun lockIfEnabled() {
if (enabled) _isLocked.value = true
}
/** Clears the lock after a successful authentication. */
fun unlock() {
_isLocked.value = false
// The keyguard PIN prompt is a separate activity, so confirming it stops and
// restarts MainActivity. Reset the timer so that return-to-foreground doesn't
// immediately re-lock.
lastBackgroundedAtMillis = -1L
}
/**
* Increment the started-activity counter and, on the 0 → 1 transition (app
* returning to the foreground) with the feature enabled, lock if the app spent
* at least [inactivityTimeoutMs] in the background. The first onActivityStarted
* after process start is a no-op (no prior background timestamp); cold-start
* locking is handled by [lockIfEnabled] instead.
*/
fun onActivityStarted() {
val wasBackgrounded = startedActivities == 0
startedActivities++
if (!wasBackgrounded) return
if (!enabled) return
val backgroundedAt = lastBackgroundedAtMillis
if (backgroundedAt < 0L) return
val backgroundedFor = nowMillis() - backgroundedAt
if (backgroundedFor >= inactivityTimeoutMs) {
_isLocked.value = true
}
}
/** Decrement the counter; on N → 0 transition, record the background timestamp. */
fun onActivityStopped() {
startedActivities--
if (startedActivities <= 0) {
startedActivities = 0
lastBackgroundedAtMillis = nowMillis()
}
}
companion object {
/** 5 minutes — the inactivity window after which a resume requires re-auth. */
const val DEFAULT_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000L
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.applock
import android.app.Activity
import android.app.Application
import android.os.Bundle
/**
* Application-wide observer that feeds foreground/background transitions to
* [AppLockState]'s controller so the app re-locks after enough time in the
* background. Registered once from
* [com.vitorpamplona.amethyst.Amethyst.onCreate].
*
* Kept separate from
* [com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook] even though
* both track the same start/stop counter: the thresholds and side effects differ
* (5 s socket-recycle vs 5 min security re-lock), and keeping them independent
* leaves each one a small, single-purpose, testable unit.
*/
class AppLockLifecycleHook : Application.ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) {
AppLockState.controller.onActivityStarted()
}
override fun onActivityStopped(activity: Activity) {
AppLockState.controller.onActivityStopped()
}
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?,
) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(
activity: Activity,
outState: Bundle,
) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.applock
import kotlinx.coroutines.flow.StateFlow
/**
* Process-wide holder for the single [AppLockController] used by the running app.
*
* A singleton is the right scope here: the lock state must survive activity
* recreation (rotation, dark-mode toggle, returning from background) and be shared
* by the lifecycle hook ([AppLockLifecycleHook]), the settings wiring
* ([com.vitorpamplona.amethyst.AppModules]) and the gate composable
* ([com.vitorpamplona.amethyst.ui.screen.AppLockGate]).
*/
object AppLockState {
val controller = AppLockController()
val isLocked: StateFlow<Boolean> get() = controller.isLocked
fun setEnabled(value: Boolean) = controller.setEnabled(value)
fun lockIfEnabled() = controller.lockIfEnabled()
fun unlock() = controller.unlock()
}

View File

@@ -26,6 +26,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
@@ -39,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AppLockGate
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -80,7 +84,13 @@ class MainActivity : AppCompatActivity() {
StringResSetup()
AmethystTheme {
NowProvider {
AccountScreen(Amethyst.instance.sessionManager)
Box(Modifier.fillMaxSize()) {
AccountScreen(Amethyst.instance.sessionManager)
// Overlays an opaque biometric/PIN lock over the whole app
// (login and logged-in alike) whenever the app-lock setting
// is on and the app has just opened or resumed after 5 min.
AppLockGate()
}
}
}
}

View File

@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.applock.AppLockState
import com.vitorpamplona.amethyst.ui.note.authenticate
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Full-screen gate drawn on top of the whole app. When [AppLockState] reports the
* app as locked it covers everything below it with an opaque lock screen that
* demands the device's biometrics or PIN before the content is revealed again.
*
* Mount this as the last child of a `Box` in the activity root so it overlays the
* login screen and the logged-in content alike.
*/
@Composable
fun AppLockGate() {
val locked by AppLockState.isLocked.collectAsStateWithLifecycle()
if (locked) {
AppLockScreen()
}
}
@Composable
private fun AppLockScreen() {
val context = LocalContext.current
// Device-credential (PIN/pattern/password) fallback launches a separate
// activity; unlock only on a confirmed result.
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
AppLockState.unlock()
}
}
fun prompt() {
// Reuses the shared biometric-or-keyguard helper: BIOMETRIC_STRONG with a
// device-credential (PIN) fallback, and an auto-approve when the device has
// no secure lock configured so the user can't be stranded.
authenticate(
title = stringRes(context, R.string.app_lock_unlock_subtitle),
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { AppLockState.unlock() },
onError = { _, _ -> },
)
}
LaunchedEffect(Unit) { prompt() }
Surface(
modifier =
Modifier
.fillMaxSize()
// Swallow every pointer event so the content drawn underneath this
// overlay can't be tapped/scrolled through the lock screen.
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
},
color = MaterialTheme.colorScheme.background,
) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = stringRes(R.string.app_lock_title),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(64.dp),
)
Text(
text = stringRes(R.string.app_lock_screen_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 24.dp),
)
Button(
onClick = { prompt() },
modifier = Modifier.padding(top = 24.dp),
) {
Text(stringRes(R.string.app_lock_unlock))
}
}
}
}

View File

@@ -33,6 +33,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -121,6 +122,19 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
ImmersiveScrollingChoice(sharedPrefs)
FeatureSetChoice(sharedPrefs)
GalleryChoice(sharedPrefs)
AppLockChoice(sharedPrefs)
}
}
@Composable
fun AppLockChoice(sharedPrefs: UiSettingsFlow) {
val appLock by sharedPrefs.appLock.collectAsState()
SettingsRow(R.string.app_lock_title, R.string.app_lock_description) {
Switch(
checked = appLock,
onCheckedChange = { sharedPrefs.appLock.tryEmit(it) },
)
}
}

View File

@@ -1717,6 +1717,12 @@
<string name="gallery_style">Profile Gallery Style</string>
<string name="gallery_style_description">Choose the gallery style</string>
<string name="app_lock_title">App Lock</string>
<string name="app_lock_description">Require biometrics or your device PIN to open Amethyst. Locks when the app starts and after 5 minutes in the background.</string>
<string name="app_lock_screen_title">Amethyst is locked</string>
<string name="app_lock_unlock">Unlock</string>
<string name="app_lock_unlock_subtitle">Unlock Amethyst</string>
<string name="load_image">Load Image</string>
<string name="spamming_users">Spammers</string>

View File

@@ -0,0 +1,183 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.applock
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [AppLockController] — the pure-state core of the app-lock gate.
* Drives the lifecycle transitions directly with a controllable clock so the
* 5-minute threshold logic doesn't need a real wall-clock wait.
*/
class AppLockControllerTest {
private val fiveMinutes = AppLockController.DEFAULT_INACTIVITY_TIMEOUT_MS
@Test
fun disabledByDefaultAndNotLocked() {
val controller = AppLockController()
assertFalse(controller.enabled)
assertFalse(controller.isLocked.value)
}
@Test
fun lockIfEnabledIsNoOpWhenDisabled() {
// Cold start with the feature off must never gate the first paint.
val controller = AppLockController()
controller.lockIfEnabled()
assertFalse(controller.isLocked.value)
}
@Test
fun lockIfEnabledLocksOnColdStartWhenEnabled() {
val controller = AppLockController()
controller.setEnabled(true)
controller.lockIfEnabled()
assertTrue("enabling + lockIfEnabled must lock on cold start", controller.isLocked.value)
}
@Test
fun disablingClearsAnActiveLock() {
val controller = AppLockController()
controller.setEnabled(true)
controller.lockIfEnabled()
assertTrue(controller.isLocked.value)
controller.setEnabled(false)
assertFalse("turning the feature off must release the lock screen", controller.isLocked.value)
}
@Test
fun shortBackgroundDoesNotRelock() {
// A quick app switch (well under 5 min) must not demand re-auth, otherwise
// the "5 minutes since last usage" rule would be meaningless.
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
controller.setEnabled(true)
fakeNow = 1_000L
controller.onActivityStarted() // cold start, no prior background
fakeNow = 2_000L
controller.onActivityStopped()
fakeNow = 2_000L + (fiveMinutes - 1) // backgrounded for just under 5 min
controller.onActivityStarted()
assertFalse("background < threshold must not re-lock", controller.isLocked.value)
}
@Test
fun backgroundLongerThanThresholdRelocks() {
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
controller.setEnabled(true)
fakeNow = 1_000L
controller.onActivityStarted()
fakeNow = 2_000L
controller.onActivityStopped()
fakeNow = 2_000L + fiveMinutes // backgrounded for exactly 5 min
controller.onActivityStarted()
assertTrue("background ≥ threshold must re-lock on resume", controller.isLocked.value)
}
@Test
fun longBackgroundDoesNotLockWhenDisabled() {
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
// feature never enabled
fakeNow = 1_000L
controller.onActivityStarted()
fakeNow = 2_000L
controller.onActivityStopped()
fakeNow = 2_000L + fiveMinutes * 10
controller.onActivityStarted()
assertFalse("disabled gate must never lock regardless of background time", controller.isLocked.value)
}
@Test
fun unlockClearsLockAndAvoidsImmediateRelock() {
// The keyguard PIN prompt is a separate activity: confirming it stops and
// restarts MainActivity. After unlock(), that restart must NOT re-lock even
// though the stop recorded a background timestamp.
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
controller.setEnabled(true)
controller.lockIfEnabled()
fakeNow = 1_000L
controller.onActivityStarted() // app foreground, lock screen visible
// user taps unlock -> keyguard activity launches -> main activity stops
fakeNow = 2_000L
controller.onActivityStopped()
// auth succeeds
controller.unlock()
assertFalse(controller.isLocked.value)
// keyguard returns, main activity restarts much later
fakeNow = 2_000L + fiveMinutes * 2
controller.onActivityStarted()
assertFalse("a fresh unlock must reset the timer so the return doesn't re-lock", controller.isLocked.value)
}
@Test
fun firstForegroundAfterProcessStartDoesNotLock() {
// The very first onActivityStarted has no prior background timestamp;
// cold-start locking is lockIfEnabled()'s job, not this path's.
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
controller.setEnabled(true)
fakeNow = 1_000L
controller.onActivityStarted()
assertFalse(controller.isLocked.value)
}
@Test
fun multipleActivitiesTrackForegroundCorrectly() {
// PiP / dialog activities overlay the main one. The app only truly
// backgrounds when the last started activity stops.
var fakeNow = 0L
val controller = AppLockController(nowMillis = { fakeNow })
controller.setEnabled(true)
fakeNow = 1_000L
controller.onActivityStarted() // cold start
fakeNow = 2_000L
controller.onActivityStarted() // second activity on top, still foreground
fakeNow = 3_000L
controller.onActivityStopped() // one left -> still foreground
fakeNow = 3_000L + fiveMinutes * 2
controller.onActivityStarted() // another comes on top, never backgrounded
assertFalse("never truly backgrounded must not lock", controller.isLocked.value)
// Now everything stops -> truly background, then resume after > 5 min.
controller.onActivityStopped()
controller.onActivityStopped()
fakeNow += fiveMinutes + 1
controller.onActivityStarted()
assertTrue("resume after a long true background must lock", controller.isLocked.value)
}
}