diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db1ad6d..dd0d336 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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, diff --git a/app/src/main/java/pm/antani/resentin/MainActivity.kt b/app/src/main/java/pm/antani/resentin/MainActivity.kt index 756ad93..15ec256 100644 --- a/app/src/main/java/pm/antani/resentin/MainActivity.kt +++ b/app/src/main/java/pm/antani/resentin/MainActivity.kt @@ -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 @@ -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(null) private var pendingSharePick = mutableStateOf(false) @@ -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 diff --git a/app/src/main/java/pm/antani/resentin/data/prefs/AppPreferences.kt b/app/src/main/java/pm/antani/resentin/data/prefs/AppPreferences.kt index 1203828..a647d2a 100644 --- a/app/src/main/java/pm/antani/resentin/data/prefs/AppPreferences.kt +++ b/app/src/main/java/pm/antani/resentin/data/prefs/AppPreferences.kt @@ -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 { @@ -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> = context.dataStore.data.map { it[keyPinnedChannels] ?: emptySet() } @@ -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 = 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 = 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 = context.dataStore.data.map { it[keyAppLockHidePreview] ?: false } + + suspend fun setAppLockHidePreview(value: Boolean) { + context.dataStore.edit { it[keyAppLockHidePreview] = value } + } } diff --git a/app/src/main/java/pm/antani/resentin/ui/AppRoot.kt b/app/src/main/java/pm/antani/resentin/ui/AppRoot.kt index bf4ae6f..446c1c5 100644 --- a/app/src/main/java/pm/antani/resentin/ui/AppRoot.kt +++ b/app/src/main/java/pm/antani/resentin/ui/AppRoot.kt @@ -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 @@ -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 @@ -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)}") diff --git a/app/src/main/java/pm/antani/resentin/ui/applock/AppLock.kt b/app/src/main/java/pm/antani/resentin/ui/applock/AppLock.kt new file mode 100644 index 0000000..55465d2 --- /dev/null +++ b/app/src/main/java/pm/antani/resentin/ui/applock/AppLock.kt @@ -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(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)) + } + } + } +} diff --git a/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsScreen.kt b/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsScreen.kt index 43dab14..f39c488 100644 --- a/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsScreen.kt +++ b/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Person @@ -83,6 +84,7 @@ import androidx.core.content.ContextCompat import androidx.core.os.LocaleListCompat import org.unifiedpush.android.connector.UnifiedPush import pm.antani.resentin.R +import pm.antani.resentin.data.prefs.AppLockTimeout import pm.antani.resentin.data.prefs.ChatDisplayMode import pm.antani.resentin.data.prefs.AppFontFamily import pm.antani.resentin.data.prefs.MessageDensity @@ -93,6 +95,8 @@ import pm.antani.resentin.net.dto.VhostOptionDto import pm.antani.resentin.ui.chat.CreditsScreen import pm.antani.resentin.ui.common.LocalDensityScale import pm.antani.resentin.ui.common.ResentinHeaderAction +import pm.antani.resentin.ui.applock.authenticateWithSystemLock +import pm.antani.resentin.ui.applock.isSystemLockAvailable import pm.antani.resentin.ui.theme.toComposeFontFamily import java.time.Instant import java.time.ZoneId @@ -134,6 +138,7 @@ private enum class SettingsSection { APPEARANCE, CHAT, NOTIFICATIONS, + SECURITY, PRESENCE, IDENTITY, COMMANDS, @@ -145,6 +150,7 @@ private fun SettingsSection.icon(): ImageVector = when (this) { SettingsSection.APPEARANCE -> Icons.Outlined.Palette SettingsSection.CHAT -> Icons.Outlined.ChatBubbleOutline SettingsSection.NOTIFICATIONS -> Icons.Outlined.Notifications + SettingsSection.SECURITY -> Icons.Outlined.Lock SettingsSection.PRESENCE -> Icons.Outlined.Schedule SettingsSection.IDENTITY -> Icons.Outlined.Person SettingsSection.COMMANDS -> Icons.Outlined.Terminal @@ -156,6 +162,7 @@ private fun SettingsSection.title(): String = when (this) { SettingsSection.APPEARANCE -> stringResource(R.string.settings_group_appearance) SettingsSection.CHAT -> stringResource(R.string.settings_group_chat) SettingsSection.NOTIFICATIONS -> stringResource(R.string.settings_group_notifications) + SettingsSection.SECURITY -> stringResource(R.string.settings_group_security) SettingsSection.PRESENCE -> stringResource(R.string.settings_group_presence) SettingsSection.IDENTITY -> stringResource(R.string.settings_group_identity) SettingsSection.COMMANDS -> stringResource(R.string.settings_group_commands) @@ -167,6 +174,7 @@ private fun SettingsSection.description(): String = when (this) { SettingsSection.APPEARANCE -> stringResource(R.string.settings_group_appearance_desc) SettingsSection.CHAT -> stringResource(R.string.settings_group_chat_desc) SettingsSection.NOTIFICATIONS -> stringResource(R.string.settings_group_notifications_desc) + SettingsSection.SECURITY -> stringResource(R.string.settings_group_security_desc) SettingsSection.PRESENCE -> stringResource(R.string.settings_group_presence_desc) SettingsSection.IDENTITY -> stringResource(R.string.settings_group_identity_desc) SettingsSection.COMMANDS -> stringResource(R.string.settings_group_commands_desc) @@ -183,6 +191,9 @@ fun AppSettingsScreen(viewModel: AppSettingsViewModel, onBack: () -> Unit, onAdm val autoAwayReason by viewModel.autoAwayReason.collectAsState() val pushEnabled by viewModel.pushEnabled.collectAsState() val pushDecryptionFailureAt by viewModel.pushDecryptionFailureAt.collectAsState() + val appLockEnabled by viewModel.appLockEnabled.collectAsState() + val appLockTimeout by viewModel.appLockTimeout.collectAsState() + val appLockHidePreview by viewModel.appLockHidePreview.collectAsState() val chatDisplayMode by viewModel.chatDisplayMode.collectAsState() val showSeconds by viewModel.showSeconds.collectAsState() val showHostmaskInEvents by viewModel.showHostmaskInEvents.collectAsState() @@ -199,6 +210,7 @@ fun AppSettingsScreen(viewModel: AppSettingsViewModel, onBack: () -> Unit, onAdm val messageDbSizeBytes by viewModel.messageDbSizeBytes.collectAsState() var showClearMessagesConfirm by remember { mutableStateOf(false) } var showCredits by remember { mutableStateOf(false) } + var appLockError by remember { mutableStateOf(null) } val context = LocalContext.current val permissionLauncher = rememberLauncherForActivityResult( @@ -258,6 +270,41 @@ fun AppSettingsScreen(viewModel: AppSettingsViewModel, onBack: () -> Unit, onAdm } } + // Blocco app: l'attivazione richiede un'autenticazione di conferma col + // sistema (impronta/volto o PIN/segno), così l'utente capisce subito cosa + // gli verrà chiesto. La disattivazione è diretta: per arrivare qui l'app + // è già sbloccata. + val noScreenLockMessage = stringResource(R.string.settings_app_lock_no_screen_lock) + val appLockAuthTitle = stringResource(R.string.app_lock_auth_title) + val appLockAuthSubtitle = stringResource(R.string.settings_app_lock_desc) + + fun onAppLockChange(enabled: Boolean) { + if (!enabled) { + viewModel.setAppLockEnabled(false) + appLockError = null + return + } + if (!isSystemLockAvailable(context)) { + appLockError = noScreenLockMessage + return + } + val fragmentActivity = context as? androidx.fragment.app.FragmentActivity + if (fragmentActivity == null) { + appLockError = noScreenLockMessage + return + } + authenticateWithSystemLock( + activity = fragmentActivity, + title = appLockAuthTitle, + subtitle = appLockAuthSubtitle, + onSuccess = { + viewModel.setAppLockEnabled(true) + appLockError = null + }, + onError = { err -> appLockError = err?.toString() }, + ) + } + // On API 33+ the platform's own LocaleManager is the source of truth for the // per-app language (it's what drives the system Settings > Apps > Resentin > // Language picker too, via android:localeConfig in the manifest) — going through @@ -739,6 +786,53 @@ fun AppSettingsScreen(viewModel: AppSettingsViewModel, onBack: () -> Unit, onAdm ) } } + if (section == SettingsSection.SECURITY) { + item { + SettingsGroupCard( + showHeader = false, + icon = Icons.Outlined.Lock, + title = stringResource(R.string.settings_group_security), + description = stringResource(R.string.settings_group_security_desc), + ) { + SettingsSwitchRow( + title = stringResource(R.string.settings_app_lock), + description = stringResource(R.string.settings_app_lock_desc), + checked = appLockEnabled, + onCheckedChange = ::onAppLockChange, + ) + appLockError?.let { error -> + Spacer(Modifier.height(ResentinSpacing.xSmall)) + Text(error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + SettingsRowDivider() + SettingsBlockLabel(text = stringResource(R.string.settings_app_lock_timeout)) + Text( + stringResource(R.string.settings_app_lock_timeout_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(ResentinSpacing.small)) + ResentinDropdown( + selected = appLockTimeout, + options = listOf( + ResentinDropdownOption(AppLockTimeout.IMMEDIATELY, stringResource(R.string.settings_app_lock_timeout_immediately)), + ResentinDropdownOption(AppLockTimeout.AFTER_1_MIN, stringResource(R.string.settings_app_lock_timeout_1min)), + ResentinDropdownOption(AppLockTimeout.AFTER_5_MIN, stringResource(R.string.settings_app_lock_timeout_5min)), + ResentinDropdownOption(AppLockTimeout.AFTER_15_MIN, stringResource(R.string.settings_app_lock_timeout_15min)), + ), + onSelected = viewModel::setAppLockTimeout, + enabled = appLockEnabled, + ) + SettingsRowDivider() + SettingsSwitchRow( + title = stringResource(R.string.settings_app_lock_hide_preview), + description = stringResource(R.string.settings_app_lock_hide_preview_desc), + checked = appLockHidePreview, + onCheckedChange = viewModel::setAppLockHidePreview, + ) + } + } + } if (section == SettingsSection.PRESENCE) { item { SettingsGroupCard( @@ -1115,6 +1209,7 @@ private fun SettingsHubCard( add(SettingsSection.APPEARANCE) add(SettingsSection.CHAT) add(SettingsSection.NOTIFICATIONS) + add(SettingsSection.SECURITY) add(SettingsSection.PRESENCE) if (showIdentity) add(SettingsSection.IDENTITY) add(SettingsSection.COMMANDS) diff --git a/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsViewModel.kt b/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsViewModel.kt index 4d8b80c..2ff1ac5 100644 --- a/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsViewModel.kt +++ b/app/src/main/java/pm/antani/resentin/ui/appsettings/AppSettingsViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import org.unifiedpush.android.connector.UnifiedPush import pm.antani.resentin.data.prefs.AppPreferences import pm.antani.resentin.data.prefs.AppFontFamily +import pm.antani.resentin.data.prefs.AppLockTimeout import pm.antani.resentin.data.prefs.ChatDisplayMode import pm.antani.resentin.data.prefs.MessageDensity import pm.antani.resentin.data.prefs.ReplyStyle @@ -188,6 +189,27 @@ class AppSettingsViewModel( viewModelScope.launch { appPreferences.setReplyStyle(style) } } + val appLockEnabled: StateFlow = appPreferences.appLockEnabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + fun setAppLockEnabled(enabled: Boolean) { + viewModelScope.launch { appPreferences.setAppLockEnabled(enabled) } + } + + val appLockTimeout: StateFlow = appPreferences.appLockTimeout + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppLockTimeout.IMMEDIATELY) + + fun setAppLockTimeout(timeout: AppLockTimeout) { + viewModelScope.launch { appPreferences.setAppLockTimeout(timeout) } + } + + val appLockHidePreview: StateFlow = appPreferences.appLockHidePreview + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + fun setAppLockHidePreview(enabled: Boolean) { + viewModelScope.launch { appPreferences.setAppLockHidePreview(enabled) } + } + fun onReplyCustomTemplateChange(value: String) = _uiState.update { it.copy(replyCustomTemplate = value, replyCustomTemplateSaved = false) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 06bb5f0..d2ba330 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -812,4 +812,24 @@ Enter a valid email address. Password must be %1$d–%2$d characters. Paste the confirmation code from your email. - This network doesn\'t support guided registration. + This network doesn\'t support guided registration. + + + Security + App lock and preview + Lock the app + Require fingerprint, face or the phone PIN/pattern/password to open Resentin + Set a screen lock on your phone first (PIN, pattern or password), then try again + Lock after + How long after the app goes to background the lock engages again. On launch the app is always locked. + Immediately + After 1 minute + After 5 minutes + After 15 minutes + Hide preview + Blank Resentin in the recent-apps screen + Resentin is locked + Unlock with fingerprint, face or your phone PIN/pattern + Unlock + Unlock Resentin + Authentication failed, try again diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 162f45b..f0172a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -825,4 +825,24 @@ Inserisci un indirizzo email valido. La password deve essere di %1$d–%2$d caratteri. Incolla il codice di conferma ricevuto via email. - Questa rete non supporta la registrazione guidata. + Questa rete non supporta la registrazione guidata. + + + Sicurezza + Blocco app e anteprima + Blocca l\'app + Richiedi impronta digitale, volto oppure PIN/segno del telefono per aprire Resentin + Imposta prima un blocco schermo sul telefono (PIN, segno o password), poi riprova + Blocca dopo + Quanto tempo dopo che l\'app va in background scatta di nuovo il blocco. All\'avvio l\'app è sempre bloccata. + Subito + Dopo 1 minuto + Dopo 5 minuti + Dopo 15 minuti + Nascondi anteprima + Oscura Resentin nella schermata delle app recenti + Resentin è bloccata + Sblocca con impronta digitale, volto oppure PIN/segno del telefono + Sblocca + Sblocca Resentin + Autenticazione non riuscita, riprova diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 34aead8..a64ccd6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ okhttp = "5.5.0" room = "2.8.5" datastore = "1.1.1" securityCrypto = "1.1.0" +biometric = "1.1.0" appcompat = "1.8.0" ksp = "2.3.10" unifiedpushConnector = "3.3.5" @@ -47,6 +48,7 @@ androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } unifiedpush-connector = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpushConnector" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingEmbedded" }