diff --git a/app/build.gradle b/app/build.gradle
index 03d67f5..20252d1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -146,4 +146,6 @@ dependencies {
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
+
+ implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3d59dbe..fdab32f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,6 +24,8 @@
+
+
SupportType.AVAILABLE
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> SupportType.NONE_ENROLLED
+
+ else -> SupportType.UNAVAILABLE
+ }
+ }
+
+ fun authenticate(
+ context: Context,
+ title: String,
+ subtitle: String
+ ): CompletableDeferred {
+ val deferred = CompletableDeferred()
+
+ val mainExecutor = ContextCompat.getMainExecutor(context)
+ val biometricPrompt = BiometricPrompt(
+ context as FragmentActivity,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ deferred.complete(false)
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ deferred.complete(true)
+ }
+
+ override fun onAuthenticationFailed() {
+ deferred.complete(false)
+ }
+ }
+ )
+
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(title)
+ .setSubtitle(subtitle)
+ .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
+ .build()
+
+ biometricPrompt.authenticate(promptInfo)
+
+ return deferred
+ }
+
+ fun closeApp(context: Context) {
+ (context as? Activity)?.let {
+ it.finishAndRemoveTask()
+ it.finishAffinity()
+ it.finish()
+ }
+
+ exitProcess(0)
+ }
+ }
+}
diff --git a/app/src/main/java/app/myzel394/alibi/ui/AsLockedApp.kt b/app/src/main/java/app/myzel394/alibi/ui/AsLockedApp.kt
new file mode 100644
index 0000000..634f7a3
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/AsLockedApp.kt
@@ -0,0 +1,152 @@
+package app.myzel394.alibi.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.unit.dp
+import app.myzel394.alibi.R
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.helpers.AppLockHelper
+import kotlinx.coroutines.launch
+
+// After this amount, close the app
+const val MAX_TRIES = 5
+
+// Makes sure the app needs to be unlocked first, if app lock is enabled
+@Composable
+fun AsLockedApp(
+ content: (@Composable () -> Unit),
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ val settings = context
+ .dataStore
+ .data
+ .collectAsState(initial = null)
+ .value ?: return
+
+ // -1 = Unlocked, any other value = locked
+ var tries by remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(settings.isAppLockEnabled()) {
+ if (!settings.isAppLockEnabled()) {
+ tries = -1
+ }
+ }
+
+ if (tries == -1) {
+ return content()
+ }
+
+ val title = stringResource(R.string.identityVerificationRequired_title)
+ val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
+
+ fun openAuthentication() {
+ if (tries >= MAX_TRIES) {
+ AppLockHelper.closeApp(context)
+ return
+ }
+
+ scope.launch {
+ val successful = AppLockHelper.authenticate(
+ context,
+ title,
+ subtitle,
+ ).await()
+
+ if (successful) {
+ tries = -1
+ return@launch
+ }
+
+ tries++
+
+ if (tries >= MAX_TRIES) {
+ AppLockHelper.closeApp(context)
+ }
+ }
+ }
+
+ LaunchedEffect(settings.isAppLockEnabled()) {
+ if (settings.isAppLockEnabled()) {
+ openAuthentication()
+ }
+ }
+
+ Scaffold { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Box {}
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Icon(
+ Icons.Default.Fingerprint,
+ contentDescription = null,
+ modifier = Modifier
+ .size(64.dp)
+ )
+ Text(
+ text = stringResource(R.string.ui_locked_title),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ Button(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(BIG_PRIMARY_BUTTON_SIZE),
+ onClick = ::openAuthentication,
+ colors = ButtonDefaults.filledTonalButtonColors(),
+ ) {
+ Icon(
+ Icons.Default.Lock,
+ contentDescription = null,
+ modifier = Modifier
+ .size(ButtonDefaults.IconSize)
+ )
+ Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
+ Text(
+ text = stringResource(R.string.ui_locked_unlocked),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/LockedAppHandlers.kt b/app/src/main/java/app/myzel394/alibi/ui/LockedAppHandlers.kt
new file mode 100644
index 0000000..5db781c
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/LockedAppHandlers.kt
@@ -0,0 +1,32 @@
+package app.myzel394.alibi.ui
+
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.LocalContext
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
+
+// Handlers that can safely be run when the app is locked (biometric authentication required)
+@Composable
+fun LockedAppHandlers() {
+ val context = LocalContext.current
+ val settings = context
+ .dataStore
+ .data
+ .collectAsState(initial = null)
+ .value ?: return
+
+ LaunchedEffect(settings.theme) {
+ if (!SUPPORTS_DARK_MODE_NATIVELY) {
+ val currentValue = AppCompatDelegate.getDefaultNightMode()
+
+ if (settings.theme == AppSettings.Theme.LIGHT && currentValue != AppCompatDelegate.MODE_NIGHT_NO) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
+ } else if (settings.theme == AppSettings.Theme.DARK && currentValue != AppCompatDelegate.MODE_NIGHT_YES) {
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
index d9a73dd..78a30e0 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/Navigation.kt
@@ -1,8 +1,17 @@
package app.myzel394.alibi.ui
import android.content.Context
+import android.hardware.biometrics.BiometricManager.Authenticators.BIOMETRIC_STRONG
+import android.hardware.biometrics.BiometricManager.Authenticators.DEVICE_CREDENTIAL
+import android.hardware.biometrics.BiometricPrompt
+import android.hardware.biometrics.BiometricPrompt.CryptoObject
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
+import android.os.Build
+import android.os.CancellationSignal
+import android.widget.Button
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatDelegate
import androidx.camera.core.CameraX
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -19,11 +28,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
+import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.models.VideoRecorderModel
@@ -33,6 +45,7 @@ import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen
import app.myzel394.alibi.ui.utils.CameraInfo
+import app.myzel394.alibi.helpers.AppLockHelper
const val SCALE_IN = 1.25f
@@ -84,6 +97,7 @@ fun Navigation(
navController = navController,
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
+ settings = settings,
)
}
composable(
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/EnableAppLockTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/EnableAppLockTile.kt
new file mode 100644
index 0000000..aeddb5e
--- /dev/null
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/SettingsScreen/Tiles/EnableAppLockTile.kt
@@ -0,0 +1,92 @@
+package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.magnifier
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.myzel394.alibi.R
+import app.myzel394.alibi.dataStore
+import app.myzel394.alibi.db.AppLockSettings
+import app.myzel394.alibi.db.AppSettings
+import app.myzel394.alibi.helpers.AppLockHelper
+import app.myzel394.alibi.ui.components.atoms.SettingsTile
+import kotlinx.coroutines.launch
+
+@Composable
+fun EnableAppLockTile(
+ settings: AppSettings,
+) {
+ val scope = rememberCoroutineScope()
+
+ val context = LocalContext.current
+ val dataStore = context.dataStore
+
+ val appLockSupport = AppLockHelper.getSupportType(context)
+
+ if (appLockSupport === AppLockHelper.SupportType.UNAVAILABLE) {
+ return
+ }
+
+ SettingsTile(
+ title = stringResource(R.string.ui_settings_option_enableAppLock_title),
+ description = stringResource(R.string.ui_settings_option_enableAppLock_description),
+ tertiaryLine = {
+ if (appLockSupport === AppLockHelper.SupportType.NONE_ENROLLED) {
+ Text(
+ stringResource(R.string.ui_settings_option_enableAppLock_enrollmentRequired),
+ color = Color.Yellow,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ },
+ leading = {
+ Icon(
+ Icons.Default.Fingerprint,
+ contentDescription = null,
+ )
+ },
+ trailing = {
+ val title = stringResource(R.string.identityVerificationRequired_title)
+ val subtitle = stringResource(R.string.identityVerificationRequired_subtitle)
+
+ Switch(
+ checked = settings.isAppLockEnabled(),
+ enabled = appLockSupport === AppLockHelper.SupportType.AVAILABLE,
+ onCheckedChange = {
+ scope.launch {
+ val authenticationSuccessful = AppLockHelper.authenticate(
+ context,
+ title = title,
+ subtitle = subtitle,
+ ).await()
+
+ if (!authenticationSuccessful) {
+ return@launch
+ }
+
+ dataStore.updateData {
+ it.setAppLockSettings(
+ if (it.appLockSettings == null)
+ AppLockSettings.getDefaultInstance()
+ else
+ null
+ )
+ }
+ }
+ }
+ )
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/SettingsTile.kt b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/SettingsTile.kt
index 729995f..2f255fe 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/components/atoms/SettingsTile.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/components/atoms/SettingsTile.kt
@@ -21,6 +21,7 @@ fun SettingsTile(
firstModifier: Modifier = Modifier,
title: String,
description: String? = null,
+ tertiaryLine: (@Composable () -> Unit) = {},
leading: @Composable () -> Unit = {},
trailing: @Composable () -> Unit = {},
extra: (@Composable () -> Unit)? = null,
@@ -49,6 +50,7 @@ fun SettingsTile(
text = description,
style = MaterialTheme.typography.bodySmall,
)
+ tertiaryLine()
}
Spacer(modifier = Modifier.width(16.dp))
trailing()
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt
index 026d89b..cb5d969 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/RecorderScreen.kt
@@ -40,12 +40,11 @@ fun RecorderScreen(
navController: NavController,
audioRecorder: AudioRecorderModel,
videoRecorder: VideoRecorderModel,
+ settings: AppSettings,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
- val settings = rememberSettings()
-
RecorderEventsHandler(
settings = settings,
snackbarHostState = snackbarHostState,
diff --git a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
index 471ee2f..217641d 100644
--- a/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/app/myzel394/alibi/ui/screens/SettingsScreen.kt
@@ -52,6 +52,7 @@ import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.AudioRecorderOutput
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.AudioRecorderSamplingRateTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.SaveFolderTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.AudioRecorderShowAllMicrophonesTile
+import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.EnableAppLockTile
import app.myzel394.alibi.ui.components.SettingsScreen.Tiles.VideoRecorderBitrateTile
import app.myzel394.alibi.ui.components.SettingsScreen.atoms.ThemeSelector
import app.myzel394.alibi.ui.components.atoms.GlobalSwitch
@@ -138,6 +139,7 @@ fun SettingsScreen(
InAppLanguagePicker()
DeleteRecordingsImmediatelyTile(settings = settings)
CustomNotificationTile(navController = navController, settings = settings)
+ EnableAppLockTile(settings = settings)
GlobalSwitch(
label = stringResource(R.string.ui_settings_advancedSettings_label),
checked = settings.showAdvancedSettings,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 82ea4f6..ced0bc1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -160,4 +160,11 @@
Recording started %s
Saving now will save until %s
Video Recorder is starting...
+ Alibi is locked
+ Unlock
+ Enable App Lock
+ Require your biometric info or your password to open Alibi
+ Please enroll a password or a biometric unlock method first
+ Verification required
+ You need to verify your identity to continue
\ No newline at end of file