Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ dependencies {

implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.androidx.appcompat)
// Session-share QR scanning (login screen "scan QR code" — cicchetto's
// "open on another device"). A drop-in scanning Activity + ActivityResultContract,
Expand Down
16 changes: 14 additions & 2 deletions app/src/main/java/pm/antani/resentin/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.fragment.app.FragmentActivity
import pm.antani.resentin.data.prefs.MessageDensity
import pm.antani.resentin.data.prefs.AppFontFamily
import pm.antani.resentin.data.prefs.ThemeMode
Expand All @@ -24,7 +26,7 @@ import pm.antani.resentin.ui.common.LocalDensityScale
import pm.antani.resentin.ui.login.parseGrappaLoginLink
import pm.antani.resentin.ui.theme.ResentinTheme

class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {

private var pendingDeepLink = mutableStateOf<DeepLinkChat?>(null)
private var pendingSharePick = mutableStateOf(false)
Expand All @@ -45,6 +47,16 @@ class MainActivity : ComponentActivity() {
val lineHeightScale by container.appPreferences.lineHeightScale.collectAsState(initial = 1f)
val messageDensity by container.appPreferences.messageDensity.collectAsState(initial = MessageDensity.NORMAL)
val themeMode by container.appPreferences.themeMode.collectAsState(initial = ThemeMode.SYSTEM)
// Nascondi anteprima (Impostazioni → Sicurezza): oscura l'app nello
// switcher delle app recenti via FLAG_SECURE.
val hidePreview by container.appPreferences.appLockHidePreview.collectAsState(initial = false)
SideEffect {
if (hidePreview) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
val useDarkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
Expand Down
39 changes: 39 additions & 0 deletions app/src/main/java/pm/antani/resentin/data/prefs/AppPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ enum class ThemeMode {
DARK,
}

/** When the app lock engages after the app goes to background. Cold start
* always locks when the lock itself is enabled — this only controls the
* background → foreground grace period, chosen by the user in Settings. */
enum class AppLockTimeout(val seconds: Long) {
IMMEDIATELY(0),
AFTER_1_MIN(60),
AFTER_5_MIN(300),
AFTER_15_MIN(900),
}

/** Bundled monospace families available to the app. SYSTEM keeps the platform
* default for the regular UI and the platform monospace face for code/IRC rows. */
enum class AppFontFamily {
Expand Down Expand Up @@ -92,6 +102,9 @@ class AppPreferences(private val context: Context) {
private val keyLineSpacing = stringPreferencesKey("line_spacing")
private val keyLineHeightScale = floatPreferencesKey("line_height_scale")
private val keyDismissedUpdateVersion = stringPreferencesKey("dismissed_update_version")
private val keyAppLockEnabled = booleanPreferencesKey("app_lock_enabled")
private val keyAppLockTimeout = stringPreferencesKey("app_lock_timeout")
private val keyAppLockHidePreview = booleanPreferencesKey("app_lock_hide_preview")

val pinnedChannels: Flow<Set<String>> = context.dataStore.data.map { it[keyPinnedChannels] ?: emptySet() }

Expand Down Expand Up @@ -349,4 +362,30 @@ class AppPreferences(private val context: Context) {
if (epochMillis != null) it[keyPushDecryptionFailureAt] = epochMillis else it.remove(keyPushDecryptionFailureAt)
}
}

/** App lock (Settings → Sicurezza): UI gate via the system
* BiometricPrompt (fingerprint/face or device PIN/pattern/password).
* Off by default; enabling requires the device to have a screen lock. */
val appLockEnabled: Flow<Boolean> = context.dataStore.data.map { it[keyAppLockEnabled] ?: false }

suspend fun setAppLockEnabled(value: Boolean) {
context.dataStore.edit { it[keyAppLockEnabled] = value }
}

/** Grace period after backgrounding before the lock re-engages. */
val appLockTimeout: Flow<AppLockTimeout> = context.dataStore.data.map {
runCatching { AppLockTimeout.valueOf(it[keyAppLockTimeout] ?: AppLockTimeout.IMMEDIATELY.name) }
.getOrDefault(AppLockTimeout.IMMEDIATELY)
}

suspend fun setAppLockTimeout(timeout: AppLockTimeout) {
context.dataStore.edit { it[keyAppLockTimeout] = timeout.name }
}

/** Hides the app preview in the recent-apps switcher (FLAG_SECURE). */
val appLockHidePreview: Flow<Boolean> = context.dataStore.data.map { it[keyAppLockHidePreview] ?: false }

suspend fun setAppLockHidePreview(value: Boolean) {
context.dataStore.edit { it[keyAppLockHidePreview] = value }
}
}
55 changes: 55 additions & 0 deletions app/src/main/java/pm/antani/resentin/ui/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ package pm.antani.resentin.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
Expand All @@ -20,7 +29,9 @@ import java.net.URLDecoder
import java.net.URLEncoder
import pm.antani.resentin.AppContainer
import pm.antani.resentin.R
import pm.antani.resentin.data.prefs.AppLockTimeout
import pm.antani.resentin.irc.isQueryTarget
import pm.antani.resentin.ui.applock.LockScreen
import pm.antani.resentin.ui.appsettings.AppSettingsScreen
import pm.antani.resentin.ui.appsettings.AppSettingsViewModel
import pm.antani.resentin.ui.channelsettings.ChannelSettingsScreen
Expand Down Expand Up @@ -93,6 +104,50 @@ fun AppRoot(
val currentSession = session!!
val navController = rememberNavController()

// Blocco app (Impostazioni → Sicurezza): al cold start parte sempre
// bloccata quando attivo; da background a foreground si riblocca dopo il
// periodo di tolleranza scelto dall'utente. Finché è bloccata il NavHost
// non è composto, quindi nessun contenuto è raggiungibile (deep-link e
// share restano in sospeso fino allo sblocco).
// `initial = null` = preferenza non ancora caricata: finché è null si
// resta bloccati (fail-closed), altrimenti il `false` iniziale sbloccherebbe
// l'app per un istante a ogni avvio anche col blocco attivo.
val appLockEnabled by container.appPreferences.appLockEnabled.collectAsState(initial = null)
val appLockTimeout by container.appPreferences.appLockTimeout.collectAsState(initial = AppLockTimeout.IMMEDIATELY)
var locked by remember { mutableStateOf(true) }
LaunchedEffect(appLockEnabled) {
if (appLockEnabled == false) locked = false
}
var backgroundedAt by remember { mutableLongStateOf(0L) }
val lockEnabledState by rememberUpdatedState(appLockEnabled)
val lockTimeoutState by rememberUpdatedState(appLockTimeout)
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
backgroundedAt = android.os.SystemClock.elapsedRealtime()
}
Lifecycle.Event.ON_START -> {
if (lockEnabledState == true && !locked && backgroundedAt != 0L) {
val elapsedSec =
(android.os.SystemClock.elapsedRealtime() - backgroundedAt) / 1000
if (elapsedSec >= lockTimeoutState.seconds) locked = true
}
backgroundedAt = 0L
}
else -> Unit
}
}
val lifecycle = ProcessLifecycleOwner.get().lifecycle
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}

if (appLockEnabled == true && locked) {
LockScreen(onUnlocked = { locked = false })
return
}

LaunchedEffect(deepLink) {
if (deepLink != null) {
navController.navigate("chat/${deepLink.networkSlug}/${encode(deepLink.channelName)}")
Expand Down
197 changes: 197 additions & 0 deletions app/src/main/java/pm/antani/resentin/ui/applock/AppLock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package pm.antani.resentin.ui.applock

import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import pm.antani.resentin.R
import pm.antani.resentin.ui.theme.ResentinSpacing

/** Whether this device can do a system lock auth at all (biometrics or
* PIN/pattern/password). Pre-30 the biometric check alone misses
* PIN-only devices, so the Keyguard state is OR-ed in. */
fun isSystemLockAvailable(context: Context): Boolean {
val keyguard = context.getSystemService(KeyguardManager::class.java)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
BiometricManager.from(context)
.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) ==
BiometricManager.BIOMETRIC_SUCCESS
} else {
@Suppress("DEPRECATION")
BiometricManager.from(context).canAuthenticate() ==
BiometricManager.BIOMETRIC_SUCCESS ||
(keyguard?.isDeviceSecure == true)
}
}

/** One-shot system auth (fingerprint/face or device PIN/pattern/password).
* [activity] must be a FragmentActivity — BiometricPrompt's requirement. */
fun authenticateWithSystemLock(
activity: FragmentActivity,
title: String,
subtitle: String?,
onSuccess: () -> Unit,
onError: (CharSequence?) -> Unit,
) {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// User cancel/dismiss is not an error to surface — they can retry
// with the Sblocca button.
if (errorCode == BiometricPrompt.ERROR_USER_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_CANCELED
) {
return
}
onError(errString)
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val infoBuilder = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setConfirmationRequired(false)
if (subtitle != null) infoBuilder.setSubtitle(subtitle)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
infoBuilder.setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
} else {
// DEVICE_CREDENTIAL via the compat path: no negative button allowed
// when the device credential is part of the prompt.
@Suppress("DEPRECATION")
infoBuilder.setDeviceCredentialAllowed(true)
}
prompt.authenticate(infoBuilder.build())
}

/** Full-screen gate shown instead of the app content while locked. Auth is
* offered automatically on first composition plus on every tap of Sblocca. */
@Composable
fun LockScreen(
onUnlocked: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
val title = stringResource(R.string.app_lock_auth_title)
val subtitle = stringResource(R.string.app_lock_subtitle)

fun launchAuth(onFailed: (CharSequence?) -> Unit) {
val fragmentActivity = activity ?: return
authenticateWithSystemLock(
activity = fragmentActivity,
title = title,
subtitle = subtitle,
onSuccess = onUnlocked,
onError = { onFailed(it) },
)
}

// Auto-prompt once, so returning to the app asks immediately instead of
// waiting for a tap. A cancelled prompt just leaves the button.
var autoPrompted by remember { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
if (!autoPrompted) {
autoPrompted = true
launchAuth { err -> errorText = err?.toString() }
}
}

Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(
modifier = Modifier.fillMaxSize().padding(ResentinSpacing.xLarge),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
modifier = Modifier.size(72.dp),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.primaryContainer,
) {
androidx.compose.foundation.layout.Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Outlined.Lock,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
Spacer(Modifier.height(ResentinSpacing.large))
Text(
stringResource(R.string.app_lock_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(ResentinSpacing.small))
Text(
stringResource(R.string.app_lock_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (errorText != null) {
Spacer(Modifier.height(ResentinSpacing.small))
Text(
errorText.toString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
} else if (activity == null) {
Spacer(Modifier.height(ResentinSpacing.small))
Text(
stringResource(R.string.app_lock_auth_failed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(ResentinSpacing.large))
Button(
onClick = { launchAuth { err -> errorText = err?.toString() } },
enabled = activity != null,
shape = MaterialTheme.shapes.medium,
) {
Text(stringResource(R.string.app_lock_unlock))
}
}
}
}
Loading