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