diff --git a/app/src/main/java/dev/pranav/applock/core/navigation/AppNavigator.kt b/app/src/main/java/dev/pranav/applock/core/navigation/AppNavigator.kt index cace84e..36784c6 100644 --- a/app/src/main/java/dev/pranav/applock/core/navigation/AppNavigator.kt +++ b/app/src/main/java/dev/pranav/applock/core/navigation/AppNavigator.kt @@ -20,8 +20,10 @@ import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.antiuninstall.ui.AntiUninstallScreen import dev.pranav.applock.features.appintro.ui.AppIntroScreen import dev.pranav.applock.features.applist.ui.MainScreen -import dev.pranav.applock.features.lockscreen.ui.PasswordOverlayScreen +import dev.pranav.applock.features.lockscreen.ui.AlphanumericPasswordOverlayScreen +import dev.pranav.applock.features.lockscreen.ui.PinPasswordOverlayScreen import dev.pranav.applock.features.lockscreen.ui.PatternLockScreen +import dev.pranav.applock.features.setpassword.ui.AlphanumericSetPasswordScreen import dev.pranav.applock.features.setpassword.ui.PatternSetPasswordScreen import dev.pranav.applock.features.setpassword.ui.SetPasswordScreen import dev.pranav.applock.features.settings.ui.SettingsScreen @@ -52,10 +54,16 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { } composable(Screen.ChangePassword.route) { - if (application.appLockRepository.getLockType() == PreferencesRepository.LOCK_TYPE_PATTERN) { - PatternSetPasswordScreen(navController, false) - } else { - SetPasswordScreen(navController, isFirstTimeSetup = false) + when (application.appLockRepository.getLockType()) { + PreferencesRepository.LOCK_TYPE_PATTERN -> { + PatternSetPasswordScreen(navController, false) + } + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + AlphanumericSetPasswordScreen(navController, false) + } + else -> { + SetPasswordScreen(navController, isFirstTimeSetup = false) + } } } @@ -63,6 +71,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { PatternSetPasswordScreen(navController, isFirstTimeSetup = true) } + composable(Screen.SetPasswordAlphanumeric.route) { + AlphanumericSetPasswordScreen(navController, isFirstTimeSetup = true) + } + composable(Screen.Main.route) { MainScreen(navController) } @@ -88,8 +100,21 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { ) } + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + AlphanumericPasswordOverlayScreen( + showBiometricButton = application.appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = true, + onBiometricAuth = { + handleBiometricAuthentication(context, navController) + }, + onAuthSuccess = { + handleAuthenticationSuccess(navController) + } + ) + } + else -> { - PasswordOverlayScreen( + PinPasswordOverlayScreen( showBiometricButton = application.appLockRepository.isBiometricAuthEnabled(), fromMainActivity = true, onBiometricAuth = { diff --git a/app/src/main/java/dev/pranav/applock/core/navigation/Screen.kt b/app/src/main/java/dev/pranav/applock/core/navigation/Screen.kt index 1043033..09cb474 100644 --- a/app/src/main/java/dev/pranav/applock/core/navigation/Screen.kt +++ b/app/src/main/java/dev/pranav/applock/core/navigation/Screen.kt @@ -4,6 +4,7 @@ sealed class Screen(val route: String) { object AppIntro : Screen("app_intro") object SetPassword : Screen("set_password") object SetPasswordPattern : Screen("set_password_pattern") + object SetPasswordAlphanumeric : Screen("set_password_alphanumeric") object ChangePassword : Screen("change_password") object Main : Screen("main") object PasswordOverlay : Screen("password_overlay") diff --git a/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt new file mode 100644 index 0000000..b68f170 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt @@ -0,0 +1,69 @@ +package dev.pranav.applock.core.utils + +import android.util.Base64 +import java.security.MessageDigest +import java.security.SecureRandom + +object SecurityUtils { + + private const val HASH_ALGORITHM = "SHA-256" + private const val SALT_LENGTH = 16 + private const val MAX_PASSWORD_LENGTH = 64 + + /** + * Sanitizes the input string by filtering out control characters and null bytes. + * Also limits the length to [MAX_PASSWORD_LENGTH]. + */ + fun sanitizePassword(input: String): String { + return input.filter { it.code >= 32 && it.code != 127 } + .take(MAX_PASSWORD_LENGTH) + } + + /** + * Generates a random cryptographic salt. + */ + fun generateSalt(): ByteArray { + val random = SecureRandom() + val salt = ByteArray(SALT_LENGTH) + random.nextBytes(salt) + return salt + } + + /** + * Hashes the password using SHA-256 with the provided salt. + * Returns a Base64 encoded string containing the salt and the hash. + * Format: salt:hash + */ + fun hashPassword(password: String, salt: ByteArray): String { + val md = MessageDigest.getInstance(HASH_ALGORITHM) + md.update(salt) + val hashedPassword = md.digest(password.toByteArray(Charsets.UTF_8)) + + val saltBase64 = Base64.encodeToString(salt, Base64.NO_WRAP) + val hashBase64 = Base64.encodeToString(hashedPassword, Base64.NO_WRAP) + + return "$saltBase64:$hashBase64" + } + + /** + * Verifies an input password against a stored salted hash string. + */ + fun verifyPassword(inputPassword: String, storedSaltedHash: String): Boolean { + return try { + val parts = storedSaltedHash.split(":") + if (parts.size != 2) return false + + val salt = Base64.decode(parts[0], Base64.DEFAULT) + val storedHash = parts[1] + + val md = MessageDigest.getInstance(HASH_ALGORITHM) + md.update(salt) + val inputHashed = md.digest(inputPassword.toByteArray(Charsets.UTF_8)) + val inputHashBase64 = Base64.encodeToString(inputHashed, Base64.NO_WRAP) + + inputHashBase64 == storedHash + } catch (e: Exception) { + false + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/data/repository/PreferencesRepository.kt b/app/src/main/java/dev/pranav/applock/data/repository/PreferencesRepository.kt index 20a7f10..73c3edc 100644 --- a/app/src/main/java/dev/pranav/applock/data/repository/PreferencesRepository.kt +++ b/app/src/main/java/dev/pranav/applock/data/repository/PreferencesRepository.kt @@ -1,5 +1,6 @@ package dev.pranav.applock.data.repository +import dev.pranav.applock.core.utils.SecurityUtils import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit @@ -17,7 +18,9 @@ class PreferencesRepository(context: Context) { context.getSharedPreferences(PREFS_NAME_SETTINGS, Context.MODE_PRIVATE) fun setPassword(password: String) { - appLockPrefs.edit { putString(KEY_PASSWORD, password) } + val salt = SecurityUtils.generateSalt() + val saltedHash = SecurityUtils.hashPassword(password, salt) + appLockPrefs.edit(commit = true) { putString(KEY_PASSWORD, saltedHash) } } fun getPassword(): String? { @@ -25,12 +28,17 @@ class PreferencesRepository(context: Context) { } fun validatePassword(inputPassword: String): Boolean { - val storedPassword = getPassword() - return storedPassword != null && inputPassword == storedPassword + val storedSaltedHash = getPassword() ?: return false + + // If it's a legacy plain text password (doesn't contain ':'), migrate it or validate as is + // For simplicity and since this is a new feature, we assume all passwords should be hashed. + // If there's an existing plain text password, this will fail validation and require reset. + + return SecurityUtils.verifyPassword(inputPassword, storedSaltedHash) } fun setPattern(pattern: String) { - appLockPrefs.edit { putString(KEY_PATTERN, pattern) } + appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, pattern) } } fun getPattern(): String? { @@ -43,7 +51,7 @@ class PreferencesRepository(context: Context) { } fun setLockType(lockType: String) { - settingsPrefs.edit { putString(KEY_LOCK_TYPE, lockType) } + settingsPrefs.edit(commit = true) { putString(KEY_LOCK_TYPE, lockType) } } fun getLockType(): String { @@ -180,5 +188,6 @@ class PreferencesRepository(context: Context) { const val LOCK_TYPE_PIN = "pin" const val LOCK_TYPE_PATTERN = "pattern" + const val LOCK_TYPE_PASSWORD = "password" } } diff --git a/app/src/main/java/dev/pranav/applock/features/admin/AdminDisableActivity.kt b/app/src/main/java/dev/pranav/applock/features/admin/AdminDisableActivity.kt index 09708a9..16e9cc7 100644 --- a/app/src/main/java/dev/pranav/applock/features/admin/AdminDisableActivity.kt +++ b/app/src/main/java/dev/pranav/applock/features/admin/AdminDisableActivity.kt @@ -7,26 +7,27 @@ import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent -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.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import dev.pranav.applock.R import dev.pranav.applock.core.broadcast.DeviceAdmin +import dev.pranav.applock.core.utils.SecurityUtils import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.data.repository.AppLockRepository import dev.pranav.applock.data.repository.PreferencesRepository @@ -99,6 +100,44 @@ class AdminDisableActivity : ComponentActivity() { ) } + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + AdminDisablePasswordScreen( + modifier = Modifier.padding(padding), + onPasswordVerified = { + val deviceAdmin = DeviceAdmin() + deviceAdmin.setPasswordVerified(this@AdminDisableActivity, true) + + Toast.makeText( + this@AdminDisableActivity, + R.string.password_verified_admin, + Toast.LENGTH_SHORT + ).show() + appLockRepository.setAntiUninstallEnabled(false) + finish() + }, + onCancel = { + val deviceAdmin = DeviceAdmin() + deviceAdmin.setPasswordVerified( + this@AdminDisableActivity, + false + ) + finish() + }, + validatePassword = { inputPassword -> + appLockRepository.validatePassword(inputPassword) + .also { isValid -> + if (!isValid) { + Toast.makeText( + this@AdminDisableActivity, + R.string.incorrect_password_try_again, + Toast.LENGTH_SHORT + ).show() + } + } + } + ) + } + else -> { AdminDisableScreen( modifier = Modifier.padding(padding), @@ -212,6 +251,107 @@ fun AdminDisableScreen( } } +@Composable +fun AdminDisablePasswordScreen( + modifier: Modifier = Modifier, + onPasswordVerified: () -> Unit, + onCancel: () -> Unit, + validatePassword: (String) -> Boolean +) { + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + var passwordState by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + var passwordVisible by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.unlock_to_disable_admin), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = passwordState, + onValueChange = { input -> + passwordState = SecurityUtils.sanitizePassword(input) + showError = false + }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + label = { Text(stringResource(R.string.password_hint)) }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + val image = if (passwordVisible) + Icons.Filled.Visibility + else Icons.Filled.VisibilityOff + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, contentDescription = null) + } + }, + isError = showError, + singleLine = true + ) + + if (showError) { + Text( + text = stringResource(R.string.incorrect_password_try_again), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + TextButton( + onClick = onCancel, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.cancel_button)) + } + + Button( + onClick = { + if (validatePassword(passwordState)) { + onPasswordVerified() + } else { + showError = true + passwordState = "" + } + }, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.verify_button)) + } + } + } + } +} + @Composable fun AdminDisablePatternScreen( modifier: Modifier = Modifier, @@ -253,6 +393,15 @@ fun AdminDisablePatternScreen( isValid } ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextButton( + onClick = onCancel, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Text(stringResource(R.string.cancel_button)) + } } } } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt new file mode 100644 index 0000000..73190c4 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt @@ -0,0 +1,224 @@ +package dev.pranav.applock.features.lockscreen.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.pranav.applock.R +import dev.pranav.applock.core.utils.SecurityUtils +import dev.pranav.applock.core.utils.appLockRepository +import dev.pranav.applock.ui.icons.Fingerprint + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AlphanumericPasswordOverlayScreen( + modifier: Modifier = Modifier, + showBiometricButton: Boolean = false, + fromMainActivity: Boolean = false, + showCloseButton: Boolean = false, + onClose: () -> Unit = {}, + onBiometricAuth: () -> Unit = {}, + onAuthSuccess: () -> Unit, + lockedAppName: String? = null, + triggeringPackageName: String? = null, + onPasswordAttempt: ((password: String) -> Boolean)? = null +) { + val appLockRepository = LocalContext.current.appLockRepository() + var passwordState by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + var passwordVisible by remember { mutableStateOf(false) } + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + val minLength = 4 + + Surface( + modifier = modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Box(modifier = Modifier.fillMaxSize()) { + if (showCloseButton) { + IconButton( + onClick = onClose, + modifier = Modifier + .statusBarsPadding() + .padding(start = 8.dp, top = 8.dp) + .align(Alignment.TopStart) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + .statusBarsPadding() + .navigationBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) + "Continue to $lockedAppName" + else + stringResource(R.string.enter_password_to_continue), + style = MaterialTheme.typography.headlineMediumEmphasized, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = passwordState, + onValueChange = { input -> + passwordState = SecurityUtils.sanitizePassword(input) + showError = false + }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + label = { Text(stringResource(R.string.password_hint)) }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (passwordState.length >= minLength) { + performVerification( + passwordState, + fromMainActivity, + appLockRepository, + onAuthSuccess, + onPasswordAttempt, + onIncorrect = { + passwordState = "" + showError = true + } + ) + } + } + ), + trailingIcon = { + val image = if (passwordVisible) + Icons.Filled.Visibility + else Icons.Filled.VisibilityOff + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, contentDescription = null) + } + }, + isError = showError, + singleLine = true + ) + + if (showError) { + Text( + text = stringResource(R.string.incorrect_password_try_again), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (showBiometricButton) { + FilledTonalIconButton( + onClick = onBiometricAuth, + modifier = Modifier.size(56.dp), + shape = MaterialTheme.shapes.medium, + ) { + Icon( + imageVector = Fingerprint, + modifier = Modifier.size(24.dp), + contentDescription = stringResource(R.string.biometric_authentication_cd), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + Button( + onClick = { + if (passwordState.length >= minLength) { + performVerification( + passwordState, + fromMainActivity, + appLockRepository, + onAuthSuccess, + onPasswordAttempt, + onIncorrect = { + passwordState = "" + showError = true + } + ) + } + }, + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.medium + ) { + Text(stringResource(R.string.verify_button)) + } + } + } + } + } + + BackHandler { } +} + +private fun performVerification( + passwordState: String, + fromMainActivity: Boolean, + appLockRepository: dev.pranav.applock.data.repository.AppLockRepository, + onAuthSuccess: () -> Unit, + onPasswordAttempt: ((password: String) -> Boolean)?, + onIncorrect: () -> Unit +) { + if (fromMainActivity) { + if (appLockRepository.validatePassword(passwordState)) { + onAuthSuccess() + } else { + onIncorrect() + } + } else { + onPasswordAttempt?.let { attempt -> + if (attempt(passwordState)) { + onAuthSuccess() + } else { + onIncorrect() + } + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt index f473293..5667f49 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt @@ -12,6 +12,8 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.* +import dev.pranav.applock.features.lockscreen.ui.AlphanumericPasswordOverlayScreen +import dev.pranav.applock.features.lockscreen.ui.PinPasswordOverlayScreen import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner @@ -99,46 +101,80 @@ class LockScreenOverlayManager(private val context: Context): val lockType = appLockRepository.getLockType() - if (lockType == PreferencesRepository.LOCK_TYPE_PATTERN) { - PatternLockScreen( - fromMainActivity = false, - showCloseButton = true, - onClose = { - onExit() - removeOverlay() - }, - lockedAppName = appName, - triggeringPackageName = triggeringPackageName, - onPatternAttempt = onPatternAttemptCallback - ) - } else { - PasswordOverlayScreen( - showBiometricButton = appLockRepository.isBiometricAuthEnabled(), - fromMainActivity = false, - showCloseButton = true, - onClose = { - onExit() - removeOverlay() - }, - lockedAppName = appName, - triggeringPackageName = triggeringPackageName, - onAuthSuccess = { - onUnlock() - removeOverlay() - }, - onBiometricAuth = { - val intent = Intent( - context, - TransparentBiometricActivity::class.java - ).apply { - flags = - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION - putExtra("locked_package", lockedPackageName) - } - context.startActivity(intent) - }, - onPinAttempt = onPinAttemptCallback - ) + when (lockType) { + PreferencesRepository.LOCK_TYPE_PATTERN -> { + PatternLockScreen( + fromMainActivity = false, + showCloseButton = true, + onClose = { + onExit() + removeOverlay() + }, + lockedAppName = appName, + triggeringPackageName = triggeringPackageName, + onPatternAttempt = onPatternAttemptCallback + ) + } + + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + AlphanumericPasswordOverlayScreen( + showBiometricButton = appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = false, + showCloseButton = true, + onClose = { + onExit() + removeOverlay() + }, + lockedAppName = appName, + triggeringPackageName = triggeringPackageName, + onAuthSuccess = { + onUnlock() + removeOverlay() + }, + onBiometricAuth = { + val intent = Intent( + context, + TransparentBiometricActivity::class.java + ).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION + putExtra("locked_package", lockedPackageName) + } + context.startActivity(intent) + }, + onPasswordAttempt = onPinAttemptCallback + ) + } + + else -> { + PinPasswordOverlayScreen( + showBiometricButton = appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = false, + showCloseButton = true, + onClose = { + onExit() + removeOverlay() + }, + lockedAppName = appName, + triggeringPackageName = triggeringPackageName, + onAuthSuccess = { + onUnlock() + removeOverlay() + }, + onBiometricAuth = { + val intent = Intent( + context, + TransparentBiometricActivity::class.java + ).apply { + flags = + Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION + putExtra("locked_package", lockedPackageName) + } + context.startActivity(intent) + }, + onPinAttempt = onPinAttemptCallback + ) + } } } } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PasswordOverlayScreen.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PasswordOverlayScreen.kt index 7f5f5ae..c485bd3 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PasswordOverlayScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PasswordOverlayScreen.kt @@ -1,5 +1,6 @@ package dev.pranav.applock.features.lockscreen.ui +import dev.pranav.applock.features.lockscreen.ui.AlphanumericPasswordOverlayScreen import android.content.Context import android.content.res.Configuration import android.os.Build @@ -84,7 +85,7 @@ class PasswordOverlayActivity: FragmentActivity() { enableEdgeToEdge() - appLockRepository = AppLockRepository(applicationContext) + appLockRepository = applicationContext.appLockRepository() onBackPressedDispatcher.addCallback( this, @@ -193,7 +194,7 @@ class PasswordOverlayActivity: FragmentActivity() { modifier = Modifier.fillMaxSize(), contentColor = MaterialTheme.colorScheme.primaryContainer ) { innerPadding -> - val lockType = remember { appLockRepository.getLockType() } + val lockType = appLockRepository.getLockType() when (lockType) { PreferencesRepository.LOCK_TYPE_PATTERN -> { PatternLockScreen( @@ -205,8 +206,23 @@ class PasswordOverlayActivity: FragmentActivity() { ) } + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + AlphanumericPasswordOverlayScreen( + modifier = Modifier.padding(innerPadding), + showBiometricButton = appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = false, + onBiometricAuth = { triggerBiometricPrompt() }, + onAuthSuccess = {}, + lockedAppName = appName, + triggeringPackageName = triggeringPackageNameFromIntent, + onPasswordAttempt = onPinAttemptCallback, + showCloseButton = true, + onClose = { finish() } + ) + } + else -> { - PasswordOverlayScreen( + PinPasswordOverlayScreen( modifier = Modifier.padding(innerPadding), showBiometricButton = appLockRepository.isBiometricAuthEnabled(), fromMainActivity = false, @@ -335,7 +351,7 @@ class PasswordOverlayActivity: FragmentActivity() { @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) @Composable -fun PasswordOverlayScreen( +fun PinPasswordOverlayScreen( modifier: Modifier = Modifier, showBiometricButton: Boolean = false, fromMainActivity: Boolean = false, diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt new file mode 100644 index 0000000..51a1350 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt @@ -0,0 +1,343 @@ +package dev.pranav.applock.features.setpassword.ui + +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.navigation.NavController +import dev.pranav.applock.AppLockApplication +import dev.pranav.applock.R +import dev.pranav.applock.core.navigation.Screen +import dev.pranav.applock.core.utils.SecurityUtils +import dev.pranav.applock.data.repository.PreferencesRepository + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AlphanumericSetPasswordScreen( + navController: NavController, + isFirstTimeSetup: Boolean +) { + var passwordState by remember { mutableStateOf("") } + var confirmPasswordState by remember { mutableStateOf("") } + var isConfirmationMode by remember { mutableStateOf(false) } + var isVerifyOldPasswordMode by remember { mutableStateOf(!isFirstTimeSetup) } + + var passwordVisible by remember { mutableStateOf(false) } + + var showMismatchError by remember { mutableStateOf(false) } + var showLengthError by remember { mutableStateOf(false) } + var showMaxLengthError by remember { mutableStateOf(false) } + var showInvalidOldPasswordError by remember { mutableStateOf(false) } + + val minLength = 8 + val maxLength = 64 + val context = LocalContext.current + val activity = LocalActivity.current as? ComponentActivity + val appLockRepository = remember { + (context.applicationContext as? AppLockApplication)?.appLockRepository + } + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + BackHandler { + if (isFirstTimeSetup) { + if (isConfirmationMode) { + isConfirmationMode = false + } else { + Toast.makeText(context, R.string.set_pin_to_continue_toast, Toast.LENGTH_SHORT).show() + } + } else { + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + activity?.finish() + } + } + } + + val fragmentActivity = LocalActivity.current as? androidx.fragment.app.FragmentActivity + + fun launchDeviceCredentialAuth() { + if (fragmentActivity == null) return + val executor = ContextCompat.getMainExecutor(context) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.authenticate_to_reset_pin_title)) + .setSubtitle(context.getString(R.string.use_device_pin_pattern_password_subtitle)) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() + val biometricPrompt = BiometricPrompt( + fragmentActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + isVerifyOldPasswordMode = false + passwordState = "" + confirmPasswordState = "" + showInvalidOldPasswordError = false + } + }) + biometricPrompt.authenticate(promptInfo) + } + + fun switchToPinMethod() { + navController.navigate(Screen.SetPassword.route) { + popUpTo(Screen.SetPasswordAlphanumeric.route) { inclusive = true } + } + } + + fun submitPassword() { + val currentInput = if (isConfirmationMode) confirmPasswordState else passwordState + + if (currentInput.length < minLength) { + showLengthError = true + return + } + + if (currentInput.length > maxLength) { + showMaxLengthError = true + return + } + + when { + isVerifyOldPasswordMode -> { + if (appLockRepository!!.validatePassword(passwordState)) { + isVerifyOldPasswordMode = false + passwordState = "" + showInvalidOldPasswordError = false + } else { + showInvalidOldPasswordError = true + passwordState = "" + } + } + + !isConfirmationMode -> { + isConfirmationMode = true + showLengthError = false + showMaxLengthError = false + } + + else -> { + if (passwordState == confirmPasswordState) { + appLockRepository?.setLockType(PreferencesRepository.LOCK_TYPE_PASSWORD) + appLockRepository?.setPassword(passwordState) + Toast.makeText( + context, + context.getString(R.string.password_set_successfully_toast), + Toast.LENGTH_SHORT + ).show() + + navController.navigate(Screen.Main.route) { + popUpTo(Screen.SetPassword.route) { inclusive = true } + if (isFirstTimeSetup) { + popUpTo(Screen.AppIntro.route) { inclusive = true } + } + } + } else { + showMismatchError = true + confirmPasswordState = "" + } + } + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + topBar = { + TopAppBar( + title = { + Text( + text = when { + isVerifyOldPasswordMode -> stringResource(R.string.enter_current_password_label) + isConfirmationMode -> stringResource(R.string.confirm_alphanumeric_password_label) + else -> stringResource(R.string.set_alphanumeric_password_title) + }, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Text( + text = when { + isVerifyOldPasswordMode -> stringResource(R.string.enter_current_password_label) + isConfirmationMode -> stringResource(R.string.confirm_alphanumeric_password_label) + else -> stringResource(R.string.create_alphanumeric_password_label) + }, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + OutlinedTextField( + value = if (isConfirmationMode) confirmPasswordState else passwordState, + onValueChange = { input -> + val sanitized = SecurityUtils.sanitizePassword(input) + if (isConfirmationMode) confirmPasswordState = sanitized else passwordState = sanitized + showMismatchError = false + showLengthError = false + showMaxLengthError = false + showInvalidOldPasswordError = false + }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + label = { Text(stringResource(R.string.password_hint)) }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + trailingIcon = { + val image = if (passwordVisible) + Icons.Filled.Visibility + else Icons.Filled.VisibilityOff + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, contentDescription = null) + } + }, + isError = showMismatchError || showLengthError || showMaxLengthError || showInvalidOldPasswordError, + singleLine = true + ) + + if (showMismatchError) { + Text( + text = stringResource(R.string.passwords_dont_match_error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + if (showLengthError) { + Text( + text = stringResource(R.string.password_too_short_error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + if (showMaxLengthError) { + Text( + text = stringResource(R.string.password_too_long_error), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + if (showInvalidOldPasswordError) { + Text( + text = stringResource(R.string.incorrect_password_try_again), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { submitPassword() }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium + ) { + Text(stringResource(R.string.next_button)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + if (!isVerifyOldPasswordMode && !isConfirmationMode) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 16.dp) + ) { + TextButton(onClick = { switchToPinMethod() }) { + Text(stringResource(R.string.use_pin_instead)) + } + TextButton(onClick = { navController.navigate(Screen.SetPasswordPattern.route) }) { + Text(stringResource(R.string.use_pattern_button)) + } + } + + } + + if (isVerifyOldPasswordMode) { + TextButton(onClick = { launchDeviceCredentialAuth() }) { + Text(stringResource(R.string.reset_using_device_password_button)) + } + } + + if (isVerifyOldPasswordMode || isConfirmationMode) { + TextButton( + onClick = { + if (isVerifyOldPasswordMode) { + if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + activity?.finish() + } + } else { + isConfirmationMode = false + if (!isFirstTimeSetup) { + isVerifyOldPasswordMode = true + } + } + passwordState = "" + confirmPasswordState = "" + showMismatchError = false + showLengthError = false + showMaxLengthError = false + showInvalidOldPasswordError = false + } + ) { + Text( + if (isVerifyOldPasswordMode) stringResource(R.string.cancel_button) + else stringResource(R.string.start_over_button) + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/PatternSetPasswordScreen.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/PatternSetPasswordScreen.kt index c14d1a9..9153a3e 100644 --- a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/PatternSetPasswordScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/PatternSetPasswordScreen.kt @@ -267,8 +267,13 @@ fun PatternSetPasswordScreen( } if (isFirstTimeSetup && !isVerifyOldPasswordMode && !isConfirmationMode) { - TextButton(onClick = { switchToPinMethod() }) { - Text("Use PIN Instead") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { switchToPinMethod() }) { + Text(stringResource(R.string.use_pin_instead)) + } + TextButton(onClick = { navController.navigate(Screen.SetPasswordAlphanumeric.route) }) { + Text(stringResource(R.string.use_password_button)) + } } } @@ -448,8 +453,13 @@ fun PatternSetPasswordScreen( Spacer(modifier = Modifier.height(16.dp)) if (!isVerifyOldPasswordMode && !isConfirmationMode) { - TextButton(onClick = { switchToPinMethod() }) { - Text("Use PIN Instead") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { switchToPinMethod() }) { + Text(stringResource(R.string.use_pin_instead)) + } + TextButton(onClick = { navController.navigate(Screen.SetPasswordAlphanumeric.route) }) { + Text(stringResource(R.string.use_password_button)) + } } } diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordScreen.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordScreen.kt index 85fb9c5..19b9fe0 100644 --- a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordScreen.kt @@ -527,15 +527,29 @@ fun SetPasswordScreen( } } - TextButton( - onClick = { - navController.navigate(Screen.SetPasswordPattern.route) - }, + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 16.dp) ) { - Text( - stringResource(R.string.use_pattern_button) - ) + TextButton( + onClick = { + navController.navigate(Screen.SetPasswordPattern.route) + } + ) { + Text( + stringResource(R.string.use_pattern_button) + ) + } + + TextButton( + onClick = { + navController.navigate(Screen.SetPasswordAlphanumeric.route) + } + ) { + Text( + stringResource(R.string.use_password_button) + ) + } } Column( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87a4760..1873486 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Enter your password to disable admin permission. Incorrect PIN. Please try again. + Incorrect password. Please try again. Password verified, you can now disable admin permission Choose App Detection Method Select how you want AppLock to detect when protected apps are launched. @@ -90,6 +91,19 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Start Over Password set successfully Use Pattern + Use Password + Use PIN + + + Set Password + Create a password + Confirm your password + Enter current password + Enter password + Password must be at least 4 characters + Password must be at most 64 characters + Passwords don\'t match + Incorrect password. Please try again. Anti-uninstall is enabled