mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
1 Commits
35e61e1d98
...
claude/app
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b050837ade |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user