diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 15e36ed..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ddf443b..0bb98b3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,8 +18,8 @@ android { minSdk = 26 targetSdk = 36 // targetSdkPreview = "CANARY" - versionCode = 223 - versionName = "2.2.3" + versionCode = 22304 + versionName = "3.1.0.00(18-03-26)" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a00795..8567ce4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,10 @@ + + + + - + + android:label="@string/app_name" + android:theme="@style/Theme.AppLock" + tools:ignore="RedundantLabel"> @@ -58,13 +65,17 @@ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" android:excludeFromRecents="true" android:exported="false" + android:label="@string/app_name" android:taskAffinity="" - android:theme="@android:style/Theme.Material.NoActionBar.TranslucentDecor" /> + android:theme="@android:style/Theme.Material.NoActionBar.TranslucentDecor" + tools:ignore="RedundantLabel" /> + android:label="@string/app_name" + android:theme="@style/Theme.AppLock" + tools:ignore="RedundantLabel" /> @@ -104,6 +115,7 @@ android:name=".core.broadcast.DeviceAdmin" android:description="@string/device_admin_description" android:exported="false" + android:label="@string/app_name" android:permission="android.permission.BIND_DEVICE_ADMIN"> diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index bd75f7e..edd9727 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/dev/pranav/applock/MainActivity.kt b/app/src/main/java/dev/pranav/applock/MainActivity.kt index f85cd6f..66a5414 100644 --- a/app/src/main/java/dev/pranav/applock/MainActivity.kt +++ b/app/src/main/java/dev/pranav/applock/MainActivity.kt @@ -3,7 +3,14 @@ package dev.pranav.applock import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.Text +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.fragment.app.FragmentActivity +import dev.pranav.applock.core.utils.BiometricStatus +import dev.pranav.applock.core.utils.getBiometricStatus import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.navigation.compose.rememberNavController @@ -22,18 +29,25 @@ class MainActivity : FragmentActivity() { navigationManager = NavigationManager(this) + val biometricStatus = getBiometricStatus(this) setContent { AppLockTheme { - val navController = rememberNavController() - val startDestination = navigationManager.determineStartDestination() + if (biometricStatus is BiometricStatus.Unavailable) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(biometricStatus.message) + } + } else { + val navController = rememberNavController() + val startDestination = navigationManager.determineStartDestination() - AppNavHost( - navController = navController, - startDestination = startDestination - ) + AppNavHost( + navController = navController, + startDestination = startDestination + ) - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - handleOnResume(navController) + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + handleOnResume(navController) + } } } } @@ -46,7 +60,7 @@ class MainActivity : FragmentActivity() { return } - if (currentRoute != Screen.PasswordOverlay.route && currentRoute != Screen.SetPassword.route && currentRoute != Screen.SetPasswordPattern.route) { + if (currentRoute != Screen.PasswordOverlay.route && currentRoute != Screen.SetPassword.route && currentRoute != Screen.SetPasswordPattern.route && currentRoute != Screen.SetPasswordText.route) { navController.navigate(Screen.PasswordOverlay.route) } } diff --git a/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceAdmin.kt b/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceAdmin.kt index dca2388..1215203 100644 --- a/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceAdmin.kt +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceAdmin.kt @@ -17,10 +17,6 @@ class DeviceAdmin : DeviceAdminReceiver() { context.getSharedPreferences("app_lock_settings", Context.MODE_PRIVATE).edit { putBoolean("anti_uninstall", true) } - - val component = ComponentName(context, DeviceAdmin::class.java) - - getManager(context).setUninstallBlocked(component, context.packageName, true) } override fun onDisabled(context: Context, intent: android.content.Intent) { 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..31c4602 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 @@ -15,21 +15,25 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import dev.pranav.applock.AppLockApplication +import dev.pranav.applock.core.utils.IntruderSelfieManager import dev.pranav.applock.core.utils.LogUtils 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.intruderselfie.ui.IntruderSelfieScreen import dev.pranav.applock.features.lockscreen.ui.PasswordOverlayScreen import dev.pranav.applock.features.lockscreen.ui.PatternLockScreen import dev.pranav.applock.features.setpassword.ui.PatternSetPasswordScreen import dev.pranav.applock.features.setpassword.ui.SetPasswordScreen +import dev.pranav.applock.features.setpassword.ui.SetPasswordTextScreen import dev.pranav.applock.features.settings.ui.SettingsScreen import dev.pranav.applock.features.triggerexclusions.ui.TriggerExclusionsScreen @Composable fun AppNavHost(navController: NavHostController, startDestination: String) { val application = LocalContext.current.applicationContext as AppLockApplication + val context = LocalContext.current NavHost( navController = navController, @@ -52,23 +56,39 @@ 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 -> SetPasswordTextScreen(navController, false) + else -> SetPasswordScreen(navController, isFirstTimeSetup = false) } } + composable(Screen.ChangePasswordPin.route) { + SetPasswordScreen(navController, isFirstTimeSetup = false) + } + + composable(Screen.ChangePasswordPattern.route) { + PatternSetPasswordScreen(navController, false) + } + + composable(Screen.ChangePasswordText.route) { + SetPasswordTextScreen(navController, false) + } + composable(Screen.SetPasswordPattern.route) { PatternSetPasswordScreen(navController, isFirstTimeSetup = true) } + composable(Screen.SetPasswordText.route) { + SetPasswordTextScreen(navController, isFirstTimeSetup = true) + } + composable(Screen.Main.route) { MainScreen(navController) } composable(Screen.PasswordOverlay.route) { - val context = LocalActivity.current as FragmentActivity + val activity = LocalActivity.current as FragmentActivity val lockType = application.appLockRepository.getLockType() when (lockType) { @@ -78,12 +98,47 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { onPatternAttempt = { pattern -> val isValid = application.appLockRepository.validatePattern(pattern) if (isValid) { + IntruderSelfieManager.resetFailedAttempts() handleAuthenticationSuccess(navController) + } else { + IntruderSelfieManager.recordFailedAttempt(context) } isValid }, onBiometricAuth = { - handleBiometricAuthentication(context, navController) + handleBiometricAuthentication(activity, navController) + }, + onForgotPasscodeReset = { + navController.navigate(Screen.ChangePasswordPattern.route) + } + ) + } + + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + PasswordOverlayScreen( + showBiometricButton = application.appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = true, + useTextPassword = true, + onBiometricAuth = { + handleBiometricAuthentication(activity, navController) + }, + onAuthSuccess = { + IntruderSelfieManager.resetFailedAttempts() + handleAuthenticationSuccess(navController) + }, + onForgotPasscodeReset = { + navController.navigate(Screen.ChangePasswordText.route) + }, + onPinAttempt = { pin, isFinal -> + val correctPassword = application.appLockRepository.getPassword() ?: "" + val isValid = pin == correctPassword + if (isValid) { + IntruderSelfieManager.resetFailedAttempts() + handleAuthenticationSuccess(navController) + } else if (isFinal) { + IntruderSelfieManager.recordFailedAttempt(context) + } + isValid } ) } @@ -93,10 +148,30 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { showBiometricButton = application.appLockRepository.isBiometricAuthEnabled(), fromMainActivity = true, onBiometricAuth = { - handleBiometricAuthentication(context, navController) + handleBiometricAuthentication(activity, navController) }, onAuthSuccess = { + IntruderSelfieManager.resetFailedAttempts() handleAuthenticationSuccess(navController) + }, + onForgotPasscodeReset = { + navController.navigate(Screen.ChangePasswordPin.route) + }, + onPinAttempt = { pin, isFinal -> + val correctPassword = application.appLockRepository.getPassword() ?: "" + val isValid = pin == correctPassword + if (isValid) { + IntruderSelfieManager.resetFailedAttempts() + handleAuthenticationSuccess(navController) + } else { + // Proper failed attempt count for PIN: + // 1. If it's a "Proceed" button click (isFinal = true), always record. + // 2. If it's an auto-unlock check, only record if length matches. + if (isFinal || (pin.length >= correctPassword.length && correctPassword.isNotEmpty())) { + IntruderSelfieManager.recordFailedAttempt(context) + } + } + isValid } ) } @@ -114,6 +189,10 @@ fun AppNavHost(navController: NavHostController, startDestination: String) { composable(Screen.AntiUninstall.route) { AntiUninstallScreen(navController) } + + composable(Screen.IntruderSelfies.route) { + IntruderSelfieScreen(navController) + } } } @@ -135,6 +214,7 @@ private fun handleBiometricAuthentication( override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) LogUtils.d(TAG, "Biometric authentication succeeded") + IntruderSelfieManager.resetFailedAttempts() navigateToMain(navController) } @@ -182,6 +262,6 @@ private fun navigateToMain(navController: NavHostController) { private const val TAG = "AppNavHost" private const val ANIMATION_DURATION = 400 private const val SCALE_INITIAL = 0.9f -private const val BIOMETRIC_TITLE = "Confirm password" +private const val BIOMETRIC_TITLE = "APP Lock by AP" private const val BIOMETRIC_SUBTITLE = "Confirm biometric to continue" private const val BIOMETRIC_NEGATIVE_BUTTON = "Use PIN" 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..980f9c1 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,10 +4,15 @@ sealed class Screen(val route: String) { object AppIntro : Screen("app_intro") object SetPassword : Screen("set_password") object SetPasswordPattern : Screen("set_password_pattern") + object SetPasswordText : Screen("set_password_text") object ChangePassword : Screen("change_password") + object ChangePasswordPin : Screen("change_password_pin") + object ChangePasswordPattern : Screen("change_password_pattern") + object ChangePasswordText : Screen("change_password_text") object Main : Screen("main") object PasswordOverlay : Screen("password_overlay") object Settings : Screen("settings") object TriggerExclusions : Screen("trigger_exclusions") object AntiUninstall: Screen("anti_uninstall") + object IntruderSelfies: Screen("intruder_selfies") } diff --git a/app/src/main/java/dev/pranav/applock/core/utils/BiometricUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/BiometricUtils.kt new file mode 100644 index 0000000..6e1554e --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/utils/BiometricUtils.kt @@ -0,0 +1,27 @@ +package dev.pranav.applock.core.utils + +import android.content.Context +import androidx.biometric.BiometricManager + +sealed class BiometricStatus { + data object Available : BiometricStatus() + data class Unavailable(val message: String) : BiometricStatus() +} + +fun getBiometricStatus(context: Context): BiometricStatus { + val biometricManager = BiometricManager.from(context) + return when ( + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + ) { + BiometricManager.BIOMETRIC_SUCCESS -> BiometricStatus.Available + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricStatus.Unavailable( + "Sorry this app cant run on this device because of security limitation: no biometric hardware." + ) + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricStatus.Unavailable( + "Sorry this app cant run on this device because of security limitation: biometric not enabled." + ) + else -> BiometricStatus.Unavailable( + "Sorry this app cant run on this device because of security limitation: biometric authentication unavailable." + ) + } +} diff --git a/app/src/main/java/dev/pranav/applock/core/utils/IntruderSelfieManager.kt b/app/src/main/java/dev/pranav/applock/core/utils/IntruderSelfieManager.kt new file mode 100644 index 0000000..cfbd522 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/utils/IntruderSelfieManager.kt @@ -0,0 +1,113 @@ +package dev.pranav.applock.core.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.ImageFormat +import android.hardware.camera2.* +import android.media.ImageReader +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.* + +object IntruderSelfieManager { + private const val TAG = "IntruderSelfieManager" + private var failedAttempts = 0 + + fun recordFailedAttempt(context: Context) { + val repository = context.appLockRepository() + if (!repository.isIntruderSelfieEnabled()) return + + failedAttempts++ + val requiredAttempts = repository.getIntruderSelfieAttempts() + + if (failedAttempts >= requiredAttempts) { + captureSelfie(context) + failedAttempts = 0 // Reset after capture + } + } + + fun resetFailedAttempts() { + failedAttempts = 0 + } + + @SuppressLint("MissingPermission") + private fun captureSelfie(context: Context) { + val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + try { + val cameraId = cameraManager.cameraIdList.find { id -> + cameraManager.getCameraCharacteristics(id) + .get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT + } ?: return + + val handlerThread = HandlerThread("CameraBackground").apply { start() } + val handler = Handler(handlerThread.looper) + + val imageReader = ImageReader.newInstance(640, 480, ImageFormat.JPEG, 1) + imageReader.setOnImageAvailableListener({ reader -> + val image = reader.acquireLatestImage() + val buffer = image.planes[0].buffer + val bytes = ByteArray(buffer.remaining()) + buffer.get(bytes) + saveSelfie(context, bytes) + image.close() + // Done capturing, we can stop the thread later or here + }, handler) + + cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + val captureBuilder = camera.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE) + captureBuilder.addTarget(imageReader.surface) + + // On some devices, we might need a preview surface too, but we try without it first for "silent" capture + camera.createCaptureSession(listOf(imageReader.surface), object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + session.capture(captureBuilder.build(), object : CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) { + camera.close() + handlerThread.quitSafely() + } + }, handler) + } + override fun onConfigureFailed(session: CameraCaptureSession) { + camera.close() + handlerThread.quitSafely() + } + }, handler) + } + + override fun onDisconnected(camera: CameraDevice) { + camera.close() + handlerThread.quitSafely() + } + + override fun onError(camera: CameraDevice, error: Int) { + camera.close() + handlerThread.quitSafely() + } + }, handler) + + } catch (e: Exception) { + Log.e(TAG, "Error capturing selfie", e) + } + } + + private fun saveSelfie(context: Context, data: ByteArray) { + try { + val selfieDir = File(context.filesDir, "intruder_selfies") + if (!selfieDir.exists()) selfieDir.mkdirs() + + val timeStamp = SimpleDateFormat("HHmmss_ddMMyyyy", Locale.getDefault()).format(Date()) + val fileName = "Intruder_$timeStamp.jpg" + val file = File(selfieDir, fileName) + + FileOutputStream(file).use { it.write(data) } + Log.d(TAG, "Selfie saved: ${file.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Error saving selfie", e) + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/core/utils/LogUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/LogUtils.kt index 8f0afe5..cac2995 100644 --- a/app/src/main/java/dev/pranav/applock/core/utils/LogUtils.kt +++ b/app/src/main/java/dev/pranav/applock/core/utils/LogUtils.kt @@ -1,14 +1,20 @@ package dev.pranav.applock.core.utils import android.annotation.SuppressLint +import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build +import android.os.Environment +import android.provider.MediaStore import android.util.Log import androidx.core.content.FileProvider import java.io.File +import java.text.SimpleDateFormat import java.time.Instant import java.time.temporal.ChronoUnit +import java.util.Date +import java.util.Locale import kotlin.concurrent.thread @SuppressLint("StaticFieldLeak") @@ -88,6 +94,55 @@ object LogUtils { } } + /** + * Export logs to the default Downloads directory in a subfolder with the app name. + * The filename format is "Logs+Time of export(hhmmss)+app name". + */ + fun exportLogsToDownloads(appName: String): String? { + val timeStamp = SimpleDateFormat("HHmmss", Locale.getDefault()).format(Date()) + val fileName = "Logs+${timeStamp}+$appName.txt" + + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) + put(MediaStore.MediaColumns.MIME_TYPE, "text/plain") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/" + appName) + } + + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + uri?.let { + resolver.openOutputStream(it)?.use { outputStream -> + val process = Runtime.getRuntime().exec("logcat -d") + process.inputStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + "${Environment.DIRECTORY_DOWNLOADS}/$appName/$fileName" + } + } else { + // Fallback for older Android versions + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val appDir = File(downloadsDir, appName) + if (!appDir.exists()) { + appDir.mkdirs() + } + val targetFile = File(appDir, fileName) + targetFile.outputStream().use { outputStream -> + val process = Runtime.getRuntime().exec("logcat -d") + process.inputStream.use { inputStream -> + inputStream.copyTo(outputStream) + } + } + "${Environment.DIRECTORY_DOWNLOADS}/$appName/$fileName" + } + } catch (e: Exception) { + Log.e(TAG, "Error exporting logs to downloads", e) + null + } + } + /** * Clear all security and audit logs. * Called when the app is updated. diff --git a/app/src/main/java/dev/pranav/applock/core/utils/RecoveryKeyManager.kt b/app/src/main/java/dev/pranav/applock/core/utils/RecoveryKeyManager.kt new file mode 100644 index 0000000..2d0958c --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/utils/RecoveryKeyManager.kt @@ -0,0 +1,44 @@ +package dev.pranav.applock.core.utils + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import java.io.File +import java.security.SecureRandom + +object RecoveryKeyManager { + private const val APP_FOLDER = "APP Lock by AP" + private const val FILE_PREFIX = "recovery-key-" + private const val CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private val random = SecureRandom() + + fun generateRecoveryKey(length: Int = 64): String = buildString(length) { + repeat(length) { + append(CHARSET[random.nextInt(CHARSET.length)]) + } + } + + fun saveRecoveryKeyToDownloads(context: Context, recoveryKey: String): Result = runCatching { + val fileName = "$FILE_PREFIX${System.currentTimeMillis()}.txt" + val body = "Recovery key for APP Lock by AP\n\n$recoveryKey\n" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "text/plain") + put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$APP_FOLDER") + } + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: error("Could not create download file") + resolver.openOutputStream(uri)?.bufferedWriter()?.use { it.write(body) } + ?: error("Could not open download output stream") + } else { + val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val dir = File(downloads, APP_FOLDER).apply { mkdirs() } + File(dir, fileName).writeText(body) + } + "Downloads/$APP_FOLDER/$fileName" + } +} diff --git a/app/src/main/java/dev/pranav/applock/data/repository/AppLockRepository.kt b/app/src/main/java/dev/pranav/applock/data/repository/AppLockRepository.kt index 91e35fe..a8698a1 100644 --- a/app/src/main/java/dev/pranav/applock/data/repository/AppLockRepository.kt +++ b/app/src/main/java/dev/pranav/applock/data/repository/AppLockRepository.kt @@ -2,6 +2,7 @@ package dev.pranav.applock.data.repository import android.content.Context import dev.pranav.applock.data.manager.BackendServiceManager +import kotlinx.coroutines.flow.Flow /** * Main repository that coordinates between different specialized repositories and managers. @@ -50,6 +51,11 @@ class AppLockRepository(private val context: Context) { fun validatePattern(inputPattern: String): Boolean = preferencesRepository.validatePattern(inputPattern) + fun setRecoveryKey(recoveryKey: String) = preferencesRepository.setRecoveryKey(recoveryKey) + fun getRecoveryKey(): String? = preferencesRepository.getRecoveryKey() + fun validateRecoveryKey(inputRecoveryKey: String): Boolean = + preferencesRepository.validateRecoveryKey(inputRecoveryKey) + fun setLockType(lockType: String) = preferencesRepository.setLockType(lockType) fun getLockType(): String = preferencesRepository.getLockType() @@ -60,6 +66,18 @@ class AppLockRepository(private val context: Context) { fun setUseMaxBrightness(enabled: Boolean) = preferencesRepository.setUseMaxBrightness(enabled) fun shouldUseMaxBrightness(): Boolean = preferencesRepository.shouldUseMaxBrightness() + fun setAmoledModeEnabled(enabled: Boolean) = preferencesRepository.setAmoledModeEnabled(enabled) + fun isAmoledModeEnabled(): Boolean = preferencesRepository.isAmoledModeEnabled() + fun amoledModeFlow(): Flow = preferencesRepository.amoledModeFlow + + fun setDynamicColorEnabled(enabled: Boolean) = preferencesRepository.setDynamicColorEnabled(enabled) + fun isDynamicColorEnabled(): Boolean = preferencesRepository.isDynamicColorEnabled() + fun dynamicColorFlow(): Flow = preferencesRepository.dynamicColorFlow + + fun setAppThemeMode(themeMode: AppThemeMode) = preferencesRepository.setAppThemeMode(themeMode) + fun getAppThemeMode(): AppThemeMode = preferencesRepository.getAppThemeMode() + fun appThemeModeFlow(): Flow = preferencesRepository.appThemeModeFlow + fun setDisableHaptics(enabled: Boolean) = preferencesRepository.setDisableHaptics(enabled) fun shouldDisableHaptics(): Boolean = preferencesRepository.shouldDisableHaptics() fun setShowSystemApps(enabled: Boolean) = preferencesRepository.setShowSystemApps(enabled) @@ -69,6 +87,37 @@ class AppLockRepository(private val context: Context) { preferencesRepository.setAntiUninstallEnabled(enabled) fun isAntiUninstallEnabled(): Boolean = preferencesRepository.isAntiUninstallEnabled() + + fun setAntiUninstallAdminSettingsEnabled(enabled: Boolean) = + preferencesRepository.setAntiUninstallAdminSettingsEnabled(enabled) + + fun isAntiUninstallAdminSettingsEnabled(): Boolean = + preferencesRepository.isAntiUninstallAdminSettingsEnabled() + + fun setAntiUninstallUsageStatsEnabled(enabled: Boolean) = + preferencesRepository.setAntiUninstallUsageStatsEnabled(enabled) + + fun isAntiUninstallUsageStatsEnabled(): Boolean = + preferencesRepository.isAntiUninstallUsageStatsEnabled() + + fun setAntiUninstallAccessibilityEnabled(enabled: Boolean) = + preferencesRepository.setAntiUninstallAccessibilityEnabled(enabled) + + fun isAntiUninstallAccessibilityEnabled(): Boolean = + preferencesRepository.isAntiUninstallAccessibilityEnabled() + + fun setAntiUninstallOverlayEnabled(enabled: Boolean) = + preferencesRepository.setAntiUninstallOverlayEnabled(enabled) + + fun isAntiUninstallOverlayEnabled(): Boolean = + preferencesRepository.isAntiUninstallOverlayEnabled() + + fun setPreventAllAppUninstallEnabled(enabled: Boolean) = + preferencesRepository.setPreventAllAppUninstallEnabled(enabled) + + fun isPreventAllAppUninstallEnabled(): Boolean = + preferencesRepository.isPreventAllAppUninstallEnabled() + fun setProtectEnabled(enabled: Boolean) = preferencesRepository.setProtectEnabled(enabled) fun isProtectEnabled(): Boolean = preferencesRepository.isProtectEnabled() @@ -91,6 +140,11 @@ class AppLockRepository(private val context: Context) { fun isLoggingEnabled(): Boolean = preferencesRepository.isLoggingEnabled() fun setLoggingEnabled(enabled: Boolean) = preferencesRepository.setLoggingEnabled(enabled) + fun setIntruderSelfieEnabled(enabled: Boolean) = preferencesRepository.setIntruderSelfieEnabled(enabled) + fun isIntruderSelfieEnabled(): Boolean = preferencesRepository.isIntruderSelfieEnabled() + fun setIntruderSelfieAttempts(attempts: Int) = preferencesRepository.setIntruderSelfieAttempts(attempts) + fun getIntruderSelfieAttempts(): Int = preferencesRepository.getIntruderSelfieAttempts() + fun setActiveBackend(backend: BackendImplementation) = backendServiceManager.setActiveBackend(backend) 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..99a6189 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 @@ -3,6 +3,9 @@ package dev.pranav.applock.data.repository import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow /** * Repository for managing application preferences and settings. @@ -17,7 +20,7 @@ class PreferencesRepository(context: Context) { context.getSharedPreferences(PREFS_NAME_SETTINGS, Context.MODE_PRIVATE) fun setPassword(password: String) { - appLockPrefs.edit { putString(KEY_PASSWORD, password) } + appLockPrefs.edit(commit = true) { putString(KEY_PASSWORD, password) } } fun getPassword(): String? { @@ -30,7 +33,20 @@ class PreferencesRepository(context: Context) { } fun setPattern(pattern: String) { - appLockPrefs.edit { putString(KEY_PATTERN, pattern) } + appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, pattern) } + } + + fun setRecoveryKey(recoveryKey: String) { + appLockPrefs.edit(commit = true) { putString(KEY_RECOVERY_KEY, recoveryKey) } + } + + fun getRecoveryKey(): String? { + return appLockPrefs.getString(KEY_RECOVERY_KEY, null) + } + + fun validateRecoveryKey(inputRecoveryKey: String): Boolean { + val storedRecoveryKey = getRecoveryKey() + return storedRecoveryKey != null && inputRecoveryKey == storedRecoveryKey } fun getPattern(): String? { @@ -43,7 +59,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 { @@ -66,6 +82,44 @@ class PreferencesRepository(context: Context) { return settingsPrefs.getBoolean(KEY_USE_MAX_BRIGHTNESS, false) } + fun setAmoledModeEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_AMOLED_MODE_ENABLED, enabled) } + _amoledModeFlow.value = enabled + } + + fun isAmoledModeEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_AMOLED_MODE_ENABLED, false) + } + + val amoledModeFlow: Flow = _amoledModeFlow.asStateFlow() + + fun setDynamicColorEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_DYNAMIC_COLOR, enabled) } + _dynamicColorFlow.value = enabled + } + + fun isDynamicColorEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_DYNAMIC_COLOR, false) + } + + val dynamicColorFlow: Flow = _dynamicColorFlow.asStateFlow() + + fun setAppThemeMode(themeMode: AppThemeMode) { + settingsPrefs.edit { putString(KEY_APP_THEME_MODE, themeMode.name) } + _appThemeModeFlow.value = themeMode + } + + fun getAppThemeMode(): AppThemeMode { + val mode = settingsPrefs.getString(KEY_APP_THEME_MODE, AppThemeMode.SYSTEM.name) + return try { + AppThemeMode.valueOf(mode ?: AppThemeMode.SYSTEM.name) + } catch (_: IllegalArgumentException) { + AppThemeMode.SYSTEM + } + } + + val appThemeModeFlow: Flow = _appThemeModeFlow.asStateFlow() + fun setDisableHaptics(enabled: Boolean) { settingsPrefs.edit { putBoolean(KEY_DISABLE_HAPTICS, enabled) } } @@ -83,15 +137,55 @@ class PreferencesRepository(context: Context) { } fun setAntiUninstallEnabled(enabled: Boolean) { - settingsPrefs.edit { putBoolean(KEY_ANTI_UNINSTALL, enabled) } + settingsPrefs.edit(commit = true) { putBoolean(KEY_ANTI_UNINSTALL, enabled) } } fun isAntiUninstallEnabled(): Boolean { return settingsPrefs.getBoolean(KEY_ANTI_UNINSTALL, false) } + fun setAntiUninstallAdminSettingsEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_ANTI_UNINSTALL_ADMIN_SETTINGS, enabled) } + } + + fun isAntiUninstallAdminSettingsEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_ANTI_UNINSTALL_ADMIN_SETTINGS, false) + } + + fun setAntiUninstallUsageStatsEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_ANTI_UNINSTALL_USAGE_STATS, enabled) } + } + + fun isAntiUninstallUsageStatsEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_ANTI_UNINSTALL_USAGE_STATS, false) + } + + fun setAntiUninstallAccessibilityEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_ANTI_UNINSTALL_ACCESSIBILITY, enabled) } + } + + fun isAntiUninstallAccessibilityEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_ANTI_UNINSTALL_ACCESSIBILITY, false) + } + + fun setAntiUninstallOverlayEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_ANTI_UNINSTALL_OVERLAY, enabled) } + } + + fun isAntiUninstallOverlayEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_ANTI_UNINSTALL_OVERLAY, false) + } + + fun setPreventAllAppUninstallEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_PREVENT_ALL_APP_UNINSTALL, enabled) } + } + + fun isPreventAllAppUninstallEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_PREVENT_ALL_APP_UNINSTALL, false) + } + fun setProtectEnabled(enabled: Boolean) { - settingsPrefs.edit { putBoolean(KEY_APPLOCK_ENABLED, enabled) } + settingsPrefs.edit(commit = true) { putBoolean(KEY_APPLOCK_ENABLED, enabled) } } fun isProtectEnabled(): Boolean { @@ -115,7 +209,7 @@ class PreferencesRepository(context: Context) { } fun setBackendImplementation(backend: BackendImplementation) { - settingsPrefs.edit { putString(KEY_BACKEND_IMPLEMENTATION, backend.name) } + settingsPrefs.edit(commit = true) { putString(KEY_BACKEND_IMPLEMENTATION, backend.name) } } fun getBackendImplementation(): BackendImplementation { @@ -154,16 +248,41 @@ class PreferencesRepository(context: Context) { settingsPrefs.edit { putBoolean(KEY_LOGGING_ENABLED, enabled) } } + fun setIntruderSelfieEnabled(enabled: Boolean) { + settingsPrefs.edit { putBoolean(KEY_INTRUDER_SELFIE_ENABLED, enabled) } + } + + fun isIntruderSelfieEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_INTRUDER_SELFIE_ENABLED, false) + } + + fun setIntruderSelfieAttempts(attempts: Int) { + settingsPrefs.edit { putInt(KEY_INTRUDER_SELFIE_ATTEMPTS, attempts) } + } + + fun getIntruderSelfieAttempts(): Int { + return settingsPrefs.getInt(KEY_INTRUDER_SELFIE_ATTEMPTS, 3) + } + companion object { private const val PREFS_NAME_APP_LOCK = "app_lock_prefs" private const val PREFS_NAME_SETTINGS = "app_lock_settings" private const val KEY_PASSWORD = "password" private const val KEY_PATTERN = "pattern" + private const val KEY_RECOVERY_KEY = "recovery_key" private const val KEY_BIOMETRIC_AUTH_ENABLED = "use_biometric_auth" private const val KEY_DISABLE_HAPTICS = "disable_haptics" private const val KEY_USE_MAX_BRIGHTNESS = "use_max_brightness" + private const val KEY_AMOLED_MODE_ENABLED = "amoled_mode_enabled" + private const val KEY_DYNAMIC_COLOR = "dynamic_color" + private const val KEY_APP_THEME_MODE = "app_theme_mode" private const val KEY_ANTI_UNINSTALL = "anti_uninstall" + private const val KEY_ANTI_UNINSTALL_ADMIN_SETTINGS = "anti_uninstall_admin_settings" + private const val KEY_ANTI_UNINSTALL_USAGE_STATS = "anti_uninstall_usage_stats" + private const val KEY_ANTI_UNINSTALL_ACCESSIBILITY = "anti_uninstall_accessibility" + private const val KEY_ANTI_UNINSTALL_OVERLAY = "anti_uninstall_overlay" + private const val KEY_PREVENT_ALL_APP_UNINSTALL = "prevent_all_app_uninstall" private const val KEY_UNLOCK_TIME_DURATION = "unlock_time_duration" private const val KEY_BACKEND_IMPLEMENTATION = "backend_implementation" private const val KEY_COMMUNITY_LINK_SHOWN = "community_link_shown" @@ -174,11 +293,37 @@ class PreferencesRepository(context: Context) { private const val KEY_AUTO_UNLOCK = "auto_unlock" private const val KEY_SHOW_SYSTEM_APPS = "show_system_apps" private const val KEY_LOCK_TYPE = "lock_type" + private const val KEY_INTRUDER_SELFIE_ENABLED = "intruder_selfie_enabled" + private const val KEY_INTRUDER_SELFIE_ATTEMPTS = "intruder_selfie_attempts" private const val DEFAULT_PROTECT_ENABLED = true private const val DEFAULT_UNLOCK_DURATION = 0 const val LOCK_TYPE_PIN = "pin" const val LOCK_TYPE_PATTERN = "pattern" + const val LOCK_TYPE_PASSWORD = "password" + + // Static flows to ensure all repository instances share the same state + private val _amoledModeFlow = MutableStateFlow(false) + private val _dynamicColorFlow = MutableStateFlow(false) + private val _appThemeModeFlow = MutableStateFlow(AppThemeMode.SYSTEM) + + // Initialize static flows with actual values on first creation + private var initialized = false } + + init { + if (!initialized) { + _amoledModeFlow.value = isAmoledModeEnabled() + _dynamicColorFlow.value = isDynamicColorEnabled() + _appThemeModeFlow.value = getAppThemeMode() + initialized = true + } + } +} + +enum class AppThemeMode { + SYSTEM, + LIGHT, + DARK } 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..123f4f4 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 @@ -73,6 +73,8 @@ class AdminDisableActivity : ComponentActivity() { R.string.password_verified_admin, Toast.LENGTH_SHORT ).show() + + devicePolicyManager.removeActiveAdmin(deviceAdminComponentName) appLockRepository.setAntiUninstallEnabled(false) finish() }, @@ -111,6 +113,8 @@ class AdminDisableActivity : ComponentActivity() { R.string.password_verified_admin, Toast.LENGTH_SHORT ).show() + + devicePolicyManager.removeActiveAdmin(deviceAdminComponentName) appLockRepository.setAntiUninstallEnabled(false) finish() }, @@ -196,7 +200,7 @@ fun AdminDisableScreen( fromMainActivity = false, onBiometricAuth = {}, onAuthSuccess = {}, - onPinAttempt = { pin -> + onPinAttempt = { pin, _ -> val isValid = validatePassword(pin) if (isValid) { onPasswordVerified() diff --git a/app/src/main/java/dev/pranav/applock/features/antiuninstall/ui/AntiUninstallScreen.kt b/app/src/main/java/dev/pranav/applock/features/antiuninstall/ui/AntiUninstallScreen.kt index 1c429b4..38b9fe0 100644 --- a/app/src/main/java/dev/pranav/applock/features/antiuninstall/ui/AntiUninstallScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/antiuninstall/ui/AntiUninstallScreen.kt @@ -357,11 +357,40 @@ private fun SearchTopBar( @Composable private fun AppProtectionItem(app: AppInfo, isProtected: Boolean, onToggle: () -> Unit) { + var showConfirmation by remember { mutableStateOf(false) } + + if (showConfirmation) { + AlertDialog( + onDismissRequest = { showConfirmation = false }, + title = { Text("Remove Protection?") }, + text = { Text("Are you sure you want to remove anti-uninstall protection from ${app.name}?") }, + confirmButton = { + TextButton(onClick = { + showConfirmation = false + onToggle() + }) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmation = false }) { + Text("Cancel") + } + } + ) + } + Surface( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .clickable { onToggle() }, + .clickable { + if (isProtected) { + showConfirmation = true + } else { + onToggle() + } + }, color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(12.dp) ) { @@ -393,18 +422,50 @@ private fun AppProtectionItem(app: AppInfo, isProtected: Boolean, onToggle: () - overflow = TextOverflow.Ellipsis ) } - Switch(checked = isProtected, onCheckedChange = { onToggle() }) + Switch( + checked = isProtected, + onCheckedChange = { + if (isProtected) { + showConfirmation = true + } else { + onToggle() + } + } + ) } } } @Composable private fun ManualPackageItem(packageName: String, onToggle: () -> Unit) { + var showConfirmation by remember { mutableStateOf(false) } + + if (showConfirmation) { + AlertDialog( + onDismissRequest = { showConfirmation = false }, + title = { Text("Remove Protection?") }, + text = { Text("Are you sure you want to remove anti-uninstall protection from $packageName?") }, + confirmButton = { + TextButton(onClick = { + showConfirmation = false + onToggle() + }) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmation = false }) { + Text("Cancel") + } + } + ) + } + Surface( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .clickable { onToggle() }, + .clickable { showConfirmation = true }, color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(12.dp) ) { @@ -437,7 +498,7 @@ private fun ManualPackageItem(packageName: String, onToggle: () -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Switch(checked = true, onCheckedChange = { onToggle() }) + Switch(checked = true, onCheckedChange = { showConfirmation = true }) } } } diff --git a/app/src/main/java/dev/pranav/applock/features/appintro/ui/AppIntroScreen.kt b/app/src/main/java/dev/pranav/applock/features/appintro/ui/AppIntroScreen.kt index a0cceae..86fb316 100644 --- a/app/src/main/java/dev/pranav/applock/features/appintro/ui/AppIntroScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/appintro/ui/AppIntroScreen.kt @@ -193,6 +193,7 @@ fun AppIntroScreen(navController: NavController) { NotificationManagerCompat.from(context).areNotificationsEnabled() } accessibilityServiceEnabled = context.isAccessibilityServiceEnabled() + usageStatsPermissionGranted = context.hasUsagePermission() } val onFinishCallback = { @@ -355,55 +356,55 @@ fun AppIntroScreen(navController: NavController) { onNext = { true } ) - val methodSpecificPages = when (selectedMethod) { - AppUsageMethod.ACCESSIBILITY -> listOf( - IntroPage( - title = stringResource(R.string.accessibility_service_title), - description = stringResource(R.string.app_intro_accessibility_desc), - icon = Accessibility, - backgroundColor = Color(0xFFF1550E), - contentColor = Color.White, - onNext = { - accessibilityServiceEnabled = context.isAccessibilityServiceEnabled() - if (!accessibilityServiceEnabled) { - val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - false - } else { - context.appLockRepository() - .setBackendImplementation(BackendImplementation.ACCESSIBILITY) - true - } + val mandatoryPermissionPages = listOf( + IntroPage( + title = stringResource(R.string.accessibility_service_title), + description = stringResource(R.string.app_intro_accessibility_desc), + icon = Accessibility, + backgroundColor = Color(0xFFF1550E), + contentColor = Color.White, + onNext = { + accessibilityServiceEnabled = context.isAccessibilityServiceEnabled() + if (!accessibilityServiceEnabled) { + val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + false + } else { + true } - ) - ) - - AppUsageMethod.USAGE_STATS -> listOf( - IntroPage( - title = stringResource(R.string.app_intro_usage_stats_title), - description = stringResource(R.string.app_intro_usage_stats_desc), - icon = Icons.Default.QueryStats, - backgroundColor = Color(0xFFB453A4), - contentColor = Color.White, - onNext = { - usageStatsPermissionGranted = context.hasUsagePermission() - if (!usageStatsPermissionGranted) { - val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - false - } else { - context.appLockRepository() - .setBackendImplementation(BackendImplementation.USAGE_STATS) - context.startService( - Intent(context, ExperimentalAppLockService::class.java) - ) - true - } + } + ), + IntroPage( + title = stringResource(R.string.app_intro_usage_stats_title), + description = stringResource(R.string.app_intro_usage_stats_desc), + icon = Icons.Default.QueryStats, + backgroundColor = Color(0xFFB453A4), + contentColor = Color.White, + onNext = { + usageStatsPermissionGranted = context.hasUsagePermission() + if (!usageStatsPermissionGranted) { + val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + false + } else { + true } - ) + } ) + ) + + val methodSpecificPages = when (selectedMethod) { + AppUsageMethod.ACCESSIBILITY -> { + context.appLockRepository().setBackendImplementation(BackendImplementation.ACCESSIBILITY) + emptyList() + } + + AppUsageMethod.USAGE_STATS -> { + context.appLockRepository().setBackendImplementation(BackendImplementation.USAGE_STATS) + emptyList() + } AppUsageMethod.SHIZUKU -> listOf( IntroPage( @@ -454,10 +455,12 @@ fun AppIntroScreen(navController: NavController) { notificationPermissionGranted = NotificationManagerCompat.from(context).areNotificationsEnabled() } + accessibilityServiceEnabled = context.isAccessibilityServiceEnabled() + usageStatsPermissionGranted = context.hasUsagePermission() val methodPermissionGranted = when (selectedMethod) { - AppUsageMethod.ACCESSIBILITY -> context.isAccessibilityServiceEnabled() - AppUsageMethod.USAGE_STATS -> context.hasUsagePermission() + AppUsageMethod.ACCESSIBILITY -> accessibilityServiceEnabled + AppUsageMethod.USAGE_STATS -> usageStatsPermissionGranted AppUsageMethod.SHIZUKU -> { if (Shizuku.isPreV11()) { checkSelfPermission( @@ -470,12 +473,11 @@ fun AppIntroScreen(navController: NavController) { } } - // Only require all permissions if accessibility is selected - val allPermissionsGranted = if (selectedMethod == AppUsageMethod.ACCESSIBILITY) { - overlayPermissionGranted && notificationPermissionGranted && methodPermissionGranted - } else { - overlayPermissionGranted && notificationPermissionGranted && methodPermissionGranted - } + val allPermissionsGranted = overlayPermissionGranted && + notificationPermissionGranted && + accessibilityServiceEnabled && + usageStatsPermissionGranted && + methodPermissionGranted if (!allPermissionsGranted) { Toast.makeText( @@ -483,13 +485,18 @@ fun AppIntroScreen(navController: NavController) { context.getString(R.string.all_permissions_required), Toast.LENGTH_SHORT ).show() + } else { + // Ensure correct service is started if not Accessibility + if (selectedMethod == AppUsageMethod.USAGE_STATS) { + context.startService(Intent(context, ExperimentalAppLockService::class.java)) + } } allPermissionsGranted } ) val allPages = - basicPages + methodSelectionPage + methodSpecificPages + finalPage + basicPages + methodSelectionPage + mandatoryPermissionPages + methodSpecificPages + finalPage AppIntro( pages = allPages, diff --git a/app/src/main/java/dev/pranav/applock/features/applist/ui/MainScreen.kt b/app/src/main/java/dev/pranav/applock/features/applist/ui/MainScreen.kt index d92116d..7705b8e 100644 --- a/app/src/main/java/dev/pranav/applock/features/applist/ui/MainScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/applist/ui/MainScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -147,7 +148,7 @@ fun MainScreen( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), - containerColor = MaterialTheme.colorScheme.surfaceContainer, + containerColor = MaterialTheme.colorScheme.background, topBar = { MediumFlexibleTopAppBar( title = { @@ -228,7 +229,7 @@ fun MainScreen( }, scrollBehavior = scrollBehavior, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer + containerColor = MaterialTheme.colorScheme.background ) ) }, @@ -401,8 +402,8 @@ private fun ProtectedAppsDashboard( } else { LazyColumn( modifier = modifier, - contentPadding = PaddingValues(bottom = 88.dp, top = 8.dp), // Extra padding for FAB - verticalArrangement = Arrangement.spacedBy(4.dp) + contentPadding = PaddingValues(bottom = 88.dp, top = 8.dp, start = 16.dp, end = 16.dp), // Extra padding for FAB and sides + verticalArrangement = Arrangement.spacedBy(12.dp) ) { items(lockedApps, key = { it.packageName }) { appInfo -> ProtectedAppItem( @@ -438,7 +439,7 @@ private fun EmptyDashboardState(modifier: Modifier = Modifier) { text = "Tap the + button to selectively secure your apps.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 32.dp) ) } @@ -568,6 +569,7 @@ private fun ProtectedAppItem( var appName by remember(appInfo) { mutableStateOf(null) } var icon by remember(appInfo) { mutableStateOf(null) } + var showConfirmation by remember { mutableStateOf(false) } LaunchedEffect(appInfo) { withContext(Dispatchers.IO) { @@ -576,64 +578,89 @@ private fun ProtectedAppItem( } } - ListItem( - headlineContent = { - if (appName != null) { + if (showConfirmation) { + AlertDialog( + onDismissRequest = { showConfirmation = false }, + title = { Text("Remove Protection?") }, + text = { Text("Are you sure you want to remove protection from ${appName ?: "this app"}?") }, + confirmButton = { + TextButton(onClick = { + showConfirmation = false + onUnlock() + }) { + Text("Remove", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showConfirmation = false }) { + Text("Cancel") + } + } + ) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp) + ) { + ListItem( + headlineContent = { + if (appName != null) { + Text( + text = appName!!, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + supportingContent = { Text( - text = appName!!, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + text = "Protected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, maxLines = 1, overflow = TextOverflow.Ellipsis ) - } - }, - supportingContent = { - Text( - text = "Protected", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - leadingContent = { - Surface( - modifier = Modifier.size(48.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + }, + leadingContent = { + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { - if (icon != null) { - Image( - bitmap = icon!!, - contentDescription = appName, - modifier = Modifier.size(32.dp) - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (icon != null) { + Image( + bitmap = icon!!, + contentDescription = appName, + modifier = Modifier.size(32.dp) + ) + } } } - } - }, - trailingContent = { - IconButton(onClick = onUnlock) { - Icon( - imageVector = Icons.Outlined.LockOpen, - contentDescription = "Unlock ${appName ?: "app"}", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - colors = ListItemDefaults.colors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clip(RoundedCornerShape(16.dp)) - ) + }, + trailingContent = { + IconButton(onClick = { showConfirmation = true }) { + Icon( + imageVector = Icons.Outlined.LockOpen, + contentDescription = "Unlock ${appName ?: "app"}", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + colors = ListItemDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent + ) + ) + } } @Composable @@ -654,64 +681,72 @@ private fun SelectableAppItem( } } - ListItem( - headlineContent = { - if (appName != null) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp) + ) { + ListItem( + headlineContent = { + if (appName != null) { + Text( + text = appName!!, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + supportingContent = { Text( - text = appName!!, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, + text = appInfo.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis ) - } - }, - supportingContent = { - Text( - text = appInfo.packageName, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - leadingContent = { - Surface( - modifier = Modifier.size(42.dp), - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + }, + leadingContent = { + Surface( + modifier = Modifier.size(42.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh ) { - if (icon != null) { - Image( - bitmap = icon!!, - contentDescription = appName, - modifier = Modifier.size(28.dp) - ) - } else { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (icon != null) { + Image( + bitmap = icon!!, + contentDescription = appName, + modifier = Modifier.size(28.dp) + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } } } - } - }, - trailingContent = { - Checkbox( - checked = isSelected, - onCheckedChange = null + }, + trailingContent = { + Checkbox( + checked = isSelected, + onCheckedChange = null + ) + }, + colors = ListItemDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - ) + ) + } } @Composable diff --git a/app/src/main/java/dev/pranav/applock/features/intruderselfie/ui/IntruderSelfieScreen.kt b/app/src/main/java/dev/pranav/applock/features/intruderselfie/ui/IntruderSelfieScreen.kt new file mode 100644 index 0000000..442aec3 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/intruderselfie/ui/IntruderSelfieScreen.kt @@ -0,0 +1,163 @@ +package dev.pranav.applock.features.intruderselfie.ui + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import java.io.File +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IntruderSelfieScreen(navController: NavController) { + val context = LocalContext.current + var selfies by remember { mutableStateOf(listOf()) } + var selfieToDelete by remember { mutableStateOf(null) } + + fun loadSelfies() { + val selfieDir = File(context.filesDir, "intruder_selfies") + if (selfieDir.exists()) { + selfies = selfieDir.listFiles()?.filter { it.extension == "jpg" }?.sortedByDescending { it.lastModified() } ?: emptyList() + } + } + + LaunchedEffect(Unit) { + loadSelfies() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Intruder Selfies") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + if (selfies.isEmpty()) { + Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text("No intruder selfies captured yet.", style = MaterialTheme.typography.bodyLarge) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(selfies) { selfie -> + SelfieCard( + file = selfie, + onDelete = { selfieToDelete = selfie } + ) + } + } + } + } + + if (selfieToDelete != null) { + AlertDialog( + onDismissRequest = { selfieToDelete = null }, + title = { Text("Delete Selfie") }, + text = { Text("Are you sure you want to delete this intruder selfie?") }, + confirmButton = { + TextButton( + onClick = { + selfieToDelete?.delete() + selfieToDelete = null + loadSelfies() + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { selfieToDelete = null }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +fun SelfieCard(file: File, onDelete: () -> Unit) { + val bitmap = remember(file) { + BitmapFactory.decodeFile(file.absolutePath) + } + + val timestamp = remember(file) { + val date = Date(file.lastModified()) + val sdf = SimpleDateFormat("HH:mm:ss dd/MM/yyyy", Locale.getDefault()) + sdf.format(date) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Intruder Selfie", + modifier = Modifier + .fillMaxWidth() + .height(250.dp) + .clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)), + contentScale = ContentScale.Crop + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Intruder Detected", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } +} 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 de95e8a..e668f1c 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,7 +1,16 @@ package dev.pranav.applock.features.lockscreen.ui +import android.annotation.SuppressLint +import android.app.admin.DevicePolicyManager +import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.util.Log @@ -17,6 +26,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -28,15 +38,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material3.* +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalWindowInfo 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.compose.ui.unit.dp @@ -45,7 +59,9 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import dev.pranav.applock.R +import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.ui.shapes +import dev.pranav.applock.core.utils.IntruderSelfieManager import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.core.utils.vibrate import dev.pranav.applock.data.repository.AppLockRepository @@ -54,9 +70,12 @@ import dev.pranav.applock.services.AppLockManager import dev.pranav.applock.ui.icons.Backspace import dev.pranav.applock.ui.icons.Fingerprint import dev.pranav.applock.ui.theme.AppLockTheme +import dev.pranav.applock.features.setpassword.ui.RecoveryKeyDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean class PasswordOverlayActivity : FragmentActivity() { private lateinit var executor: Executor @@ -68,10 +87,30 @@ class PasswordOverlayActivity : FragmentActivity() { private var isBiometricPromptShowingLocal = false private var movedToBackground = false - private var appName: String = "" + private val isGoingHome = AtomicBoolean(false) + + private var appName by mutableStateOf("") + private var appIcon by mutableStateOf(null) private val TAG = "PasswordOverlayActivity" + private val systemDialogsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_CLOSE_SYSTEM_DIALOGS) { + val reason = intent.getStringExtra("reason") + // Handle button presses + when (reason) { + "recentapps", "homekey" -> { + // Home or Recents button pressed - trigger HOME action + Log.d(TAG, "System dialog action detected ($reason) - triggering HOME action") + goHome() + } + } + } + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,12 +126,44 @@ class PasswordOverlayActivity : FragmentActivity() { appLockRepository = AppLockRepository(applicationContext) + // Override back button to do nothing onBackPressedDispatcher.addCallback(this) { - // Prevent back navigation to maintain security + Log.d(TAG, "Back button pressed - ignoring on lock screen") + // Do nothing - swallow the back press } setupWindow() - loadAppNameAndSetupUI() + loadAppDetailsAndSetupUI() + + val filter = IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(systemDialogsReceiver, filter, Context.RECEIVER_EXPORTED) + } else { + registerReceiver(systemDialogsReceiver, filter) + } + } + + private fun goHome() { + if (isGoingHome.getAndSet(true)) return // Prevent multiple calls + + try { + // Record that we are leaving the locked app to ensure grace period triggers correctly + lockedPackageNameFromIntent?.let { + AppLockManager.setRecentlyLeftApp(it) + } + + val intent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(intent) + + // Finish activity after starting home intent to clear overlay + finish() + } catch (e: Exception) { + Log.e(TAG, "Error going home: ${e.message}") + isGoingHome.set(false) + } } override fun onPostCreate(savedInstanceState: Bundle?) { @@ -118,7 +189,8 @@ class PasswordOverlayActivity : FragmentActivity() { WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON or - WindowManager.LayoutParams.FLAG_SECURE + WindowManager.LayoutParams.FLAG_SECURE or + WindowManager.LayoutParams.FLAG_FULLSCREEN ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { @@ -130,6 +202,14 @@ class PasswordOverlayActivity : FragmentActivity() { window.setHideOverlayWindows(true) } + // Disable system gestures and navigation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or + WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + + window.addFlags(flags) + } val layoutParams = window.attributes layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY @@ -138,31 +218,58 @@ class PasswordOverlayActivity : FragmentActivity() { layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL } window.attributes = layoutParams + + // Immersive mode to hide system UI + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + android.view.View.SYSTEM_UI_FLAG_FULLSCREEN + ) + } } - private fun loadAppNameAndSetupUI() { + private fun loadAppDetailsAndSetupUI() { lifecycleScope.launch(Dispatchers.IO) { try { - appName = packageManager.getApplicationLabel( - packageManager.getApplicationInfo(lockedPackageNameFromIntent!!, 0) - ).toString() + val pkgName = lockedPackageNameFromIntent!! + val info = packageManager.getApplicationInfo(pkgName, 0) + val label = packageManager.getApplicationLabel(info).toString() + val icon = packageManager.getApplicationIcon(info) + + withContext(Dispatchers.Main) { + appName = label + appIcon = icon + } } catch (e: Exception) { - Log.e(TAG, "Error loading app name: ${e.message}") - appName = getString(R.string.default_app_name) + Log.e(TAG, "Error loading app details: ${e.message}") + withContext(Dispatchers.Main) { + appName = getString(R.string.default_app_name) + } } } setupUI() } private fun setupUI() { - val onPinAttemptCallback = { pin: String -> - val isValid = appLockRepository.validatePassword(pin) + val onPinAttemptCallback = { pin: String, isFinal: Boolean -> + val correctPassword = appLockRepository.getPassword() ?: "" + val isValid = pin == correctPassword + if (isValid) { + IntruderSelfieManager.resetFailedAttempts() lockedPackageNameFromIntent?.let { pkgName -> AppLockManager.unlockApp(pkgName) - finishAfterTransition() } + } else { + // Proper failed attempt count: + // 1. If it's a "Proceed" button click, it's always a failed attempt. + // 2. If it's an auto-unlock check, only count if the length matches or exceeds the correct password length. + if (isFinal || (pin.length >= correctPassword.length && correctPassword.isNotEmpty())) { + IntruderSelfieManager.recordFailedAttempt(this@PasswordOverlayActivity) + } } isValid } @@ -170,11 +277,14 @@ class PasswordOverlayActivity : FragmentActivity() { val onPatternAttemptCallback = { pattern: String -> val isValid = appLockRepository.validatePattern(pattern) if (isValid) { + IntruderSelfieManager.resetFailedAttempts() lockedPackageNameFromIntent?.let { pkgName -> AppLockManager.unlockApp(pkgName) finishAfterTransition() } + } else { + IntruderSelfieManager.recordFailedAttempt(this@PasswordOverlayActivity) } isValid } @@ -183,7 +293,8 @@ class PasswordOverlayActivity : FragmentActivity() { AppLockTheme { Scaffold( modifier = Modifier.fillMaxSize(), - contentColor = MaterialTheme.colorScheme.primaryContainer + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground ) { innerPadding -> val lockType = remember { appLockRepository.getLockType() } when (lockType) { @@ -192,8 +303,27 @@ class PasswordOverlayActivity : FragmentActivity() { modifier = Modifier.padding(innerPadding), fromMainActivity = false, lockedAppName = appName, + lockedAppIcon = appIcon, + triggeringPackageName = triggeringPackageNameFromIntent, + onPatternAttempt = onPatternAttemptCallback, + onBiometricAuth = { triggerBiometricPrompt() }, + onForgotPasscodeReset = { launchResetFlow() } + ) + } + + PreferencesRepository.LOCK_TYPE_PASSWORD -> { + PasswordOverlayScreen( + modifier = Modifier.padding(innerPadding), + showBiometricButton = appLockRepository.isBiometricAuthEnabled(), + fromMainActivity = false, + useTextPassword = true, + onBiometricAuth = { triggerBiometricPrompt() }, + onAuthSuccess = { IntruderSelfieManager.resetFailedAttempts() }, + lockedAppName = appName, + lockedAppIcon = appIcon, triggeringPackageName = triggeringPackageNameFromIntent, - onPatternAttempt = onPatternAttemptCallback + onForgotPasscodeReset = { launchResetFlow() }, + onPinAttempt = onPinAttemptCallback ) } @@ -203,9 +333,11 @@ class PasswordOverlayActivity : FragmentActivity() { showBiometricButton = appLockRepository.isBiometricAuthEnabled(), fromMainActivity = false, onBiometricAuth = { triggerBiometricPrompt() }, - onAuthSuccess = {}, + onAuthSuccess = { IntruderSelfieManager.resetFailedAttempts() }, lockedAppName = appName, + lockedAppIcon = appIcon, triggeringPackageName = triggeringPackageNameFromIntent, + onForgotPasscodeReset = { launchResetFlow() }, onPinAttempt = onPinAttemptCallback ) } @@ -215,6 +347,13 @@ class PasswordOverlayActivity : FragmentActivity() { } } + private fun launchResetFlow() { + startActivity(Intent(this, dev.pranav.applock.MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + }) + finish() + } + private fun setupBiometricPromptInternal() { executor = ContextCompat.getMainExecutor(this) biometricPrompt = @@ -239,16 +378,21 @@ class PasswordOverlayActivity : FragmentActivity() { super.onAuthenticationError(errorCode, errString) isBiometricPromptShowingLocal = false AppLockManager.reportBiometricAuthFinished() - Log.w(TAG, "Authentication error: $errString ($errorCode)") + + // Only log unexpected errors, ignore cancellation during navigation + if (errorCode != BiometricPrompt.ERROR_CANCELED && + errorCode != BiometricPrompt.ERROR_USER_CANCELED && + errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + Log.w(TAG, "Authentication error: $errString ($errorCode)") + } } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) isBiometricPromptShowingLocal = false + IntruderSelfieManager.resetFailedAttempts() lockedPackageNameFromIntent?.let { pkgName -> AppLockManager.temporarilyUnlockAppWithBiometrics(pkgName) - // Fix: Do NOT relaunch the app. Just finish the overlay to reveal the underlying activity. - // This preserves the navigation stack/state of the locked app. } finishAfterTransition() } @@ -257,7 +401,8 @@ class PasswordOverlayActivity : FragmentActivity() { override fun onResume() { super.onResume() movedToBackground = false - AppLockManager.isLockScreenShown.set(true) // Set to true when activity is visible + isGoingHome.set(false) + AppLockManager.isLockScreenShown.set(true) lifecycleScope.launch { applyUserPreferences() } @@ -275,7 +420,7 @@ class PasswordOverlayActivity : FragmentActivity() { } fun triggerBiometricPrompt() { - if (appLockRepository.isBiometricAuthEnabled()) { + if (appLockRepository.isBiometricAuthEnabled() && !isGoingHome.get()) { AppLockManager.reportBiometricAuthStarted() isBiometricPromptShowingLocal = true try { @@ -288,9 +433,16 @@ class PasswordOverlayActivity : FragmentActivity() { } } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + Log.d(TAG, "User leave hint - going home from lock screen") + // Allow going home when user presses home/recents + goHome() + } + override fun onPause() { super.onPause() - if (!isChangingConfigurations() && !isBiometricPromptShowingLocal && !movedToBackground) { + if (!isChangingConfigurations() && !isBiometricPromptShowingLocal && !movedToBackground && !isGoingHome.get()) { AppLockManager.isLockScreenShown.set(false) AppLockManager.reportBiometricAuthFinished() finish() @@ -314,6 +466,11 @@ class PasswordOverlayActivity : FragmentActivity() { override fun onDestroy() { super.onDestroy() + try { + unregisterReceiver(systemDialogsReceiver) + } catch (e: Exception) { + // Already unregistered + } AppLockManager.isLockScreenShown.set(false) AppLockManager.reportBiometricAuthFinished() Log.d(TAG, "PasswordOverlayActivity onDestroy for $lockedPackageNameFromIntent") @@ -330,8 +487,11 @@ fun PasswordOverlayScreen( onBiometricAuth: () -> Unit = {}, onAuthSuccess: () -> Unit, lockedAppName: String? = null, + lockedAppIcon: Drawable? = null, triggeringPackageName: String? = null, - onPinAttempt: ((pin: String) -> Boolean)? = null + useTextPassword: Boolean = false, + onForgotPasscodeReset: (() -> Unit)? = null, + onPinAttempt: ((pin: String, isFinal: Boolean) -> Boolean)? = null ) { val appLockRepository = LocalContext.current.appLockRepository() val windowInfo = LocalWindowInfo.current @@ -345,13 +505,66 @@ fun PasswordOverlayScreen( Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surfaceContainer + color = MaterialTheme.colorScheme.background ) { val passwordState = remember { mutableStateOf("") } var showError by remember { mutableStateOf(false) } + var showRecoveryDialog by remember { mutableStateOf(false) } val minLength = 4 - if (isLandscape) { + if (showRecoveryDialog) { + RecoveryKeyDialog( + onDismiss = { showRecoveryDialog = false }, + onValidated = { onForgotPasscodeReset?.invoke() } + ) + } + + if (useTextPassword) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AppHeader( + fromMainActivity = fromMainActivity, + lockedAppName = lockedAppName, + lockedAppIcon = lockedAppIcon, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.height(24.dp)) + OutlinedTextField( + value = passwordState.value, + onValueChange = { + passwordState.value = it + showError = false + }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + if (showError) { + Spacer(modifier = Modifier.height(8.dp)) + Text(stringResource(R.string.incorrect_password_error), color = MaterialTheme.colorScheme.error) + } + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { + val valid = onPinAttempt?.invoke(passwordState.value, true) ?: false + if (valid) { + onAuthSuccess() + } else { + showError = true + passwordState.value = "" + } + }, modifier = Modifier.fillMaxWidth()) { Text("Unlock") } + TextButton(onClick = { showRecoveryDialog = true }) { Text("Forgot passcode?") } + if (showBiometricButton) { + TextButton(onClick = onBiometricAuth) { Text("Use biometric") } + } + } + } else if (isLandscape) { Row( modifier = Modifier .fillMaxSize() @@ -366,25 +579,13 @@ fun PasswordOverlayScreen( 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.titleLarge, - textAlign = TextAlign.Center + AppHeader( + fromMainActivity = fromMainActivity, + lockedAppName = lockedAppName, + lockedAppIcon = lockedAppIcon, + style = MaterialTheme.typography.titleLarge ) -// if (!fromMainActivity && !triggeringPackageName.isNullOrEmpty()) { -// Spacer(modifier = Modifier.height(8.dp)) -// Text( -// text = triggeringPackageName, -// style = MaterialTheme.typography.labelSmall, -// color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), -// textAlign = TextAlign.Center -// ) -// } - Spacer(modifier = Modifier.height(16.dp)) PasswordIndicators( @@ -417,11 +618,12 @@ fun PasswordOverlayScreen( showError = false if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) + onPinAttempt?.invoke(passwordState.value, false) } }, onPinIncorrect = { showError = true } ) + TextButton(onClick = { showRecoveryDialog = true }) { Text("Forgot passcode?") } } } } else { @@ -436,27 +638,16 @@ fun PasswordOverlayScreen( val topSpacerHeight = if (screenHeightDp < 600.dp) 12.dp else 48.dp Spacer(modifier = Modifier.height(topSpacerHeight)) - Text( - text = if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) - "Continue to $lockedAppName" - else - stringResource(R.string.enter_password_to_continue), + AppHeader( + fromMainActivity = fromMainActivity, + lockedAppName = lockedAppName, + lockedAppIcon = lockedAppIcon, style = if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) MaterialTheme.typography.titleLargeEmphasized else - MaterialTheme.typography.headlineMediumEmphasized, - textAlign = TextAlign.Center + MaterialTheme.typography.headlineMediumEmphasized ) -// if (!fromMainActivity && !triggeringPackageName.isNullOrEmpty()) { -// Text( -// text = triggeringPackageName, -// style = MaterialTheme.typography.labelSmall, -// color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), -// textAlign = TextAlign.Center -// ) -// } - Spacer(modifier = Modifier.height(16.dp)) PasswordIndicators( @@ -486,11 +677,12 @@ fun PasswordOverlayScreen( showError = false if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) + onPinAttempt?.invoke(passwordState.value, false) } }, onPinIncorrect = { showError = true } ) + TextButton(onClick = { showRecoveryDialog = true }) { Text("Forgot passcode?") } } } } @@ -500,6 +692,70 @@ fun PasswordOverlayScreen( } } +@Composable +fun AppHeader( + fromMainActivity: Boolean, + lockedAppName: String?, + lockedAppIcon: Drawable?, + style: androidx.compose.ui.text.TextStyle +) { + if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Continue to", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + letterSpacing = 1.sp + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(horizontal = 16.dp) + ) { + if (lockedAppIcon != null) { + val bitmap = remember(lockedAppIcon) { + val b = Bitmap.createBitmap( + lockedAppIcon.intrinsicWidth.coerceAtLeast(1), + lockedAppIcon.intrinsicHeight.coerceAtLeast(1), + Bitmap.Config.ARGB_8888 + ) + val canvas = Canvas(b) + lockedAppIcon.setBounds(0, 0, canvas.width, canvas.height) + lockedAppIcon.draw(canvas) + b.asImageBitmap() + } + Image( + bitmap = bitmap, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } + + Text( + text = lockedAppName, + style = style.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp + ), + textAlign = TextAlign.Center + ) + } + } + } else { + Text( + text = stringResource(R.string.enter_password_to_continue), + style = style, + textAlign = TextAlign.Center + ) + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) @Composable fun PasswordIndicators( @@ -556,7 +812,7 @@ fun PasswordIndicators( ) { LazyRow( state = lazyListState, - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy( indicatorSpacing, Alignment.CenterHorizontally @@ -648,7 +904,7 @@ fun KeypadSection( fromMainActivity: Boolean = false, onBiometricAuth: () -> Unit, onAuthSuccess: () -> Unit, - onPinAttempt: ((pin: String) -> Boolean)? = null, + onPinAttempt: ((pin: String, isFinal: Boolean) -> Boolean)? = null, onPasswordChange: () -> Unit, onPinIncorrect: () -> Unit ) { @@ -679,9 +935,6 @@ fun KeypadSection( } } - // Calculate available height for keypad (heuristic) - // 4 rows of buttons + 3 spacings + biometric button (optional) - // Estimate top content takes ~200dp val estimatedTopContentHeight = 220.dp val availableHeight = screenHeightDp - estimatedTopContentHeight @@ -694,10 +947,11 @@ fun KeypadSection( horizontalPadding, showBiometricButton ) { + val rows = if (showBiometricButton) 5f else 4f if (isLandscape) { val availableLandscapeHeight = screenHeightDp * 0.8f - val totalVerticalSpacing = buttonSpacing * 3 - val heightBasedSize = (availableLandscapeHeight - totalVerticalSpacing) / 4f + val totalVerticalSpacing = buttonSpacing * (rows - 1) + val heightBasedSize = (availableLandscapeHeight - totalVerticalSpacing) / rows val availableWidth = (screenWidthDp * 0.45f) val totalHorizontalSpacing = buttonSpacing * 2 @@ -709,13 +963,9 @@ fun KeypadSection( val totalHorizontalSpacing = buttonSpacing * 2 val widthBasedSize = (availableWidth - totalHorizontalSpacing) / 3.5f - // Height constraint for portrait - val totalVerticalSpacing = buttonSpacing * 3 - // If biometric button is shown, it takes extra space, but it's floating or above? - // In the current layout, it's inside the column at the top. - val biometricAllowance = if (showBiometricButton) 60.dp else 0.dp + val totalVerticalSpacing = buttonSpacing * (rows - 1) val heightBasedSize = - (availableHeight - totalVerticalSpacing - biometricAllowance) / 4f + (availableHeight - totalVerticalSpacing) / rows minOf(widthBasedSize, heightBasedSize) } @@ -769,26 +1019,9 @@ fun KeypadSection( Modifier .padding(horizontal = horizontalPadding) .navigationBarsPadding() - // Add a small bottom padding to ensure it doesn't touch the edge .padding(bottom = 8.dp) } ) { - if (showBiometricButton) { - FilledTonalIconButton( - onClick = onBiometricAuth, - modifier = Modifier.size(44.dp), - shape = RoundedCornerShape(40), - ) { - Icon( - imageVector = Fingerprint, - modifier = Modifier - .fillMaxSize() - .padding(10.dp), - contentDescription = stringResource(R.string.biometric_authentication_cd), - tint = MaterialTheme.colorScheme.surfaceTint - ) - } - } KeypadRow( disableHaptics = disableHaptics, keys = listOf("1", "2", "3"), @@ -818,6 +1051,26 @@ fun KeypadSection( buttonSize = buttonSize, buttonSpacing = buttonSpacing ) + + if (showBiometricButton) { + FilledTonalIconButton( + onClick = onBiometricAuth, + modifier = Modifier.size(buttonSize), + shape = CircleShape, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon( + imageVector = Fingerprint, + modifier = Modifier + .fillMaxSize() + .padding(buttonSize * 0.25f), + contentDescription = stringResource(R.string.biometric_authentication_cd), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } } } @@ -836,7 +1089,7 @@ private fun handleKeypadSpecialButtonLogic( minLength: Int, fromMainActivity: Boolean, onAuthSuccess: () -> Unit, - onPinAttempt: ((pin: String) -> Boolean)?, + onPinAttempt: ((pin: String, isFinal: Boolean) -> Boolean)?, context: Context, onPasswordChange: () -> Unit, onPinIncorrect: () -> Unit @@ -862,9 +1115,12 @@ private fun handleKeypadSpecialButtonLogic( } if (passwordState.value.length >= minLength) { if (fromMainActivity) { - if (appLockRepository.validatePassword(passwordState.value)) { + val correctPassword = appLockRepository.getPassword() ?: "" + if (passwordState.value == correctPassword) { + IntruderSelfieManager.resetFailedAttempts() onAuthSuccess() } else { + IntruderSelfieManager.recordFailedAttempt(context) passwordState.value = "" if (!appLockRepository.shouldDisableHaptics()) { vibrate(context, 100) @@ -873,7 +1129,7 @@ private fun handleKeypadSpecialButtonLogic( } } else { onPinAttempt?.let { attempt -> - val pinWasCorrectAndProcessed = attempt(passwordState.value) + val pinWasCorrectAndProcessed = attempt(passwordState.value, true) if (!pinWasCorrectAndProcessed) { passwordState.value = "" if (!appLockRepository.shouldDisableHaptics()) { @@ -918,7 +1174,7 @@ fun KeypadRow( val targetColor = if (isPressed) { MaterialTheme.colorScheme.primaryContainer } else { - if (icons.isNotEmpty() && index < icons.size && icons[index] != null) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceBright + MaterialTheme.colorScheme.secondaryContainer } val animatedContainerColor by animateColorAsState( @@ -953,7 +1209,7 @@ fun KeypadRow( ), elevation = ButtonDefaults.filledTonalButtonElevation() ) { - val contentColor = MaterialTheme.colorScheme.onPrimaryContainer + val contentColor = if (isPressed) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer if (icons.isNotEmpty() && index < icons.size && icons[index] != null) { Icon( @@ -965,6 +1221,7 @@ fun KeypadRow( } else { Text( text = key, + color = contentColor, style = MaterialTheme.typography.headlineLargeEmphasized.copy( fontSize = animatedFontSize.sp ), diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PatternLockScreen.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PatternLockScreen.kt index 69b9f7d..d4c337c 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PatternLockScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/PatternLockScreen.kt @@ -1,5 +1,6 @@ package dev.pranav.applock.features.lockscreen.ui +import android.graphics.drawable.Drawable import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState @@ -15,13 +16,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,6 +44,7 @@ import com.mrhwsn.composelock.Dot import com.mrhwsn.composelock.LockCallback import com.mrhwsn.composelock.PatternLock import dev.pranav.applock.R +import dev.pranav.applock.core.utils.IntruderSelfieManager import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.core.utils.vibrate import dev.pranav.applock.ui.icons.Fingerprint @@ -50,9 +55,11 @@ fun PatternLockScreen( modifier: Modifier = Modifier, fromMainActivity: Boolean = false, lockedAppName: String? = null, + lockedAppIcon: Drawable? = null, triggeringPackageName: String? = null, onPatternAttempt: ((pattern: String) -> Boolean)? = null, - onBiometricAuth: (() -> Unit)? = null + onBiometricAuth: (() -> Unit)? = null, + onForgotPasscodeReset: (() -> Unit)? = null ) { val appLockRepository = LocalContext.current.appLockRepository() val context = LocalContext.current @@ -64,7 +71,7 @@ fun PatternLockScreen( Surface( modifier = modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.surface + color = MaterialTheme.colorScheme.background ) { var showError by remember { mutableStateOf(false) } @@ -101,6 +108,9 @@ fun PatternLockScreen( if (!isValid) { showError = true errorShakeOffset = 10f + // recordFailedAttempt is already handled in PasswordOverlayActivity's onPatternAttemptCallback + } else { + IntruderSelfieManager.resetFailedAttempts() } } } @@ -119,55 +129,44 @@ fun PatternLockScreen( .weight(1f) .fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween + verticalArrangement = Arrangement.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + AppHeader( + fromMainActivity = fromMainActivity, + lockedAppName = lockedAppName, + lockedAppIcon = lockedAppIcon, + style = MaterialTheme.typography.titleMedium + ) + + if (showError) { + Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) - "Continue to $lockedAppName" - else - stringResource(R.string.enter_pattern_to_continue), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.incorrect_pattern_try_again), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center ) -// -// if (!fromMainActivity && !triggeringPackageName.isNullOrEmpty()) { -// Spacer(modifier = Modifier.height(8.dp)) -// Text( -// text = triggeringPackageName, -// style = MaterialTheme.typography.bodySmall, -// color = MaterialTheme.colorScheme.onSurfaceVariant, -// textAlign = TextAlign.Center -// ) -// } - - if (showError) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.incorrect_pattern_try_again), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - } } + TextButton(onClick = { onForgotPasscodeReset?.invoke() }) { Text("Forgot passcode?") } + if (appLockRepository.isBiometricAuthEnabled() && onBiometricAuth != null) { + Spacer(modifier = Modifier.height(24.dp)) FilledTonalIconButton( onClick = { onBiometricAuth() }, - modifier = Modifier.size(44.dp), - shape = RoundedCornerShape(40), + modifier = Modifier.size(64.dp), + shape = CircleShape, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) ) { Icon( imageVector = Fingerprint, modifier = Modifier .fillMaxSize() - .padding(10.dp), + .padding(16.dp), contentDescription = stringResource(R.string.biometric_authentication_cd), - tint = MaterialTheme.colorScheme.surfaceTint + tint = MaterialTheme.colorScheme.onSecondaryContainer ) } } @@ -206,26 +205,13 @@ fun PatternLockScreen( Column( horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - text = if (!fromMainActivity && !lockedAppName.isNullOrEmpty()) - "Continue to $lockedAppName" - else - stringResource(R.string.enter_pattern_to_continue), - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center + AppHeader( + fromMainActivity = fromMainActivity, + lockedAppName = lockedAppName, + lockedAppIcon = lockedAppIcon, + style = MaterialTheme.typography.headlineSmall ) -// if (!fromMainActivity && !triggeringPackageName.isNullOrEmpty()) { -// Spacer(modifier = Modifier.height(8.dp)) -// Text( -// text = triggeringPackageName, -// style = MaterialTheme.typography.labelLarge, -// color = MaterialTheme.colorScheme.onSurfaceVariant, -// textAlign = TextAlign.Center -// ) -// } - if (showError) { Spacer(modifier = Modifier.height(8.dp)) Text( @@ -238,27 +224,10 @@ fun PatternLockScreen( } Column( - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.weight(1f) ) { - if (appLockRepository.isBiometricAuthEnabled() && onBiometricAuth != null) { - FilledTonalIconButton( - onClick = { onBiometricAuth() }, - modifier = Modifier.size(44.dp), - shape = RoundedCornerShape(40), - ) { - Icon( - imageVector = Fingerprint, - modifier = Modifier - .fillMaxSize() - .padding(10.dp), - contentDescription = stringResource(R.string.biometric_authentication_cd), - tint = MaterialTheme.colorScheme.surfaceTint - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - } - PatternLock( modifier = Modifier .fillMaxWidth() @@ -273,6 +242,30 @@ fun PatternLockScreen( animationDuration = 120, callback = lockCallback ) + + TextButton(onClick = { onForgotPasscodeReset?.invoke() }) { Text("Forgot passcode?") } + + if (appLockRepository.isBiometricAuthEnabled() && onBiometricAuth != null) { + Spacer(modifier = Modifier.height(32.dp)) + + FilledTonalIconButton( + onClick = { onBiometricAuth() }, + modifier = Modifier.size(72.dp), + shape = CircleShape, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Icon( + imageVector = Fingerprint, + modifier = Modifier + .fillMaxSize() + .padding(18.dp), + contentDescription = stringResource(R.string.biometric_authentication_cd), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } } } } 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..ff19fdc 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 @@ -44,6 +44,7 @@ import com.mrhwsn.composelock.PatternLock import dev.pranav.applock.AppLockApplication import dev.pranav.applock.R import dev.pranav.applock.core.navigation.Screen +import dev.pranav.applock.core.utils.RecoveryKeyManager import dev.pranav.applock.core.utils.vibrate import dev.pranav.applock.data.repository.PreferencesRepository @@ -56,11 +57,11 @@ fun PatternSetPasswordScreen( var patternState by remember { mutableStateOf("") } var confirmPatternState by remember { mutableStateOf("") } var isConfirmationMode by remember { mutableStateOf(false) } - var isVerifyOldPasswordMode by remember { mutableStateOf(!isFirstTimeSetup) } var showMismatchError by remember { mutableStateOf(false) } var showMinLengthError by remember { mutableStateOf(false) } - var showInvalidOldPasswordError by remember { mutableStateOf(false) } + var showRecoveryDialog by remember { mutableStateOf(false) } + var generatedRecoveryKey by remember { mutableStateOf("") } val minLength = 4 val context = LocalContext.current @@ -74,23 +75,6 @@ fun PatternSetPasswordScreen( val screenHeight = windowInfo.containerSize.height val isLandscape = screenWidth > screenHeight - 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() { @@ -108,15 +92,29 @@ fun PatternSetPasswordScreen( object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) - isVerifyOldPasswordMode = false - patternState = "" - confirmPatternState = "" - showInvalidOldPasswordError = false + // In this context, we just allow them to set a new pattern } }) biometricPrompt.authenticate(promptInfo) } + 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() + } + } + } + fun switchToPinMethod() { navController.navigate(Screen.SetPassword.route) { popUpTo(Screen.SetPasswordPattern.route) { inclusive = true } @@ -125,7 +123,6 @@ fun PatternSetPasswordScreen( fun submitPattern() { val currentPattern = when { - isVerifyOldPasswordMode -> patternState isConfirmationMode -> confirmPatternState else -> patternState } @@ -136,17 +133,6 @@ fun PatternSetPasswordScreen( } when { - isVerifyOldPasswordMode -> { - if (appLockRepository!!.validatePattern(patternState)) { - isVerifyOldPasswordMode = false - patternState = "" - showInvalidOldPasswordError = false - } else { - showInvalidOldPasswordError = true - patternState = "" - } - } - !isConfirmationMode -> { isConfirmationMode = true showMinLengthError = false @@ -162,12 +148,9 @@ fun PatternSetPasswordScreen( Toast.LENGTH_SHORT ).show() - navController.navigate(Screen.Main.route) { - popUpTo(Screen.SetPassword.route) { inclusive = true } - if (isFirstTimeSetup) { - popUpTo(Screen.AppIntro.route) { inclusive = true } - } - } + generatedRecoveryKey = RecoveryKeyManager.generateRecoveryKey() + appLockRepository?.setRecoveryKey(generatedRecoveryKey) + showRecoveryDialog = true } else { showMismatchError = true confirmPatternState = "" @@ -176,6 +159,21 @@ fun PatternSetPasswordScreen( } } + if (showRecoveryDialog) { + RecoveryKeyGeneratedDialog( + recoveryKey = generatedRecoveryKey, + onDismiss = { + showRecoveryDialog = false + navController.navigate(Screen.Main.route) { + popUpTo(Screen.SetPasswordPattern.route) { inclusive = true } + if (isFirstTimeSetup) { + popUpTo(Screen.AppIntro.route) { inclusive = true } + } + } + } + ) + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainer, topBar = { @@ -184,7 +182,6 @@ fun PatternSetPasswordScreen( title = { Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_title) isConfirmationMode -> stringResource(R.string.confirm_pin_title) else -> stringResource(R.string.set_new_pin_title) }, @@ -215,7 +212,6 @@ fun PatternSetPasswordScreen( ) { Text( text = when { - isVerifyOldPasswordMode -> "Enter current Pattern" isConfirmationMode -> "Confirm pattern" else -> "Create a pattern" }, @@ -241,14 +237,6 @@ fun PatternSetPasswordScreen( textAlign = TextAlign.Center ) } - if (showInvalidOldPasswordError) { - Text( - text = "Incorrect pattern", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center - ) - } Spacer(modifier = Modifier.height(12.dp)) @@ -259,46 +247,38 @@ fun PatternSetPasswordScreen( textAlign = TextAlign.Center ) - if (isVerifyOldPasswordMode) { + if (!isFirstTimeSetup) { Spacer(modifier = Modifier.height(12.dp)) TextButton(onClick = { launchDeviceCredentialAuth() }) { Text(stringResource(R.string.reset_using_device_password_button)) } } - if (isFirstTimeSetup && !isVerifyOldPasswordMode && !isConfirmationMode) { + if (isFirstTimeSetup && !isConfirmationMode) { TextButton(onClick = { switchToPinMethod() }) { - Text("Use PIN Instead") + Text("Use PIN") + } + TextButton(onClick = { + navController.navigate(Screen.SetPasswordText.route) { + popUpTo(Screen.SetPasswordPattern.route) { inclusive = true } + } + }) { + Text("Use Password") } } - if (isVerifyOldPasswordMode || isConfirmationMode) { + if (isConfirmationMode) { Spacer(modifier = Modifier.height(8.dp)) TextButton( onClick = { - if (isVerifyOldPasswordMode) { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } else { - activity?.finish() - } - } else { - isConfirmationMode = false - if (!isFirstTimeSetup) { - isVerifyOldPasswordMode = true - } - } + isConfirmationMode = false patternState = "" confirmPatternState = "" showMismatchError = false showMinLengthError = false - showInvalidOldPasswordError = false } ) { - Text( - if (isVerifyOldPasswordMode) stringResource(R.string.cancel_button) - else stringResource(R.string.start_over_button) - ) + Text(stringResource(R.string.start_over_button)) } } } @@ -329,9 +309,7 @@ fun PatternSetPasswordScreen( override fun onResult(result: List) { val patternString = result.joinToString("") { it.id.toString() } - if (isVerifyOldPasswordMode) { - patternState = patternString - } else if (isConfirmationMode) { + if (isConfirmationMode) { confirmPatternState = patternString } else { patternState = patternString @@ -355,7 +333,6 @@ fun PatternSetPasswordScreen( ) { Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_label) isConfirmationMode -> stringResource(R.string.confirm_new_pin_label) else -> stringResource(R.string.create_new_pin_label) }, @@ -383,16 +360,6 @@ fun PatternSetPasswordScreen( ) } - if (showInvalidOldPasswordError) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Incorrect pattern", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelLarge, - textAlign = TextAlign.Center - ) - } - Spacer(modifier = Modifier.height(8.dp)) Text( text = stringResource(R.string.enter_pattern_to_continue), @@ -433,9 +400,7 @@ fun PatternSetPasswordScreen( override fun onResult(result: List) { val patternString = result.joinToString("") { it.id.toString() } - if (isVerifyOldPasswordMode) { - patternState = patternString - } else if (isConfirmationMode) { + if (isConfirmationMode) { confirmPatternState = patternString } else { patternState = patternString @@ -447,44 +412,34 @@ fun PatternSetPasswordScreen( Spacer(modifier = Modifier.height(16.dp)) - if (!isVerifyOldPasswordMode && !isConfirmationMode) { + if (!isConfirmationMode) { TextButton(onClick = { switchToPinMethod() }) { - Text("Use PIN Instead") + Text("Use PIN") + } + TextButton(onClick = { + navController.navigate(Screen.SetPasswordText.route) + }) { + Text("Use Password") } } - if (isVerifyOldPasswordMode) { + if (!isFirstTimeSetup) { TextButton(onClick = { launchDeviceCredentialAuth() }) { Text(stringResource(R.string.reset_using_device_password_button)) } } - if (isVerifyOldPasswordMode || isConfirmationMode) { + if (isConfirmationMode) { TextButton( onClick = { - if (isVerifyOldPasswordMode) { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } else { - activity?.finish() - } - } else { - isConfirmationMode = false - if (!isFirstTimeSetup) { - isVerifyOldPasswordMode = true - } - } + isConfirmationMode = false patternState = "" confirmPatternState = "" showMismatchError = false showMinLengthError = false - showInvalidOldPasswordError = false } ) { - Text( - if (isVerifyOldPasswordMode) stringResource(R.string.cancel_button) - else stringResource(R.string.start_over_button) - ) + Text(stringResource(R.string.start_over_button)) } } } diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/RecoveryKeyComponents.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/RecoveryKeyComponents.kt new file mode 100644 index 0000000..c4d385c --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/RecoveryKeyComponents.kt @@ -0,0 +1,115 @@ +package dev.pranav.applock.features.setpassword.ui + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import dev.pranav.applock.core.utils.RecoveryKeyManager +import dev.pranav.applock.core.utils.appLockRepository + +@Composable +fun RecoveryKeyDialog( + onDismiss: () -> Unit, + onValidated: () -> Unit +) { + val context = LocalContext.current + val repo = context.appLockRepository() + val recoveryKey = remember { mutableStateOf("") } + val error = remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Forgot passcode") }, + text = { + Column { + Text("Enter your recovery key to reset your current lock.") + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = recoveryKey.value, + onValueChange = { + recoveryKey.value = it + error.value = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Recovery key") }, + supportingText = { + error.value?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + ) + } + }, + confirmButton = { + TextButton(onClick = { + if (repo.validateRecoveryKey(recoveryKey.value.trim())) { + onValidated() + onDismiss() + } else { + error.value = "Invalid recovery key" + } + }) { Text("Reset lock") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +fun RecoveryKeyGeneratedDialog( + recoveryKey: String, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + AlertDialog( + onDismissRequest = {}, + title = { Text("Save your recovery key") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("This key is shown only once. Save it now to reset your lock later if you forget your passcode.") + Text( + text = recoveryKey, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + }, + confirmButton = { + TextButton(onClick = { + val result = RecoveryKeyManager.saveRecoveryKeyToDownloads(context, recoveryKey) + result.onSuccess { + Toast.makeText(context, "Recovery key saved to $it", Toast.LENGTH_LONG).show() + }.onFailure { + Toast.makeText(context, it.message ?: "Failed to save recovery key", Toast.LENGTH_LONG).show() + } + }) { Text("Save as TXT") } + }, + dismissButton = { + Column { + TextButton(onClick = { + clipboardManager.setText(AnnotatedString(recoveryKey)) + Toast.makeText(context, "Recovery key copied", Toast.LENGTH_SHORT).show() + }) { Text("Copy") } + TextButton(onClick = onDismiss) { Text("Continue") } + } + } + ) +} 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..74aec19 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 @@ -43,6 +43,7 @@ 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.RecoveryKeyManager import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.lockscreen.ui.KeypadRow import dev.pranav.applock.features.lockscreen.ui.PasswordIndicators @@ -61,11 +62,10 @@ fun SetPasswordScreen( var confirmPasswordState by remember { mutableStateOf("") } var isConfirmationMode by remember { mutableStateOf(false) } - var isVerifyOldPasswordMode by remember { mutableStateOf(!isFirstTimeSetup) } - var showMismatchError by remember { mutableStateOf(false) } var showLengthError by remember { mutableStateOf(false) } - var showInvalidOldPasswordError by remember { mutableStateOf(false) } + var showRecoveryDialog by remember { mutableStateOf(false) } + var generatedRecoveryKey by remember { mutableStateOf("") } val minLength = 4 val context = LocalContext.current @@ -131,31 +131,19 @@ fun SetPasswordScreen( } } - 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 + if (showRecoveryDialog) { + RecoveryKeyGeneratedDialog( + recoveryKey = generatedRecoveryKey, + onDismiss = { + showRecoveryDialog = false + navController.navigate(Screen.Main.route) { + popUpTo(Screen.SetPassword.route) { inclusive = true } + if (isFirstTimeSetup) { + popUpTo(Screen.AppIntro.route) { inclusive = true } + } } - - }) - biometricPrompt.authenticate(promptInfo) + } + ) } Scaffold( @@ -166,7 +154,6 @@ fun SetPasswordScreen( title = { Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_title) isConfirmationMode -> stringResource(R.string.confirm_pin_title) else -> stringResource(R.string.set_new_pin_title) }, @@ -199,7 +186,6 @@ fun SetPasswordScreen( ) { Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_label) isConfirmationMode -> stringResource(R.string.confirm_new_pin_label) else -> stringResource(R.string.create_new_pin_label) }, @@ -225,17 +211,8 @@ fun SetPasswordScreen( modifier = Modifier.padding(8.dp) ) } - if (showInvalidOldPasswordError) { - Text( - text = stringResource(R.string.incorrect_pin_try_again), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(8.dp) - ) - } val currentPassword = when { - isVerifyOldPasswordMode -> passwordState isConfirmationMode -> confirmPasswordState else -> passwordState } @@ -248,7 +225,6 @@ fun SetPasswordScreen( Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_label) isConfirmationMode -> stringResource(R.string.re_enter_new_pin_confirm_label) else -> stringResource(R.string.tooltip_create_pin_min_length) }, @@ -257,41 +233,18 @@ fun SetPasswordScreen( textAlign = TextAlign.Center ) - if (isVerifyOldPasswordMode) { - Spacer(modifier = Modifier.height(16.dp)) - TextButton(onClick = { launchDeviceCredentialAuth() }) { - Text(stringResource(R.string.reset_using_device_password_button)) - } - } - - if (isVerifyOldPasswordMode || isConfirmationMode) { + if (isConfirmationMode) { Spacer(modifier = Modifier.height(8.dp)) TextButton( onClick = { - if (isVerifyOldPasswordMode) { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } else { - activity?.finish() - } - } else { - isConfirmationMode = false - if (!isFirstTimeSetup) { - isVerifyOldPasswordMode = true - } - } + isConfirmationMode = false passwordState = "" confirmPasswordState = "" showMismatchError = false showLengthError = false - showInvalidOldPasswordError = false } ) { - Text( - if (isVerifyOldPasswordMode) stringResource(R.string.cancel_button) else stringResource( - R.string.start_over_button - ) - ) + Text(stringResource(R.string.start_over_button)) } } } @@ -303,12 +256,10 @@ fun SetPasswordScreen( ) { val onKeyClick: (String) -> Unit = { key -> val currentActivePassword = when { - isVerifyOldPasswordMode -> passwordState isConfirmationMode -> confirmPasswordState else -> passwordState } val updatePassword: (String) -> Unit = when { - isVerifyOldPasswordMode -> { newPass -> passwordState = newPass } isConfirmationMode -> { newPass -> confirmPasswordState = newPass } else -> { newPass -> passwordState = newPass } } @@ -324,23 +275,11 @@ fun SetPasswordScreen( } showMismatchError = false showLengthError = false - showInvalidOldPasswordError = false } "proceed" -> { if (currentActivePassword.length >= minLength) { when { - isVerifyOldPasswordMode -> { - if (appLockRepository!!.validatePassword(passwordState)) { - isVerifyOldPasswordMode = false - passwordState = "" - showInvalidOldPasswordError = false - } else { - showInvalidOldPasswordError = true - passwordState = "" - } - } - !isConfirmationMode -> { isConfirmationMode = true showLengthError = false @@ -349,22 +288,15 @@ fun SetPasswordScreen( else -> { if (passwordState == confirmPasswordState) { appLockRepository?.setPassword(passwordState) + appLockRepository?.setLockType(PreferencesRepository.LOCK_TYPE_PIN) + generatedRecoveryKey = RecoveryKeyManager.generateRecoveryKey() + appLockRepository?.setRecoveryKey(generatedRecoveryKey) 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 - } - } - } + showRecoveryDialog = true } else { showMismatchError = true confirmPasswordState = "" @@ -408,7 +340,7 @@ fun SetPasswordScreen( icons = listOf( Backspace, null, - if (isConfirmationMode || isVerifyOldPasswordMode) Icons.Default.Check else Icons.AutoMirrored.Rounded.KeyboardArrowRight + if (isConfirmationMode) Icons.Default.Check else Icons.AutoMirrored.Rounded.KeyboardArrowRight ), onKeyClick = onKeyClick, buttonSize = buttonSize, @@ -433,7 +365,6 @@ fun SetPasswordScreen( ) { Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_label) isConfirmationMode -> stringResource(R.string.confirm_new_pin_label) else -> stringResource(R.string.create_new_pin_label) }, @@ -458,17 +389,8 @@ fun SetPasswordScreen( modifier = Modifier.padding(8.dp) ) } - if (showInvalidOldPasswordError) { - Text( - text = stringResource(R.string.incorrect_pin_try_again), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(8.dp) - ) - } val currentPassword = when { - isVerifyOldPasswordMode -> passwordState isConfirmationMode -> confirmPasswordState else -> passwordState } @@ -479,7 +401,6 @@ fun SetPasswordScreen( Text( text = when { - isVerifyOldPasswordMode -> stringResource(R.string.enter_current_pin_label) isConfirmationMode -> stringResource(R.string.re_enter_new_pin_confirm_label) else -> stringResource(R.string.tooltip_create_pin_min_length) }, @@ -490,40 +411,18 @@ fun SetPasswordScreen( Spacer(modifier = Modifier.weight(1f)) - if (isVerifyOldPasswordMode) { - TextButton(onClick = { launchDeviceCredentialAuth() }) { - Text(stringResource(R.string.reset_using_device_password_button)) - } - } - - if (isVerifyOldPasswordMode || isConfirmationMode) { + if (isConfirmationMode) { TextButton( onClick = { - if (isVerifyOldPasswordMode) { - if (navController.previousBackStackEntry != null) { - navController.popBackStack() - } else { - activity?.finish() - } - } else { - isConfirmationMode = false - if (!isFirstTimeSetup) { - isVerifyOldPasswordMode = true - } - } + isConfirmationMode = false passwordState = "" confirmPasswordState = "" showMismatchError = false showLengthError = false - showInvalidOldPasswordError = false }, modifier = Modifier.padding(bottom = 16.dp) ) { - Text( - if (isVerifyOldPasswordMode) stringResource(R.string.cancel_button) else stringResource( - R.string.start_over_button - ) - ) + Text(stringResource(R.string.start_over_button)) } } @@ -533,9 +432,15 @@ fun SetPasswordScreen( }, modifier = Modifier.padding(bottom = 16.dp) ) { - Text( - stringResource(R.string.use_pattern_button) - ) + Text(stringResource(R.string.use_pattern_button)) + } + TextButton( + onClick = { + navController.navigate(Screen.SetPasswordText.route) + }, + modifier = Modifier.padding(bottom = 16.dp) + ) { + Text("Use Password") } Column( @@ -545,12 +450,10 @@ fun SetPasswordScreen( ) { val onKeyClick: (String) -> Unit = { key -> val currentActivePassword = when { - isVerifyOldPasswordMode -> passwordState isConfirmationMode -> confirmPasswordState else -> passwordState } val updatePassword: (String) -> Unit = when { - isVerifyOldPasswordMode -> { newPass -> passwordState = newPass } isConfirmationMode -> { newPass -> confirmPasswordState = newPass } else -> { newPass -> passwordState = newPass } } @@ -566,23 +469,11 @@ fun SetPasswordScreen( } showMismatchError = false showLengthError = false - showInvalidOldPasswordError = false } "proceed" -> { if (currentActivePassword.length >= minLength) { when { - isVerifyOldPasswordMode -> { - if (appLockRepository!!.validatePassword(passwordState)) { - isVerifyOldPasswordMode = false - passwordState = "" - showInvalidOldPasswordError = false - } else { - showInvalidOldPasswordError = true - passwordState = "" - } - } - !isConfirmationMode -> { isConfirmationMode = true showLengthError = false @@ -592,22 +483,14 @@ fun SetPasswordScreen( if (passwordState == confirmPasswordState) { appLockRepository?.setLockType(PreferencesRepository.LOCK_TYPE_PIN) appLockRepository?.setPassword(passwordState) + generatedRecoveryKey = RecoveryKeyManager.generateRecoveryKey() + appLockRepository?.setRecoveryKey(generatedRecoveryKey) 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 - } - } - } + showRecoveryDialog = true } else { showMismatchError = true confirmPasswordState = "" @@ -651,7 +534,7 @@ fun SetPasswordScreen( icons = listOf( Backspace, null, - if (isConfirmationMode || isVerifyOldPasswordMode) Icons.Default.Check else Icons.AutoMirrored.Rounded.KeyboardArrowRight + if (isConfirmationMode) Icons.Default.Check else Icons.AutoMirrored.Rounded.KeyboardArrowRight ), onKeyClick = onKeyClick, buttonSize = buttonSize, diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordTextScreen.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordTextScreen.kt new file mode 100644 index 0000000..83be728 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/SetPasswordTextScreen.kt @@ -0,0 +1,170 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +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.Arrangement +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.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.PasswordVisualTransformation +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.RecoveryKeyManager +import dev.pranav.applock.data.repository.PreferencesRepository + +private fun isStrongPassword(password: String): Boolean { + return password.length >= 8 && + password.any { it.isUpperCase() } && + password.any { it.isLowerCase() } && + password.any { it.isDigit() } && + password.any { !it.isLetterOrDigit() } +} + +@Composable +fun SetPasswordTextScreen(navController: NavController, isFirstTimeSetup: Boolean) { + val context = LocalContext.current + val activity = LocalActivity.current as? ComponentActivity + val repo = remember { (context.applicationContext as? AppLockApplication)?.appLockRepository } + + var newPassword by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var showRecoveryDialog by remember { mutableStateOf(false) } + + 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) + // Reset current password requirement or navigate directly + // In this context, we just allow them to set a new password without current one + } + }) + biometricPrompt.authenticate(promptInfo) + } + + BackHandler { + if (isFirstTimeSetup) { + Toast.makeText(context, R.string.set_pin_to_continue_toast, Toast.LENGTH_SHORT).show() + } else if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + activity?.finish() + } + } + + if (showRecoveryDialog) { + RecoveryKeyGeneratedDialog( + recoveryKey = repo?.getRecoveryKey().orEmpty(), + onDismiss = { + showRecoveryDialog = false + navController.navigate(Screen.Main.route) { + popUpTo(Screen.SetPasswordText.route) { inclusive = true } + if (isFirstTimeSetup) { + popUpTo(Screen.AppIntro.route) { inclusive = true } + } + } + } + ) + } + + Scaffold(topBar = { TopAppBar(title = { Text(if (isFirstTimeSetup) "Set Password" else "Change Password") }) }) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Password must contain at least 8 characters, 1 uppercase, 1 lowercase, 1 number and 1 symbol.") + + OutlinedTextField( + value = newPassword, + onValueChange = { newPassword = it; error = null }, + label = { Text("New password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + OutlinedTextField( + value = confirmPassword, + onValueChange = { confirmPassword = it; error = null }, + label = { Text("Confirm password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Spacer(Modifier.height(8.dp)) + Button(onClick = { + when { + !isStrongPassword(newPassword) -> error = "Password does not meet complexity requirements" + newPassword != confirmPassword -> error = "Passwords do not match" + else -> { + repo?.setLockType(PreferencesRepository.LOCK_TYPE_PASSWORD) + repo?.setPassword(newPassword) + val recoveryKey = RecoveryKeyManager.generateRecoveryKey() + repo?.setRecoveryKey(recoveryKey) + showRecoveryDialog = true + } + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Save password") + } + + if (!isFirstTimeSetup) { + TextButton(onClick = { launchDeviceCredentialAuth() }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.reset_using_device_password_button)) + } + } + + TextButton(onClick = { + navController.navigate(if (isFirstTimeSetup) Screen.SetPassword.route else Screen.ChangePasswordPin.route) + }) { Text("Use PIN") } + TextButton(onClick = { + navController.navigate(if (isFirstTimeSetup) Screen.SetPasswordPattern.route else Screen.ChangePasswordPattern.route) + }) { Text("Use Pattern") } + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt b/app/src/main/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt index 01843f1..2c4220f 100644 --- a/app/src/main/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt @@ -1,27 +1,35 @@ package dev.pranav.applock.features.settings.ui +import android.Manifest +import android.annotation.SuppressLint import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.compose.animation.* import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.BugReport -import androidx.compose.material.icons.outlined.Code import androidx.compose.material.icons.outlined.Security import androidx.compose.material3.* import androidx.compose.runtime.* @@ -31,63 +39,109 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import dev.pranav.applock.R import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen +import dev.pranav.applock.core.utils.BiometricStatus import dev.pranav.applock.core.utils.LogUtils +import dev.pranav.applock.core.utils.getBiometricStatus import dev.pranav.applock.core.utils.hasUsagePermission import dev.pranav.applock.core.utils.isAccessibilityServiceEnabled import dev.pranav.applock.core.utils.openAccessibilitySettings import dev.pranav.applock.data.repository.AppLockRepository +import dev.pranav.applock.data.repository.AppThemeMode import dev.pranav.applock.data.repository.BackendImplementation +import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.admin.AdminDisableActivity import dev.pranav.applock.services.ExperimentalAppLockService import dev.pranav.applock.services.ShizukuAppLockService -import dev.pranav.applock.ui.components.DonateButton import dev.pranav.applock.ui.icons.* import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuProvider +import java.io.File import kotlin.math.abs +@SuppressLint("LocalContextGetResourceValueCall") @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( navController: NavController ) { val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current val appLockRepository = remember { AppLockRepository(context) } - var showDialog by remember { mutableStateOf(false) } + // Pre-fetch strings to avoid LocalContext.current resource querying in lambdas + val shizukuPermissionGrantedMsg = stringResource(R.string.settings_screen_shizuku_permission_granted) + val shizukuPermissionRequiredMsg = stringResource(R.string.settings_screen_shizuku_permission_required_desc) + val deviceAdminExplanation = stringResource(R.string.main_screen_device_admin_explanation) + val exportLogsError = stringResource(R.string.settings_screen_export_logs_error) + val shizukuNotRunningMsg = stringResource(R.string.settings_screen_shizuku_not_running_toast) + val usagePermissionMsg = stringResource(R.string.settings_screen_usage_permission_toast) + var showUnlockTimeDialog by remember { mutableStateOf(false) } val shizukuPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { - Toast.makeText( - context, - context.getString(R.string.settings_screen_shizuku_permission_granted), - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, shizukuPermissionGrantedMsg, Toast.LENGTH_SHORT).show() } else { - Toast.makeText( - context, - context.getString(R.string.settings_screen_shizuku_permission_required_desc), - Toast.LENGTH_SHORT - ).show() + Toast.makeText(context, shizukuPermissionRequiredMsg, Toast.LENGTH_SHORT).show() } } + // Intruder Selfie states + var intruderSelfieEnabled by remember { mutableStateOf(appLockRepository.isIntruderSelfieEnabled()) } + var intruderSelfieAttempts by remember { mutableIntStateOf(appLockRepository.getIntruderSelfieAttempts()) } + var hasSelfies by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val selfieDir = File(context.filesDir, "intruder_selfies") + hasSelfies = selfieDir.exists() && (selfieDir.listFiles()?.isNotEmpty() ?: false) + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + intruderSelfieEnabled = true + appLockRepository.setIntruderSelfieEnabled(true) + } else { + Toast.makeText(context, "Camera permission is required for Intruder Selfie", Toast.LENGTH_SHORT).show() + } + } + + // Reactive states from repository flows + val amoledModeEnabled by appLockRepository.amoledModeFlow() + .collectAsState(initial = appLockRepository.isAmoledModeEnabled()) + val dynamicColorEnabled by appLockRepository.dynamicColorFlow() + .collectAsState(initial = appLockRepository.isDynamicColorEnabled()) + val appThemeMode by appLockRepository.appThemeModeFlow() + .collectAsState(initial = appLockRepository.getAppThemeMode()) + var autoUnlock by remember { mutableStateOf(appLockRepository.isAutoUnlockEnabled()) } var useMaxBrightness by remember { mutableStateOf(appLockRepository.shouldUseMaxBrightness()) } var useBiometricAuth by remember { mutableStateOf(appLockRepository.isBiometricAuthEnabled()) } - var unlockTimeDuration by remember { mutableIntStateOf(appLockRepository.getUnlockTimeDuration()) } + var unlockTimeDuration by remember { mutableStateOf(appLockRepository.getUnlockTimeDuration()) } var antiUninstallEnabled by remember { mutableStateOf(appLockRepository.isAntiUninstallEnabled()) } + + var antiUninstallAdminSettings by remember { mutableStateOf(appLockRepository.isAntiUninstallAdminSettingsEnabled()) } + var antiUninstallUsageStats by remember { mutableStateOf(appLockRepository.isAntiUninstallUsageStatsEnabled()) } + var antiUninstallAccessibility by remember { mutableStateOf(appLockRepository.isAntiUninstallAccessibilityEnabled()) } + var antiUninstallOverlay by remember { mutableStateOf(appLockRepository.isAntiUninstallOverlayEnabled()) } + var preventAllAppUninstall by remember { mutableStateOf(appLockRepository.isPreventAllAppUninstallEnabled()) } + var disableHapticFeedback by remember { mutableStateOf(appLockRepository.shouldDisableHaptics()) } var loggingEnabled by remember { mutableStateOf(appLockRepository.isLoggingEnabled()) } @@ -95,7 +149,74 @@ fun SettingsScreen( var showDeviceAdminDialog by remember { mutableStateOf(false) } var showAccessibilityDialog by remember { mutableStateOf(false) } + // Sync other states with repository on resume (non-flow states) + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + autoUnlock = appLockRepository.isAutoUnlockEnabled() + useMaxBrightness = appLockRepository.shouldUseMaxBrightness() + useBiometricAuth = appLockRepository.isBiometricAuthEnabled() + unlockTimeDuration = appLockRepository.getUnlockTimeDuration() + antiUninstallEnabled = appLockRepository.isAntiUninstallEnabled() + antiUninstallAdminSettings = appLockRepository.isAntiUninstallAdminSettingsEnabled() + antiUninstallUsageStats = appLockRepository.isAntiUninstallUsageStatsEnabled() + antiUninstallAccessibility = appLockRepository.isAntiUninstallAccessibilityEnabled() + antiUninstallOverlay = appLockRepository.isAntiUninstallOverlayEnabled() + preventAllAppUninstall = appLockRepository.isPreventAllAppUninstallEnabled() + disableHapticFeedback = appLockRepository.shouldDisableHaptics() + loggingEnabled = appLockRepository.isLoggingEnabled() + intruderSelfieEnabled = appLockRepository.isIntruderSelfieEnabled() + intruderSelfieAttempts = appLockRepository.getIntruderSelfieAttempts() + + val selfieDir = File(context.filesDir, "intruder_selfies") + hasSelfies = selfieDir.exists() && (selfieDir.listFiles()?.isNotEmpty() ?: false) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + val biometricManager = remember { BiometricManager.from(context) } + var isSettingsAuthorized by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + when (val biometricStatus = getBiometricStatus(context)) { + BiometricStatus.Available -> { + val activity = context as? androidx.fragment.app.FragmentActivity ?: return@LaunchedEffect + val executor = ContextCompat.getMainExecutor(context) + val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + isSettingsAuthorized = true + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + if (!isSettingsAuthorized) navController.popBackStack() + } + }) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Settings") + .setSubtitle("Biometric authentication required") + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setNegativeButtonText("Cancel") + .build() + prompt.authenticate(promptInfo) + } + is BiometricStatus.Unavailable -> { + Toast.makeText(context, biometricStatus.message, Toast.LENGTH_LONG).show() + navController.popBackStack() + } + } + } + + if (!isSettingsAuthorized) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Authenticate with biometrics to open Settings") + } + return + } + val isBiometricAvailable = remember { biometricManager.canAuthenticate( BiometricManager.Authenticators.BIOMETRIC_WEAK or @@ -103,33 +224,18 @@ fun SettingsScreen( ) == BiometricManager.BIOMETRIC_SUCCESS } - if (showDialog) { - AlertDialog( - onDismissRequest = { showDialog = false }, - title = { Text(stringResource(R.string.settings_screen_support_development_dialog_title)) }, - text = { Text(stringResource(R.string.support_development_text)) }, - confirmButton = { - FilledTonalButton( - onClick = { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - "https://pranavpurwar.github.io/donate.html".toUri() - ) - ) - showDialog = false - } - ) { - Text(stringResource(R.string.settings_screen_support_development_donate_button)) - } - }, - dismissButton = { - TextButton(onClick = { showDialog = false }) { - Text(stringResource(R.string.cancel_button)) - } - }, - containerColor = MaterialTheme.colorScheme.surface - ) + // Effect to handle anti-uninstall settings reset when disabled + LaunchedEffect(antiUninstallEnabled) { + if (!antiUninstallEnabled) { + antiUninstallAdminSettings = false + antiUninstallUsageStats = false + antiUninstallAccessibility = false + antiUninstallOverlay = false + appLockRepository.setAntiUninstallAdminSettingsEnabled(false) + appLockRepository.setAntiUninstallUsageStatsEnabled(false) + appLockRepository.setAntiUninstallAccessibilityEnabled(false) + appLockRepository.setAntiUninstallOverlayEnabled(false) + } } if (showUnlockTimeDialog) { @@ -164,7 +270,7 @@ fun SettingsScreen( putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component) putExtra( DevicePolicyManager.EXTRA_ADD_EXPLANATION, - context.getString(R.string.main_screen_device_admin_explanation) + deviceAdminExplanation ) } context.startActivity(intent) @@ -205,250 +311,395 @@ fun SettingsScreen( ) } }, - scrollBehavior = scrollBehavior, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - scrolledContainerColor = MaterialTheme.colorScheme.surface - ) + scrollBehavior = scrollBehavior ) - }, - containerColor = MaterialTheme.colorScheme.surface - ) { innerPadding -> + } + ) { padding -> LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding() + 24.dp - ), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier + .fillMaxSize() + .padding(padding) ) { item { - val packageInfo = remember { - try { - context.packageManager.getPackageInfo(context.packageName, 0) - } catch (_: Exception) { - null - } + SectionTitle(text = stringResource(R.string.settings_screen_security_title)) + + SettingsCard(index = 0, listSize = 3) { + SwitchItem( + title = stringResource(R.string.settings_screen_auto_unlock_title), + summary = stringResource(R.string.settings_screen_auto_unlock_desc), + icon = Icons.Default.LockOpen, + checked = autoUnlock, + onCheckedChange = { + autoUnlock = it + appLockRepository.setAutoUnlockEnabled(it) + } + ) } - val versionName = packageInfo?.versionName ?: "Unknown" - Text( - text = stringResource(R.string.settings_screen_version_template, versionName), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) - ) - } - item { - DonateButton() - } + SettingsCard(index = 1, listSize = 3) { + SwitchItem( + title = stringResource(R.string.settings_screen_biometric_auth_title), + summary = stringResource(R.string.settings_screen_biometric_auth_desc), + icon = Icons.Default.Fingerprint, + checked = useBiometricAuth, + enabled = isBiometricAvailable, + onCheckedChange = { + useBiometricAuth = it + appLockRepository.setBiometricAuthEnabled(it) + } + ) + } - item { - SectionTitle(text = stringResource(R.string.settings_screen_lock_screen_customization_title)) - } + SettingsCard(index = 2, listSize = 4) { + ClickableItem( + title = "Change Lock", + summary = when (appLockRepository.getLockType()) { + PreferencesRepository.LOCK_TYPE_PATTERN -> "Update your unlock pattern" + PreferencesRepository.LOCK_TYPE_PASSWORD -> "Update your unlock password" + else -> "Update your unlock PIN" + }, + icon = Icons.Default.Lock, + onClick = { + navController.navigate(Screen.ChangePassword.route) + } + ) + } - item { - SettingsGroup( - items = listOf( - ToggleSettingItem( - icon = BrightnessHigh, - title = stringResource(R.string.settings_screen_max_brightness_title), - subtitle = stringResource(R.string.settings_screen_max_brightness_desc), - checked = useMaxBrightness, - enabled = true, - onCheckedChange = { isChecked -> - useMaxBrightness = isChecked - appLockRepository.setUseMaxBrightness(isChecked) - } - ), - ToggleSettingItem( - icon = if (useBiometricAuth) Fingerprint else FingerprintOff, - title = stringResource(R.string.settings_screen_biometric_auth_title), - subtitle = if (isBiometricAvailable) - stringResource(R.string.settings_screen_biometric_auth_desc_available) - else - stringResource(R.string.settings_screen_biometric_auth_desc_unavailable), - checked = useBiometricAuth && isBiometricAvailable, - enabled = isBiometricAvailable, - onCheckedChange = { isChecked -> - useBiometricAuth = isChecked - appLockRepository.setBiometricAuthEnabled(isChecked) - } - ), - ToggleSettingItem( - icon = Icons.Default.Vibration, - title = stringResource(R.string.settings_screen_haptic_feedback_title), - subtitle = stringResource(R.string.settings_screen_haptic_feedback_desc), - checked = disableHapticFeedback, - enabled = true, - onCheckedChange = { isChecked -> - disableHapticFeedback = isChecked - appLockRepository.setDisableHaptics(isChecked) - } - ), - ToggleSettingItem( - icon = Icons.Default.ShieldMoon, - title = stringResource(R.string.settings_screen_auto_unlock_title), - subtitle = stringResource(R.string.settings_screen_auto_unlock_desc), - checked = autoUnlock, - enabled = true, - onCheckedChange = { isChecked -> - autoUnlock = isChecked - appLockRepository.setAutoUnlockEnabled(isChecked) - } - ) + SettingsCard(index = 3, listSize = 4) { + SwitchItem( + title = "Prevent all app uninstall", + summary = "Require protection before any app uninstall attempt", + icon = Icons.Outlined.Security, + checked = preventAllAppUninstall, + onCheckedChange = { + preventAllAppUninstall = it + appLockRepository.setPreventAllAppUninstallEnabled(it) + } ) - ) - } + } - item { - SectionTitle(text = stringResource(R.string.settings_screen_security_title)) - } + SectionTitle(text = "Intruder Selfie") - item { - SettingsGroup( - items = listOf( - ActionSettingItem( - icon = Icons.Default.Lock, - title = stringResource(R.string.settings_screen_change_pin_title), - subtitle = stringResource(R.string.settings_screen_change_pin_desc), - onClick = { navController.navigate(Screen.ChangePassword.route) } - ), - ActionSettingItem( - icon = Timer, - title = stringResource(R.string.settings_screen_unlock_duration_title), - subtitle = if (unlockTimeDuration > 0) { - if (unlockTimeDuration > 10_000) "Until screen off" - else stringResource( - R.string.settings_screen_unlock_duration_summary_minutes, - unlockTimeDuration - ) - } else stringResource(R.string.settings_screen_unlock_duration_summary_immediate), - onClick = { showUnlockTimeDialog = true } - ), - ToggleSettingItem( - icon = Icons.Default.Lock, - title = stringResource(R.string.settings_screen_anti_uninstall_title), - subtitle = stringResource(R.string.settings_screen_anti_uninstall_desc), - checked = antiUninstallEnabled, - enabled = true, - onCheckedChange = { isChecked -> - if (isChecked) { - val dpm = - context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - val component = ComponentName(context, DeviceAdmin::class.java) - val hasDeviceAdmin = dpm.isAdminActive(component) - val hasAccessibility = context.isAccessibilityServiceEnabled() - - when { - !hasDeviceAdmin && !hasAccessibility -> { - showPermissionDialog = true - } - !hasDeviceAdmin -> { - showDeviceAdminDialog = true - } - !hasAccessibility -> { - showAccessibilityDialog = true - } - else -> { - antiUninstallEnabled = true - appLockRepository.setAntiUninstallEnabled(true) - } - } + SettingsCard(index = 0, listSize = 3) { + SwitchItem( + title = "Enable Intruder Selfie", + summary = "Capture a photo after failed unlock attempts", + icon = Icons.Default.CameraAlt, + checked = intruderSelfieEnabled, + onCheckedChange = { enabled -> + if (enabled) { + val permissionCheck = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + if (permissionCheck == PackageManager.PERMISSION_GRANTED) { + intruderSelfieEnabled = true + appLockRepository.setIntruderSelfieEnabled(true) } else { - context.startActivity( - Intent(context, AdminDisableActivity::class.java) - ) + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } + } else { + intruderSelfieEnabled = false + appLockRepository.setIntruderSelfieEnabled(false) } - ) + } ) - ) - } + } - item { - SectionTitle(text = stringResource(R.string.settings_screen_advanced_title)) - } + SettingsCard(index = 1, listSize = 3) { + FailedAttemptsSpinnerItem( + selectedAttempts = intruderSelfieAttempts, + enabled = intruderSelfieEnabled, + onAttemptsSelected = { attempts -> + intruderSelfieAttempts = attempts + appLockRepository.setIntruderSelfieAttempts(attempts) + } + ) + } - item { - SettingsGroup( - items = listOf( - ActionSettingItem( - icon = Icons.Outlined.Security, - title = stringResource(R.string.settings_Screen_export_audit), - subtitle = stringResource(R.string.settings_screen_export_audit_desc), - onClick = { - val uri = LogUtils.exportAuditLogs() - if (uri != null) { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser(shareIntent, "Share audit logs") - ) - } else { - Toast.makeText( - context, - context.getString(R.string.settings_screen_export_logs_error), - Toast.LENGTH_SHORT - ).show() + SettingsCard(index = 2, listSize = 3) { + ClickableItem( + title = "Show Intruder Selfies", + summary = "View captured photos of intruders", + icon = Icons.Default.PhotoLibrary, + enabled = hasSelfies, + onClick = { + navController.navigate(Screen.IntruderSelfies.route) + } + ) + } + + SectionTitle(text = stringResource(R.string.settings_screen_anti_uninstall_title)) + + SettingsCard(index = 0, listSize = 5) { + SwitchItem( + title = stringResource(R.string.settings_screen_anti_uninstall_title), + summary = stringResource(R.string.settings_screen_anti_uninstall_desc), + icon = Icons.Outlined.Security, + checked = antiUninstallEnabled, + onCheckedChange = { + antiUninstallEnabled = it + appLockRepository.setAntiUninstallEnabled(it) + } + ) + } + + SettingsCard(index = 1, listSize = 5) { + SwitchItem( + title = stringResource(R.string.permission_warning_device_admin_title), + summary = stringResource(R.string.permission_warning_device_admin_desc), + icon = Icons.Default.AdminPanelSettings, + checked = antiUninstallAdminSettings, + enabled = antiUninstallEnabled, + onCheckedChange = { + if (it) { + val dpm = + context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val component = ComponentName(context, DeviceAdmin::class.java) + if (!dpm.isAdminActive(component)) { + showPermissionDialog = true + return@SwitchItem } } - ), - ActionSettingItem( - icon = Icons.Outlined.BugReport, - title = stringResource(R.string.settings_screen_export_logs_title), - subtitle = stringResource(R.string.settings_screen_export_logs_desc), - onClick = { - val uri = LogUtils.exportLogs() - if (uri != null) { - val shareIntent = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - context.startActivity( - Intent.createChooser(shareIntent, "Share logs") - ) - } else { - Toast.makeText( - context, - context.getString(R.string.settings_screen_export_logs_error), - Toast.LENGTH_SHORT - ).show() - } + antiUninstallAdminSettings = it + appLockRepository.setAntiUninstallAdminSettingsEnabled(it) + } + ) + } + + SettingsCard(index = 2, listSize = 5) { + SwitchItem( + title = stringResource(R.string.permission_warning_usage_stats_title), + summary = stringResource(R.string.permission_warning_usage_stats_desc), + icon = Icons.Default.QueryStats, + checked = antiUninstallUsageStats, + enabled = antiUninstallEnabled, + onCheckedChange = { + if (it && !context.hasUsagePermission()) { + val intent = + Intent(android.provider.Settings.ACTION_USAGE_ACCESS_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + return@SwitchItem } - ), - ToggleSettingItem( - icon = Icons.Default.Troubleshoot, - title = "Logging", - subtitle = "Enable debug logging for troubleshooting", - checked = loggingEnabled, - enabled = true, - onCheckedChange = { isChecked -> - loggingEnabled = isChecked - appLockRepository.setLoggingEnabled(isChecked) - LogUtils.setLoggingEnabled(isChecked) + antiUninstallUsageStats = it + appLockRepository.setAntiUninstallUsageStatsEnabled(it) + } + ) + } + + SettingsCard(index = 3, listSize = 5) { + SwitchItem( + title = stringResource(R.string.permission_warning_accessibility_title), + summary = stringResource(R.string.permission_warning_accessibility_desc), + icon = Icons.Default.Accessibility, + checked = antiUninstallAccessibility, + enabled = antiUninstallEnabled, + onCheckedChange = { + if (it && !context.isAccessibilityServiceEnabled()) { + showAccessibilityDialog = true + return@SwitchItem } - ) + antiUninstallAccessibility = it + appLockRepository.setAntiUninstallAccessibilityEnabled(it) + } + ) + } + + SettingsCard(index = 4, listSize = 5) { + SwitchItem( + title = stringResource(R.string.permission_warning_overlay_title), + summary = stringResource(R.string.permission_warning_overlay_desc), + icon = Icons.Default.Layers, + checked = antiUninstallOverlay, + enabled = antiUninstallEnabled, + onCheckedChange = { + antiUninstallOverlay = it + appLockRepository.setAntiUninstallOverlayEnabled(it) + } ) + } + + SectionTitle(text = stringResource(R.string.settings_screen_lock_screen_customization_title)) + + SettingsCard(index = 0, listSize = 5) { + SwitchItem( + title = stringResource(R.string.settings_screen_max_brightness_title), + summary = stringResource(R.string.settings_screen_max_brightness_desc), + icon = Icons.Default.BrightnessHigh, + checked = useMaxBrightness, + onCheckedChange = { + useMaxBrightness = it + appLockRepository.setUseMaxBrightness(it) + } + ) + } + + SettingsCard(index = 1, listSize = 5) { + SwitchItem( + title = stringResource(R.string.settings_screen_amoled_mode_title), + summary = stringResource(R.string.settings_screen_amoled_mode_desc), + icon = Icons.Default.DarkMode, + checked = amoledModeEnabled, + onCheckedChange = { + appLockRepository.setAmoledModeEnabled(it) + } + ) + } + + SettingsCard(index = 2, listSize = 5) { + SwitchItem( + title = "Use Dynamic Theme", + summary = "Apply dynamic colors from your wallpaper (Android 12+)", + icon = Icons.Default.Palette, + checked = dynamicColorEnabled, + enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + onCheckedChange = { + appLockRepository.setDynamicColorEnabled(it) + } + ) + } + + SettingsCard(index = 3, listSize = 5) { + SwitchItem( + title = stringResource(R.string.settings_screen_haptic_feedback_title), + summary = stringResource(R.string.settings_screen_haptic_feedback_desc), + icon = Icons.Default.Vibration, + checked = disableHapticFeedback, + onCheckedChange = { + disableHapticFeedback = it + appLockRepository.setDisableHaptics(it) + } + ) + } + + SettingsCard(index = 4, listSize = 5) { + ClickableItem( + title = stringResource(R.string.settings_screen_unlock_duration_title), + summary = when (unlockTimeDuration) { + 0 -> stringResource(R.string.settings_screen_unlock_duration_dialog_option_immediate) + 1 -> stringResource( + R.string.settings_screen_unlock_duration_dialog_option_minute, + unlockTimeDuration + ) + 60 -> stringResource(R.string.settings_screen_unlock_duration_dialog_option_hour) + Integer.MAX_VALUE -> "Until Screen Off" + else -> stringResource( + R.string.settings_screen_unlock_duration_summary_minutes, + unlockTimeDuration + ) + }, + icon = Icons.Default.Timer, + onClick = { showUnlockTimeDialog = true } + ) + } + + SectionTitle(text = stringResource(R.string.settings_screen_app_theme_title)) + AppThemeModeCard( + selectedThemeMode = appThemeMode, + onThemeModeSelected = { + appLockRepository.setAppThemeMode(it) + } ) - } - item { BackendSelectionCard( appLockRepository = appLockRepository, context = context, - shizukuPermissionLauncher = shizukuPermissionLauncher + shizukuPermissionLauncher = shizukuPermissionLauncher, + shizukuNotRunningMsg = shizukuNotRunningMsg, + usagePermissionMsg = usagePermissionMsg ) - } - item { - LinksSection() + SectionTitle(text = "App Details") + + SettingsCard(index = 0, listSize = 3) { + ClickableItem( + title = "App Version", + summary = try { + context.packageManager.getPackageInfo( + context.packageName, + 0 + ).versionName ?: "Unknown" + } catch (e: Exception) { + "Unknown" + }, + icon = Icons.Default.Info, + onClick = {} + ) + } + + SettingsCard(index = 1, listSize = 3) { + SwitchItem( + title = stringResource(R.string.settings_screen_logging_title), + summary = stringResource(R.string.settings_screen_logging_summary), + icon = Icons.Outlined.BugReport, + checked = loggingEnabled, + onCheckedChange = { + loggingEnabled = it + appLockRepository.setLoggingEnabled(it) + } + ) + } + + SettingsCard(index = 2, listSize = 3) { + ClickableItem( + title = "Export Logs", + summary = "Save app logs to your Downloads folder", + icon = Icons.Default.Download, + onClick = { + val appName = context.getString(R.string.app_name) + val exportedFile = LogUtils.exportLogsToDownloads(appName) + if (exportedFile != null) { + Toast.makeText(context, "Logs exported: $exportedFile", Toast.LENGTH_LONG).show() + } else { + Toast.makeText(context, exportLogsError, Toast.LENGTH_SHORT).show() + } + } + ) + } + + SectionTitle(text = stringResource(R.string.settings_screen_links_section)) + + SettingsCard(index = 0, listSize = 3) { + LinkItem( + title = stringResource(R.string.settings_screen_developer_email_ap), + icon = Icons.Default.Email, + onClick = { + val intent = Intent( + Intent.ACTION_SENDTO, + "mailto:ap0803apap@gmail.com".toUri() + ) + context.startActivity(intent) + } + ) + } + + SettingsCard(index = 1, listSize = 3) { + LinkItem( + title = stringResource(R.string.settings_screen_source_code), + icon = Icons.Default.Code, + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + "https://github.com/ap0803apap-sketch".toUri() + ) + context.startActivity(intent) + } + ) + } + + SettingsCard(index = 2, listSize = 3) { + LinkItem( + title = stringResource(R.string.settings_screen_report_issue), + icon = Icons.Default.BugReport, + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + "https://github.com/ap0803apap-sketch".toUri() + ) + context.startActivity(intent) + } + ) + } + + Spacer(modifier = Modifier.height(24.dp)) } } } @@ -458,80 +709,12 @@ fun SettingsScreen( fun SectionTitle(text: String) { Text( text = text, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, bottom = 4.dp, top = 4.dp) + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) ) } -sealed class SettingItemType { - data class Toggle( - val icon: ImageVector, - val title: String, - val subtitle: String, - val checked: Boolean, - val enabled: Boolean, - val onCheckedChange: (Boolean) -> Unit - ): SettingItemType() - - data class Action( - val icon: ImageVector, - val title: String, - val subtitle: String, - val onClick: () -> Unit - ): SettingItemType() -} - -data class ToggleSettingItem( - val icon: ImageVector, - val title: String, - val subtitle: String, - val checked: Boolean, - val enabled: Boolean, - val onCheckedChange: (Boolean) -> Unit -) - -data class ActionSettingItem( - val icon: ImageVector, - val title: String, - val subtitle: String, - val onClick: () -> Unit -) - -@Composable -fun SettingsGroup( - items: List -) { - Column { - items.forEachIndexed { index, item -> - SettingsCard(index = index, listSize = items.size) { - when (item) { - is ToggleSettingItem -> { - ToggleSettingRow( - icon = item.icon, - title = item.title, - subtitle = item.subtitle, - checked = item.checked, - enabled = item.enabled, - onCheckedChange = item.onCheckedChange - ) - } - - is ActionSettingItem -> { - ActionSettingRow( - icon = item.icon, - title = item.title, - subtitle = item.subtitle, - onClick = item.onClick - ) - } - } - } - } - } -} - @Composable fun SettingsCard( index: Int, @@ -540,93 +723,67 @@ fun SettingsCard( ) { val shape = when { listSize == 1 -> RoundedCornerShape(24.dp) - index == 0 -> RoundedCornerShape( - topStart = 24.dp, - topEnd = 24.dp, - bottomStart = 6.dp, - bottomEnd = 6.dp - ) - - index == listSize - 1 -> RoundedCornerShape( - topStart = 6.dp, - topEnd = 6.dp, - bottomStart = 24.dp, - bottomEnd = 24.dp - ) - - else -> RoundedCornerShape(6.dp) + index == 0 -> RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 4.dp, bottomEnd = 4.dp) + index == listSize - 1 -> RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp, bottomStart = 24.dp, bottomEnd = 24.dp) + else -> RoundedCornerShape(4.dp) } - AnimatedVisibility( - visible = true, - enter = fadeIn() + scaleIn( - initialScale = 0.95f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy) - ), - exit = fadeOut() + shrinkVertically() + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 2.dp), + shape = shape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 1.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ), - shape = shape - ) { - content() - } + content() } } @Composable -fun ToggleSettingRow( - icon: ImageVector, +fun SwitchItem( title: String, - subtitle: String, + summary: String, + icon: ImageVector, checked: Boolean, - enabled: Boolean, + enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit ) { ListItem( - modifier = Modifier - .clickable(enabled = enabled) { if (enabled) onCheckedChange(!checked) } - .padding(vertical = 2.dp, horizontal = 4.dp), + modifier = Modifier.toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange + ), headlineContent = { Text( text = title, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) }, supportingContent = { Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall + text = summary, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) ) }, leadingContent = { - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.secondary - ) - } + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) + ) }, trailingContent = { - Box( - contentAlignment = Alignment.Center - ) { - Switch( - checked = checked, - onCheckedChange = null, - enabled = enabled - ) - } + Switch( + checked = checked, + onCheckedChange = null, + enabled = enabled + ) }, colors = ListItemDefaults.colors( containerColor = Color.Transparent @@ -635,50 +792,138 @@ fun ToggleSettingRow( } @Composable -fun ActionSettingRow( - icon: ImageVector, +fun ClickableItem( title: String, - subtitle: String, + summary: String, + icon: ImageVector, + enabled: Boolean = true, onClick: () -> Unit ) { ListItem( - modifier = Modifier - .clickable(onClick = onClick) - .padding(vertical = 2.dp, horizontal = 4.dp), + modifier = Modifier.clickable(enabled = enabled, onClick = onClick), headlineContent = { Text( text = title, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) }, supportingContent = { Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall + text = summary, + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) ) }, leadingContent = { - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center - ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) +} + +@Composable +fun FailedAttemptsSpinnerItem( + selectedAttempts: Int, + enabled: Boolean, + onAttemptsSelected: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val attemptsOptions = listOf(1, 2, 3, 4, 5) + + Box { + ListItem( + modifier = Modifier.clickable(enabled = enabled) { expanded = true }, + headlineContent = { + Text( + text = "Failed attempts", + style = MaterialTheme.typography.titleMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + }, + supportingContent = { + Text( + text = "Take photo after $selectedAttempts failed attempt(s)", + style = MaterialTheme.typography.bodySmall, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + }, + leadingContent = { Icon( - imageVector = icon, + imageVector = Icons.Default.Warning, contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.secondary + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.primary.copy(alpha = 0.38f) ) - } - }, - trailingContent = { - Box( - contentAlignment = Alignment.Center - ) { + }, + trailingContent = { Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent + ) + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + attemptsOptions.forEach { option -> + DropdownMenuItem( + text = { Text("$option attempt(s)") }, + onClick = { + onAttemptsSelected(option) + expanded = false + } ) } + } + } +} + +@Composable +fun LinkItem( + title: String, + icon: ImageVector, + onClick: () -> Unit +) { + ListItem( + modifier = Modifier.clickable(onClick = onClick), + headlineContent = { + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) }, colors = ListItemDefaults.colors( containerColor = Color.Transparent @@ -692,30 +937,35 @@ fun UnlockTimeDurationDialog( onDismiss: () -> Unit, onConfirm: (Int) -> Unit ) { - val durations = listOf(0, 1, 5, 15, 30, 60, Integer.MAX_VALUE) + val durations = listOf(0, 1, 5, 10, 30, 60, Integer.MAX_VALUE) var selectedDuration by remember { mutableIntStateOf(currentDuration) } - if (!durations.contains(selectedDuration)) { - selectedDuration = durations.minByOrNull { abs(it - currentDuration) } ?: 0 - } - AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.settings_screen_unlock_duration_dialog_title)) }, text = { - Column { - Text(stringResource(R.string.settings_screen_unlock_duration_dialog_description_new)) + val durationsScrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(durationsScrollState) + .selectableGroup() + ) { durations.forEach { duration -> Row( modifier = Modifier .fillMaxWidth() - .clickable { selectedDuration = duration } - .padding(vertical = 8.dp), + .selectable( + selected = selectedDuration == duration, + onClick = { selectedDuration = duration }, + role = Role.RadioButton + ) + .padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = selectedDuration == duration, - onClick = { selectedDuration = duration } + onClick = null ) Text( text = when (duration) { @@ -754,7 +1004,9 @@ fun UnlockTimeDurationDialog( fun BackendSelectionCard( appLockRepository: AppLockRepository, context: Context, - shizukuPermissionLauncher: androidx.activity.result.ActivityResultLauncher + shizukuPermissionLauncher: androidx.activity.result.ActivityResultLauncher, + shizukuNotRunningMsg: String, + usagePermissionMsg: String ) { var selectedBackend by remember { mutableStateOf(appLockRepository.getBackendImplementation()) } @@ -781,7 +1033,7 @@ fun BackendSelectionCard( } else { Toast.makeText( context, - context.getString(R.string.settings_screen_shizuku_not_running_toast), + shizukuNotRunningMsg, Toast.LENGTH_LONG ).show() } @@ -803,7 +1055,7 @@ fun BackendSelectionCard( context.startActivity(intent) Toast.makeText( context, - context.getString(R.string.settings_screen_usage_permission_toast), + usagePermissionMsg, Toast.LENGTH_LONG ).show() return@BackendSelectionItem @@ -836,11 +1088,11 @@ fun BackendSelectionCard( fun BackendSelectionItem( backend: BackendImplementation, isSelected: Boolean, - onClick: () -> Unit + onClick: (() -> Unit)? ) { ListItem( modifier = Modifier - .clickable { onClick() } + .clickable { onClick?.invoke() } .padding(vertical = 2.dp, horizontal = 4.dp), headlineContent = { Row( @@ -928,6 +1180,51 @@ private fun getBackendIcon(backend: BackendImplementation): ImageVector { } } + +@Composable +fun AppThemeModeCard( + selectedThemeMode: AppThemeMode, + onThemeModeSelected: (AppThemeMode) -> Unit +) { + val modes = listOf( + AppThemeMode.SYSTEM to stringResource(R.string.settings_screen_theme_mode_system), + AppThemeMode.LIGHT to stringResource(R.string.settings_screen_theme_mode_light), + AppThemeMode.DARK to stringResource(R.string.settings_screen_theme_mode_dark) + ) + + SettingsCard(index = 0, listSize = 1) { + Column( + modifier = Modifier + .fillMaxWidth() + .selectableGroup() + ) { + modes.forEach { (mode, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedThemeMode == mode, + onClick = { onThemeModeSelected(mode) }, + role = Role.RadioButton + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedThemeMode == mode, + onClick = null + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } +} + @Composable fun PermissionRequiredDialog( onDismiss: () -> Unit, @@ -965,13 +1262,12 @@ fun DeviceAdminDialog( title = { Text(stringResource(R.string.settings_screen_device_admin_dialog_title)) }, text = { Column { - Text(stringResource(R.string.settings_screen_device_admin_dialog_text_1)) - Text(stringResource(R.string.settings_screen_device_admin_dialog_text_2)) + Text(stringResource(R.string.settings_screen_device_admin_dialog_text)) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text(stringResource(R.string.enable_button)) + Text(stringResource(R.string.grant_permission_button)) } }, dismissButton = { @@ -992,14 +1288,12 @@ fun AccessibilityDialog( title = { Text(stringResource(R.string.settings_screen_accessibility_dialog_title)) }, text = { Column { - Text(stringResource(R.string.settings_screen_accessibility_dialog_text_1)) - Text(stringResource(R.string.settings_screen_accessibility_dialog_text_2)) - Text(stringResource(R.string.settings_screen_accessibility_dialog_text_3)) + Text(stringResource(R.string.settings_screen_accessibility_dialog_text)) } }, confirmButton = { TextButton(onClick = onConfirm) { - Text(stringResource(R.string.enable_button)) + Text(stringResource(R.string.grant_permission_button)) } }, dismissButton = { @@ -1009,101 +1303,3 @@ fun AccessibilityDialog( } ) } - -@Composable -fun LinksSection() { - val context = LocalContext.current - - Column { - SectionTitle(text = "Links") - - Column { - SettingsCard(index = 0, listSize = 3) { - LinkItem( - title = "Discord Community", - icon = Discord, - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - "https://discord.gg/46wCMRVAre".toUri() - ) - context.startActivity(intent) - } - ) - } - - SettingsCard(index = 1, listSize = 3) { - LinkItem( - title = "Source Code", - icon = Icons.Outlined.Code, - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - "https://github.com/aload0/AppLock".toUri() - ) - context.startActivity(intent) - } - ) - } - - SettingsCard(index = 2, listSize = 3) { - LinkItem( - title = "Report Issue", - icon = Icons.Outlined.BugReport, - onClick = { - val intent = Intent( - Intent.ACTION_VIEW, - "https://github.com/aload0/AppLock/issues".toUri() - ) - context.startActivity(intent) - } - ) - } - } - } -} - -@Composable -fun LinkItem( - title: String, - icon: ImageVector, - onClick: () -> Unit -) { - ListItem( - modifier = Modifier - .clickable(onClick = onClick) - .padding(vertical = 2.dp, horizontal = 4.dp), - headlineContent = { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) - }, - leadingContent = { - Box( - modifier = Modifier.size(24.dp), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.secondary - ) - } - }, - trailingContent = { - Box( - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null - ) - } - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent - ) - ) -} diff --git a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt index 5377dba..cc12009 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -3,17 +3,24 @@ package dev.pranav.applock.services import android.accessibilityservice.AccessibilityService import android.accessibilityservice.AccessibilityServiceInfo import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ResolveInfo +import android.content.pm.ServiceInfo +import android.os.Build import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast +import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService +import dev.pranav.applock.R import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.appLockRepository @@ -22,6 +29,9 @@ import dev.pranav.applock.data.repository.AppLockRepository import dev.pranav.applock.data.repository.BackendImplementation import dev.pranav.applock.features.lockscreen.ui.PasswordOverlayActivity import dev.pranav.applock.services.AppLockConstants.ACCESSIBILITY_SETTINGS_CLASSES +import dev.pranav.applock.services.AppLockConstants.DEVICE_ADMIN_SETTINGS_CLASSES +import dev.pranav.applock.services.AppLockConstants.USAGE_ACCESS_SETTINGS_CLASSES +import dev.pranav.applock.services.AppLockConstants.OVERLAY_SETTINGS_CLASSES import dev.pranav.applock.services.AppLockConstants.EXCLUDED_APPS import rikka.shizuku.Shizuku @@ -29,17 +39,21 @@ import rikka.shizuku.Shizuku class AppLockAccessibilityService : AccessibilityService() { private val appLockRepository: AppLockRepository by lazy { applicationContext.appLockRepository() } private val keyboardPackages: List by lazy { getKeyboardPackageNames() } + private var launcherPackages: Set = emptySet() private var recentsOpen = false private var lastForegroundPackage = "" + private val NOTIFICATION_ID = 114 + private val CHANNEL_ID = "AppLockAccessibilityServiceChannel" + private val notificationManager: NotificationManager by lazy { getSystemService(NotificationManager::class.java)!! } + enum class BiometricState { IDLE, AUTH_STARTED } companion object { private const val TAG = "AppLockAccessibility" - private const val DEVICE_ADMIN_SETTINGS_PACKAGE = "com.android.settings" private const val APP_PACKAGE_PREFIX = "dev.pranav.applock" @Volatile @@ -53,7 +67,6 @@ class AppLockAccessibilityService : AccessibilityService() { LogUtils.d(TAG, "Screen off detected. Resetting AppLock state.") AppLockManager.isLockScreenShown.set(false) AppLockManager.clearTemporarilyUnlockedApp() - // Optional: Clear all unlock timestamps to force re-lock on next unlock AppLockManager.appUnlockTimes.clear() } } catch (e: Exception) { @@ -69,6 +82,8 @@ class AppLockAccessibilityService : AccessibilityService() { AppLockManager.currentBiometricState = BiometricState.IDLE AppLockManager.isLockScreenShown.set(false) startPrimaryBackendService() + startForegroundService() + updateLauncherPackages() val filter = android.content.IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) @@ -80,14 +95,16 @@ class AppLockAccessibilityService : AccessibilityService() { } } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForegroundService() + return START_STICKY + } override fun onServiceConnected() { super.onServiceConnected() try { serviceInfo = serviceInfo.apply { eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or - AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED or AccessibilityEvent.TYPE_WINDOWS_CHANGED feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC packageNames = null @@ -96,114 +113,241 @@ class AppLockAccessibilityService : AccessibilityService() { Log.d(TAG, "Accessibility service connected") AppLockManager.resetRestartAttempts(TAG) appLockRepository.setActiveBackend(BackendImplementation.ACCESSIBILITY) + startForegroundService() + updateLauncherPackages() } catch (e: Exception) { logError("Error in onServiceConnected", e) } } + private fun updateLauncherPackages() { + try { + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME) + launcherPackages = packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + .map { it.activityInfo.packageName } + .toSet() + Log.d(TAG, "Launcher packages updated: $launcherPackages") + } catch (e: Exception) { + logError("Error updating launcher packages", e) + } + } + override fun onAccessibilityEvent(event: AccessibilityEvent) { - Log.d(TAG, event.toString()) try { + // Block Recents button if lock screen is active + if (AppLockManager.isLockScreenShown.get()) { + if (isRecentsEvent(event)) { + LogUtils.d(TAG, "Blocking Recents access while lock screen is active. Triggering BACK action to stay on overlay.") + performGlobalAction(GLOBAL_ACTION_BACK) + return + } + } + handleAccessibilityEvent(event) } catch (e: Exception) { logError("Unhandled error in onAccessibilityEvent", e) } } + private fun isRecentsEvent(event: AccessibilityEvent): Boolean { + val packageName = event.packageName?.toString() ?: "" + val className = event.className?.toString() ?: "" + val text = event.text.toString().lowercase() + + if (packageName == applicationContext.packageName) return false + + return className in AppLockConstants.KNOWN_RECENTS_CLASSES || + (packageName == "com.android.systemui" && className.contains("recents", ignoreCase = true)) || + text.contains("recent apps") || + text.contains("overview") + } + private fun handleAccessibilityEvent(event: AccessibilityEvent) { - if (appLockRepository.isAntiUninstallEnabled() && - event.packageName == DEVICE_ADMIN_SETTINGS_PACKAGE - ) { - checkForDeviceAdminDeactivation(event) + if (appLockRepository.isAntiUninstallEnabled()) { + handleAntiUninstallBlocking(event) } - // Early return if protection is disabled or service is not running if (!appLockRepository.isProtectEnabled() || !isServiceRunning) { return } - // Handle window state changes + // Only react to major window state changes to avoid triggering on notifications or keyboards if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { - try { - handleWindowStateChanged(event) - } catch (e: Exception) { - logError("Error handling window state change", e) + val eventPackage = event.packageName?.toString() ?: return + + // 1. Get the current actual active package from the window manager if possible + val activePackageName = rootInActiveWindow?.packageName?.toString() ?: eventPackage + + // 2. Detect if we are on any Launcher/Home screen + if (launcherPackages.contains(eventPackage) || launcherPackages.contains(activePackageName)) { + val unlockedApp = AppLockManager.temporarilyUnlockedApp + if (unlockedApp.isNotEmpty()) { + // Record that we left this app to handle stray exit events via grace period + AppLockManager.setRecentlyLeftApp(unlockedApp) + AppLockManager.clearTemporarilyUnlockedApp() + } + recentsOpen = false + lastForegroundPackage = if (launcherPackages.contains(activePackageName)) activePackageName else eventPackage return } - } - // Skip processing if recents are open - if (recentsOpen) { - LogUtils.d(TAG, "Recents opened, ignoring accessibility event") - return + // 3. Handle Recents and focus transitions + handleWindowStateChanged(event) + + if (recentsOpen) { + return + } + + // 4. Check if it's a valid app package for locking + if (!isValidPackageForLocking(eventPackage)) { + return + } + + // 5. Process locking logic + try { + processPackageLocking(eventPackage) + } catch (e: Exception) { + logError("Error processing package locking for $eventPackage", e) + } } + } - // Extract and validate package name + private fun handleAntiUninstallBlocking(event: AccessibilityEvent) { val packageName = event.packageName?.toString() ?: return - - // Skip if device is locked or app is excluded - if (!isValidPackageForLocking(packageName)) { - return + + val isSettings = packageName.contains("settings") || + packageName.contains("packageinstaller") || + packageName.contains("permissioncontroller") || + packageName == "android" + + if (!isSettings) return + + val className = event.className?.toString() ?: "" + val root = rootInActiveWindow + + if (appLockRepository.isPreventAllAppUninstallEnabled()) { + if (className.contains("Uninstaller") || className.contains("PackageInstaller") || containsTextRecursive(root, "Uninstall")) { + blockAccess("App uninstall is protected.") + return + } } - try { - processPackageLocking(packageName) - } catch (e: Exception) { - logError("Error processing package locking for $packageName", e) + if (root != null && (containsTextRecursive(root, "dev.pranav.applock") || containsTextRecursive(root, "APP Lock by AP"))) { + if (className.contains("AppDetails") || className.contains("InstalledAppDetails") || + className.contains("Uninstaller") || className.contains("PackageInstaller") || + className.contains("Settings\$AppDetailsActivity")) { + blockAccess("APP Lock by AP protection is active.") + return + } } - } - private fun handleWindowStateChanged(event: AccessibilityEvent) { - val isRecentlyOpened = isRecentlyOpened(event) - val isHomeScreen = isHomeScreen(event) + if (appLockRepository.isAntiUninstallAdminSettingsEnabled()) { + if (className in DEVICE_ADMIN_SETTINGS_CLASSES || + className.contains("DeviceAdminSettings") || + className.contains("DeviceAdminAdd")) { + blockAccess("Device Admin settings are protected.") + return + } + if (root != null && (containsTextRecursive(root, "Device admin") || containsTextRecursive(root, "Device administrator"))) { + if (className.contains("SubSettings") || className.contains("SettingsActivity")) { + blockAccess("Device Admin settings are protected.") + return + } + } + } - when { - isRecentlyOpened -> { - LogUtils.d(TAG, "Entering recents") - recentsOpen = true + if (appLockRepository.isAntiUninstallUsageStatsEnabled()) { + if (className in USAGE_ACCESS_SETTINGS_CLASSES || + className.contains("UsageAccessSettings") || + className.contains("UsageStats")) { + blockAccess("Usage access settings are protected.") + return + } + if (root != null && (containsTextRecursive(root, "Usage access") || containsTextRecursive(root, "Usage stats"))) { + if (className.contains("SubSettings") || className.contains("SettingsActivity")) { + blockAccess("Usage access settings are protected.") + return + } } + } - isHomeScreenTransition(event) && recentsOpen -> { - LogUtils.d(TAG, "Transitioning to home screen from recents") - recentsOpen = false - clearTemporarilyUnlockedAppIfNeeded() + if (appLockRepository.isAntiUninstallAccessibilityEnabled()) { + if (className in ACCESSIBILITY_SETTINGS_CLASSES || + className.contains("AccessibilitySettings") || + className.contains("AccessibilityServiceWarning")) { + blockAccess("Accessibility settings are protected.") + return + } + + val accessibilityKeywords = listOf("Accessibility", "Installed apps", "Downloaded apps", "Installed services", "Downloaded services") + if (root != null && accessibilityKeywords.any { containsTextRecursive(root, it) }) { + if (containsTextRecursive(root, "APP Lock by AP")) { + blockAccess("Accessibility settings for APP Lock by AP are protected.") + return + } } + } - isHomeScreen -> { - LogUtils.d(TAG, "On home screen") - recentsOpen = false - clearTemporarilyUnlockedAppIfNeeded() + if (appLockRepository.isAntiUninstallOverlayEnabled()) { + if (className in OVERLAY_SETTINGS_CLASSES || + className.contains("DrawOverlayDetails") || + className.contains("OverlaySettings")) { + blockAccess("Overlay settings are protected.") + return + } + if (root != null && (containsTextRecursive(root, "Display over other apps") || containsTextRecursive(root, "Appear on top"))) { + if (className.contains("SubSettings") || className.contains("SettingsActivity") || className.contains("DrawOverlayDetails")) { + blockAccess("Overlay settings are protected.") + return + } } + } + } - isAppSwitchedFromRecents(event) -> { - LogUtils.d(TAG, "App switched from recents") - recentsOpen = false - clearTemporarilyUnlockedAppIfNeeded(event.packageName?.toString()) + private fun containsTextRecursive(node: AccessibilityNodeInfo?, text: String): Boolean { + if (node == null) return false + + val nodeText = node.text?.toString() ?: "" + val contentDescription = node.contentDescription?.toString() ?: "" + + if (nodeText.contains(text, ignoreCase = true) || contentDescription.contains(text, ignoreCase = true)) { + return true + } + + for (i in 0 until node.childCount) { + val child = node.getChild(i) + if (containsTextRecursive(child, text)) { + return true } } + return false } - @SuppressLint("InlinedApi") - private fun isRecentlyOpened(event: AccessibilityEvent): Boolean { - return (event.packageName == getSystemDefaultLauncherPackageName() && - event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED) || - (event.text.toString().lowercase().contains("recent apps")) + private fun blockAccess(message: String) { + performGlobalAction(GLOBAL_ACTION_HOME) + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } - private fun isHomeScreen(event: AccessibilityEvent): Boolean { - return event.packageName == getSystemDefaultLauncherPackageName() && - event.className == "com.android.launcher3.uioverrides.QuickstepLauncher" && - event.text.toString().lowercase().contains("home screen") + private fun handleWindowStateChanged(event: AccessibilityEvent) { + if (isRecentlyOpened(event)) { + recentsOpen = true + } else if (isAppSwitchedFromRecents(event)) { + recentsOpen = false + clearTemporarilyUnlockedAppIfNeeded(event.packageName?.toString()) + } } @SuppressLint("InlinedApi") - private fun isHomeScreenTransition(event: AccessibilityEvent): Boolean { - return event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED && - event.packageName == getSystemDefaultLauncherPackageName() + private fun isRecentlyOpened(event: AccessibilityEvent): Boolean { + val packageName = event.packageName?.toString() ?: "" + return (launcherPackages.contains(packageName) && + event.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_APPEARED) || + (event.text.toString().lowercase().contains("recent apps")) } private fun isAppSwitchedFromRecents(event: AccessibilityEvent): Boolean { - return event.packageName != getSystemDefaultLauncherPackageName() && recentsOpen + val packageName = event.packageName?.toString() ?: "" + return !launcherPackages.contains(packageName) && recentsOpen } private fun clearTemporarilyUnlockedAppIfNeeded(newPackage: String? = null) { @@ -212,56 +356,58 @@ class AppLockAccessibilityService : AccessibilityService() { newPackage !in appLockRepository.getTriggerExcludedApps()) if (shouldClear) { - LogUtils.d(TAG, "Clearing temporarily unlocked app") AppLockManager.clearTemporarilyUnlockedApp() } } private fun isValidPackageForLocking(packageName: String): Boolean { - // Check if device is locked if (applicationContext.isDeviceLocked()) { AppLockManager.appUnlockTimes.clear() AppLockManager.clearTemporarilyUnlockedApp() return false } - // Check if accessibility should handle locking if (!shouldAccessibilityHandleLocking()) { return false } - // Skip excluded packages - if (packageName == APP_PACKAGE_PREFIX || + // Don't lock our own app, keyboards, system apps, or launchers + if (packageName == applicationContext.packageName || packageName in keyboardPackages || - packageName in EXCLUDED_APPS + packageName in EXCLUDED_APPS || + launcherPackages.contains(packageName) ) { return false } - // Skip known recents classes return true } private fun processPackageLocking(packageName: String) { val currentForegroundPackage = packageName + + // Attempt to restore state during transitions (handles stray "exit" events) + if (AppLockManager.checkAndRestoreRecentlyLeftApp(currentForegroundPackage)) { + LogUtils.d(TAG, "Restored state for $currentForegroundPackage via grace period") + } + val triggeringPackage = lastForegroundPackage lastForegroundPackage = currentForegroundPackage - // Skip if triggering package is excluded + // "One-way" Entry-based Detection: Only trigger if the package actually changed + if (currentForegroundPackage == triggeringPackage) { + return + } + if (triggeringPackage in appLockRepository.getTriggerExcludedApps()) { return } - // Fix for "Lock Immediately" not working when switching between apps val unlockedApp = AppLockManager.temporarilyUnlockedApp if (unlockedApp.isNotEmpty() && unlockedApp != currentForegroundPackage && currentForegroundPackage !in appLockRepository.getTriggerExcludedApps() ) { - LogUtils.d( - TAG, - "Switched from unlocked app $unlockedApp to $currentForegroundPackage." - ) AppLockManager.setRecentlyLeftApp(unlockedApp) AppLockManager.clearTemporarilyUnlockedApp() } @@ -283,19 +429,16 @@ class AppLockAccessibilityService : AccessibilityService() { } private fun checkAndLockApp(packageName: String, triggeringPackage: String, currentTime: Long) { - // Return early if lock screen is already shown or biometric auth is in progress if (AppLockManager.isLockScreenShown.get() || AppLockManager.currentBiometricState == BiometricState.AUTH_STARTED ) { return } - // Return if package is not locked if (packageName !in appLockRepository.getLockedApps()) { return } - // Return if app is temporarily unlocked if (AppLockManager.isAppTemporarilyUnlocked(packageName)) { return } @@ -305,30 +448,18 @@ class AppLockAccessibilityService : AccessibilityService() { val unlockDurationMinutes = appLockRepository.getUnlockTimeDuration() val unlockTimestamp = AppLockManager.appUnlockTimes[packageName] ?: 0L - LogUtils.d( - TAG, - "checkAndLockApp: pkg=$packageName, duration=$unlockDurationMinutes min, unlockTime=$unlockTimestamp, currentTime=$currentTime, isLockScreenShown=${AppLockManager.isLockScreenShown.get()}" - ) - if (unlockDurationMinutes > 0 && unlockTimestamp > 0) { if (unlockDurationMinutes >= 10_000) { return } val durationMillis = unlockDurationMinutes.toLong() * 60L * 1000L - val elapsedMillis = currentTime - unlockTimestamp - LogUtils.d( - TAG, - "Grace period check: elapsed=${elapsedMillis}ms (${elapsedMillis / 1000}s), duration=${durationMillis}ms (${durationMillis / 1000}s)" - ) - if (elapsedMillis < durationMillis) { return } - LogUtils.d(TAG, "Unlock grace period expired for $packageName. Clearing timestamp.") AppLockManager.appUnlockTimes.remove(packageName) AppLockManager.clearTemporarilyUnlockedApp() } @@ -336,7 +467,6 @@ class AppLockAccessibilityService : AccessibilityService() { if (AppLockManager.isLockScreenShown.get() || AppLockManager.currentBiometricState == BiometricState.AUTH_STARTED ) { - LogUtils.d(TAG, "Lock screen already shown or biometric auth in progress, skipping") return } @@ -344,7 +474,6 @@ class AppLockAccessibilityService : AccessibilityService() { } private fun showLockScreenOverlay(packageName: String, triggeringPackage: String) { - LogUtils.d(TAG, "Locked app detected: $packageName. Showing overlay.") AppLockManager.isLockScreenShown.set(true) val intent = Intent(this, PasswordOverlayActivity::class.java).apply { @@ -365,109 +494,6 @@ class AppLockAccessibilityService : AccessibilityService() { } } - private fun checkForDeviceAdminDeactivation(event: AccessibilityEvent) { - Log.d(TAG, "Checking for device admin deactivation for event: $event") - - // Check if user is trying to deactivate the accessibility service - if (isDeactivationAttempt(event)) { - Log.d(TAG, "Blocking accessibility service deactivation") - blockDeactivationAttempt() - return - } - - // Check if on device admin page and our app is visible - val isDeviceAdminPage = isDeviceAdminPage(event) - //val isOurAppVisible = findNodeWithTextContaining(rootNode, "App Lock") != null || - // findNodeWithTextContaining(rootNode, "AppLock") != null - - LogUtils.d(TAG, "User is on device admin page: $isDeviceAdminPage, $event") - - if (!isDeviceAdminPage) { - return - } - - blockDeviceAdminDeactivation() - } - - private fun isDeactivationAttempt(event: AccessibilityEvent): Boolean { - val isAccessibilitySettings = event.className in ACCESSIBILITY_SETTINGS_CLASSES && - event.text.any { it.contains("App Lock") } - val isSubSettings = event.className == "com.android.settings.SubSettings" && - event.text.any { it.contains("App Lock") } - val isAlertDialog = - event.packageName == "com.google.android.packageinstaller" && event.className == "android.app.AlertDialog" && event.text.toString() - .lowercase().contains("App Lock") - - return isAccessibilitySettings || isSubSettings || isAlertDialog - } - - @SuppressLint("InlinedApi") - private fun blockDeactivationAttempt() { - try { - performGlobalAction(GLOBAL_ACTION_BACK) - performGlobalAction(GLOBAL_ACTION_HOME) - performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) - } catch (e: Exception) { - logError("Error blocking deactivation attempt", e) - } - } - - private fun isDeviceAdminPage(event: AccessibilityEvent): Boolean { - val hasDeviceAdminDescription = event.contentDescription?.toString()?.lowercase() - ?.contains("Device admin app") == true && - event.className == "android.widget.FrameLayout" - - val isAdminConfigClass = - event.className!!.contains("DeviceAdminAdd") || event.className!!.contains("DeviceAdminSettings") - - return hasDeviceAdminDescription || isAdminConfigClass - } - - @SuppressLint("InlinedApi") - private fun blockDeviceAdminDeactivation() { - try { - val dpm: DevicePolicyManager? = getSystemService() - val component = ComponentName(this, DeviceAdmin::class.java) - - if (dpm?.isAdminActive(component) == true) { - performGlobalAction(GLOBAL_ACTION_BACK) - performGlobalAction(GLOBAL_ACTION_BACK) - performGlobalAction(GLOBAL_ACTION_HOME) - Thread.sleep(100) - performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) - Toast.makeText( - this, - "Disable anti-uninstall from AppLock settings to remove this restriction.", - Toast.LENGTH_LONG - ).show() - Log.w(TAG, "Blocked device admin deactivation attempt.") - } - } catch (e: Exception) { - logError("Error blocking device admin deactivation", e) - } - } - - private fun findNodeWithTextContaining( - node: AccessibilityNodeInfo, - text: String - ): AccessibilityNodeInfo? { - return try { - if (node.text?.toString()?.contains(text, ignoreCase = true) == true) { - return node - } - - for (i in 0 until node.childCount) { - val child = node.getChild(i) ?: continue - val result = findNodeWithTextContaining(child, text) - if (result != null) return result - } - null - } catch (e: Exception) { - logError("Error finding node with text: $text", e) - null - } - } - private fun getKeyboardPackageNames(): List { return try { getSystemService()?.enabledInputMethodList?.map { it.packageName } @@ -478,54 +504,20 @@ class AppLockAccessibilityService : AccessibilityService() { } } - fun getSystemDefaultLauncherPackageName(): String { - return try { - val packageManager = packageManager - val homeIntent = Intent(Intent.ACTION_MAIN).apply { - addCategory(Intent.CATEGORY_HOME) - } - - val resolveInfoList: List = packageManager.queryIntentActivities( - homeIntent, - PackageManager.MATCH_DEFAULT_ONLY - ) - - val systemLauncher = resolveInfoList.find { resolveInfo -> - val isSystemApp = (resolveInfo.activityInfo.applicationInfo.flags and - android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 - val isOurApp = resolveInfo.activityInfo.packageName == packageName - - isSystemApp && !isOurApp - } - - systemLauncher?.activityInfo?.packageName?.also { - if (it.isEmpty()) { - Log.w(TAG, "Could not find a clear system launcher package name.") - } - } ?: "" - } catch (e: Exception) { - logError("Error getting system default launcher package", e) - "" - } - } - private fun startPrimaryBackendService() { try { AppLockManager.stopAllOtherServices(this, AppLockAccessibilityService::class.java) when (appLockRepository.getBackendImplementation()) { BackendImplementation.SHIZUKU -> { - Log.d(TAG, "Starting Shizuku service as primary backend") startService(Intent(this, ShizukuAppLockService::class.java)) } BackendImplementation.USAGE_STATS -> { - Log.d(TAG, "Starting Experimental service as primary backend") startService(Intent(this, ExperimentalAppLockService::class.java)) } else -> { - Log.d(TAG, "Accessibility service is the primary backend.") } } } catch (e: Exception) { @@ -533,17 +525,67 @@ class AppLockAccessibilityService : AccessibilityService() { } } - override fun onInterrupt() { - try { - LogUtils.d(TAG, "Accessibility service interrupted") - } catch (e: Exception) { - logError("Error in onInterrupt", e) + private fun startForegroundService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel() + val notification = createNotification() + + val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + determineForegroundServiceType() + } else 0 + + try { + if (type != 0) { + startForeground(NOTIFICATION_ID, notification, type) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + } + } + } + + private fun determineForegroundServiceType(): Int { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val dpm = getSystemService(DevicePolicyManager::class.java) + val component = ComponentName(this, DeviceAdmin::class.java) + + return if (dpm?.isAdminActive(component) == true) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } } + return 0 + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + "APP Lock by AP Accessibility Service", + NotificationManager.IMPORTANCE_LOW + ) + notificationManager.createNotificationChannel(serviceChannel) + } + } + + private fun createNotification(): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("APP Lock by AP") + .setContentText("Accessibility service is protecting your apps") + .setSmallIcon(R.drawable.baseline_shield_24) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .build() + } + + override fun onInterrupt() { } override fun onUnbind(intent: Intent?): Boolean { return try { - Log.d(TAG, "Accessibility service unbound") isServiceRunning = false AppLockManager.startFallbackServices(this, AppLockAccessibilityService::class.java) @@ -562,13 +604,10 @@ class AppLockAccessibilityService : AccessibilityService() { try { super.onDestroy() isServiceRunning = false - LogUtils.d(TAG, "Accessibility service destroyed") try { unregisterReceiver(screenStateReceiver) } catch (_: IllegalArgumentException) { - // Ignore if not registered - Log.w(TAG, "Receiver not registered or already unregistered") } AppLockManager.isLockScreenShown.set(false) @@ -578,10 +617,6 @@ class AppLockAccessibilityService : AccessibilityService() { } } - /** - * Logs errors silently without crashing the service. - * Only logs to debug level to avoid unnecessary noise in production. - */ private fun logError(message: String, throwable: Throwable? = null) { Log.e(TAG, message, throwable) } diff --git a/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt b/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt index f34af00..e6715b2 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt @@ -1,6 +1,5 @@ package dev.pranav.applock.services -import android.app.ActivityManager import android.app.KeyguardManager import android.content.Context import android.content.Intent @@ -19,6 +18,8 @@ object AppLockConstants { "com.android.quickstep.RecentsActivity", "com.android.systemui.recents.RecentsView", "com.android.systemui.recents.RecentsPanelView", + "com.sec.android.app.launcher.recents.RecentsActivity", + "com.google.android.apps.nexuslauncher.RecentsActivity" ) val EXCLUDED_APPS = setOf( @@ -29,14 +30,67 @@ object AppLockConstants { "com.google.android.googlequicksearchbox", "android", "com.google.android.gms", - "com.google.android.webview" + "com.google.android.webview", + "com.google.android.packageinstaller", + "com.android.packageinstaller", + "com.samsung.android.incallui", + "com.android.incallui", + "com.google.android.apps.nexuslauncher", + "com.sec.android.app.launcher", + "com.miui.home", + "com.huawei.android.launcher", + "com.oppo.launcher" ) val ACCESSIBILITY_SETTINGS_CLASSES = setOf( "com.android.settings.accessibility.AccessibilitySettings", "com.android.settings.accessibility.AccessibilityMenuActivity", "com.android.settings.accessibility.AccessibilityShortcutActivity", - "com.android.settings.Settings\$AccessibilitySettingsActivity" + "com.android.settings.Settings\$AccessibilitySettingsActivity", + "com.android.settings.Settings\$AccessibilitySettings", + // Samsung specific + "com.samsung.android.settings.accessibility.AccessibilitySettings", + "com.samsung.android.settings.accessibility.AccessibilityShortcutActivity", + "com.samsung.android.settings.accessibility.AccessibilityMenuActivity", + "com.samsung.android.settings.accessibility.home.AccessibilitySettings", + "com.samsung.android.settings.accessibility.AccessibilityDetailsSettings", + "com.samsung.android.settings.accessibility.InstalledAppsActivity", + "com.samsung.android.settings.accessibility.ListServiceAccessibilitySettings", + "com.samsung.android.settings.accessibility.advanced.AdvancedSettingsActivity", + "com.samsung.android.settings.Settings\$AccessibilitySettingsActivity", + "com.samsung.android.settings.Settings\$AccessibilitySettings" + ) + + val DEVICE_ADMIN_SETTINGS_CLASSES = setOf( + "com.android.settings.Settings\$DeviceAdminSettingsActivity", + "com.android.settings.DeviceAdminSettings", + "com.android.settings.applications.specialaccess.deviceadmin.DeviceAdminSettings", + "com.android.settings.DeviceAdminAdd", + // Samsung specific + "com.samsung.android.settings.deviceadmin.DeviceAdminSettings", + "com.samsung.android.settings.deviceadmin.DeviceAdminAdd", + "com.samsung.android.settings.Settings\$DeviceAdminSettingsActivity" + ) + + val USAGE_ACCESS_SETTINGS_CLASSES = setOf( + "com.android.settings.Settings\$UsageAccessSettingsActivity", + "com.android.settings.applications.specialaccess.usageaccess.UsageAccessSettings", + "com.android.settings.UsageAccessSettings", + // Samsung specific + "com.samsung.android.settings.usageaccess.UsageAccessSettings", + "com.android.settings.Settings\$UsageAccessSettingsActivity" + ) + + val OVERLAY_SETTINGS_CLASSES = setOf( + "com.android.settings.Settings\$OverlaySettingsActivity", + "com.android.settings.Settings\$DrawOverlayDetailsActivity", + "com.android.settings.applications.specialaccess.drawoverlay.DrawOverlayDetails", + "com.android.settings.DrawOverlayDetails", + // Samsung specific + "com.samsung.android.settings.applications.specialaccess.drawoverlay.DrawOverlayDetails", + "com.samsung.android.settings.applications.specialaccess.drawoverlay.OverlaySettings", + "com.samsung.android.settings.Settings\$OverlaySettingsActivity", + "com.samsung.android.settings.Settings\$DrawOverlayDetailsActivity" ) const val MAX_RESTART_ATTEMPTS = 3 @@ -49,11 +103,13 @@ fun Context.isDeviceLocked(): Boolean { return keyguardManager?.isKeyguardLocked ?: false } -@Suppress("DEPRECATION") fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { - val manager = getSystemService(ActivityManager::class.java) ?: return false - return manager.getRunningServices(Int.MAX_VALUE) - .any { serviceClass.name == it.service.className } + return when (serviceClass) { + AppLockAccessibilityService::class.java -> AppLockAccessibilityService.isServiceRunning + ShizukuAppLockService::class.java -> ShizukuAppLockService.isServiceRunning + ExperimentalAppLockService::class.java -> ExperimentalAppLockService.isServiceRunning + else -> false + } } object AppLockManager { @@ -67,7 +123,7 @@ object AppLockManager { // Grace period tracking private var recentlyLeftApp: String = "" private var recentlyLeftTime: Long = 0L - private const val GRACE_PERIOD_MS = 300L + private const val GRACE_PERIOD_MS = 2000L // Increased to 2 seconds for better navigation stability fun setRecentlyLeftApp(packageName: String) { recentlyLeftApp = packageName diff --git a/app/src/main/java/dev/pranav/applock/services/ExperimentalAppLockService.kt b/app/src/main/java/dev/pranav/applock/services/ExperimentalAppLockService.kt index aa38e00..9af87f3 100644 --- a/app/src/main/java/dev/pranav/applock/services/ExperimentalAppLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/ExperimentalAppLockService.kt @@ -42,6 +42,11 @@ class ExperimentalAppLockService : Service() { private var timer: Timer? = null private var previousForegroundPackage = "" + companion object { + @Volatile + var isServiceRunning = false + } + private val screenStateReceiver = object: android.content.BroadcastReceiver() { override fun onReceive(context: android.content.Context?, intent: Intent?) { if (intent?.action == Intent.ACTION_SCREEN_OFF) { @@ -64,6 +69,7 @@ class ExperimentalAppLockService : Service() { return START_NOT_STICKY } + isServiceRunning = true AppLockManager.resetRestartAttempts(TAG) appLockRepository.setActiveBackend(BackendImplementation.USAGE_STATS) AppLockManager.stopAllOtherServices(this, this::class.java) @@ -82,6 +88,7 @@ class ExperimentalAppLockService : Service() { } override fun onDestroy() { + isServiceRunning = false timer?.cancel() LogUtils.d(TAG, "Service destroyed. Checking for fallback.") @@ -262,7 +269,7 @@ class ExperimentalAppLockService : Service() { private fun createNotificationChannel() { val serviceChannel = NotificationChannel( CHANNEL_ID, - "AppLock Service (Usage Stats)", + "APP Lock by AP Service (Usage Stats)", NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(serviceChannel) @@ -270,7 +277,7 @@ class ExperimentalAppLockService : Service() { private fun createNotification(): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("App Lock") + .setContentTitle("APP Lock by AP") .setContentText("Protecting your apps") .setSmallIcon(R.drawable.baseline_shield_24) .setPriority(NotificationCompat.PRIORITY_MIN) diff --git a/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt b/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt index 7fd0534..a1cd292 100644 --- a/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt @@ -142,7 +142,7 @@ class ShizukuAppLockService : Service() { private fun createNotificationChannel() { val serviceChannel = NotificationChannel( CHANNEL_ID, - "AppLock Service", + "APP Lock by AP Service", NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(serviceChannel) @@ -150,7 +150,7 @@ class ShizukuAppLockService : Service() { private fun createNotification(): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("AppLock") + .setContentTitle("APP Lock by AP") .setContentText("Protecting your apps with Shizuku") .setSmallIcon(R.drawable.baseline_shield_24) .setPriority(NotificationCompat.PRIORITY_MIN) diff --git a/app/src/main/java/dev/pranav/applock/ui/theme/Theme.kt b/app/src/main/java/dev/pranav/applock/ui/theme/Theme.kt index c795e35..3e0a2f7 100644 --- a/app/src/main/java/dev/pranav/applock/ui/theme/Theme.kt +++ b/app/src/main/java/dev/pranav/applock/ui/theme/Theme.kt @@ -10,27 +10,142 @@ import androidx.compose.material3.Shapes import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.expressiveLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import dev.pranav.applock.data.repository.AppLockRepository +import dev.pranav.applock.data.repository.AppThemeMode + +// Purple/Lavender Palette +private val PurplePrimary = Color(0xFF7B50A1) +private val PurpleOnPrimary = Color(0xFFFFFFFF) +private val PurplePrimaryContainer = Color(0xFFF3E8FF) +private val PurpleOnPrimaryContainer = Color(0xFF2D0050) +private val PurpleSecondary = Color(0xFF655A70) +private val PurpleOnSecondary = Color(0xFFFFFFFF) +private val PurpleSecondaryContainer = Color(0xFFEBDFF7) +private val PurpleOnSecondaryContainer = Color(0xFF20182B) +private val PurpleBackground = Color(0xFFFFF7FF) +private val PurpleOnBackground = Color(0xFF1D1B1F) +private val PurpleSurface = Color(0xFFFFF7FF) +private val PurpleOnSurface = Color(0xFF1D1B1F) + +private val PurplePrimaryDark = Color(0xFFE1B6FF) +private val PurpleOnPrimaryDark = Color(0xFF491E6F) +private val PurplePrimaryContainerDark = Color(0xFF613787) +private val PurpleOnPrimaryContainerDark = Color(0xFFF3E8FF) +private val PurpleSecondaryDark = Color(0xFFD0C1DA) +private val PurpleOnSecondaryDark = Color(0xFF362D3F) +private val PurpleSecondaryContainerDark = Color(0xFF4D4357) +private val PurpleOnSecondaryContainerDark = Color(0xFFEBDFF7) +private val PurpleBackgroundDark = Color(0xFF1D1B1F) +private val PurpleOnBackgroundDark = Color(0xFFE6E1E6) +private val PurpleSurfaceDark = Color(0xFF1D1B1F) +private val PurpleOnSurfaceDark = Color(0xFFE6E1E6) + +private val CustomLightColorScheme = lightColorScheme( + primary = PurplePrimary, + onPrimary = PurpleOnPrimary, + primaryContainer = PurplePrimaryContainer, + onPrimaryContainer = PurpleOnPrimaryContainer, + secondary = PurpleSecondary, + onSecondary = PurpleOnSecondary, + secondaryContainer = PurpleSecondaryContainer, + onSecondaryContainer = PurpleOnSecondaryContainer, + background = PurpleBackground, + onBackground = PurpleOnBackground, + surface = PurpleSurface, + onSurface = PurpleOnSurface, + surfaceContainer = Color(0xFFF7F2FA), + surfaceContainerLow = Color(0xFFF3EDF7), + surfaceContainerHigh = Color(0xFFECE6F0), +) + +private val CustomDarkColorScheme = darkColorScheme( + primary = PurplePrimaryDark, + onPrimary = PurpleOnPrimaryDark, + primaryContainer = PurplePrimaryContainerDark, + onPrimaryContainer = PurpleOnPrimaryContainerDark, + secondary = PurpleSecondaryDark, + onSecondary = PurpleOnSecondaryDark, + secondaryContainer = PurpleSecondaryContainerDark, + onSecondaryContainer = PurpleOnSecondaryContainerDark, + background = PurpleBackgroundDark, + onBackground = PurpleOnBackgroundDark, + surface = PurpleSurfaceDark, + onSurface = PurpleOnSurfaceDark, + surfaceContainer = Color(0xFF211F26), + surfaceContainerLow = Color(0xFF1D1B20), + surfaceContainerHigh = Color(0xFF2B2930), +) @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AppLockTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + val context = LocalContext.current + val appLockRepository = remember(context) { AppLockRepository(context) } + + val themeMode by appLockRepository.appThemeModeFlow() + .collectAsState(initial = appLockRepository.getAppThemeMode()) + val amoledModeEnabled by appLockRepository.amoledModeFlow() + .collectAsState(initial = appLockRepository.isAmoledModeEnabled()) + val dynamicColorEnabled by appLockRepository.dynamicColorFlow() + .collectAsState(initial = appLockRepository.isDynamicColorEnabled()) + + val resolvedDarkTheme = when (themeMode) { + AppThemeMode.SYSTEM -> darkTheme + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + } + val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val base = if (resolvedDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + if (resolvedDarkTheme && amoledModeEnabled) { + base.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color.Black, + surfaceContainer = Color.Black, + surfaceContainerLow = Color.Black, + surfaceContainerHigh = Color(0xFF121212), + surfaceContainerLowest = Color.Black, + surfaceContainerHighest = Color(0xFF1A1A1A), + ) + } else base + } + + resolvedDarkTheme -> { + if (amoledModeEnabled) { + CustomDarkColorScheme.copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = Color.Black, + surfaceContainer = Color.Black, + surfaceContainerLow = Color.Black, + surfaceContainerHigh = Color(0xFF121212), // Slightly lighter for cards in amoled + surfaceContainerLowest = Color.Black, + surfaceContainerHighest = Color(0xFF1A1A1A), + onBackground = Color.White, + onSurface = Color.White, + onSurfaceVariant = Color(0xFFE0E0E0), + secondaryContainer = Color(0xFF121212), + onSecondaryContainer = Color.White, + ) + } else { + CustomDarkColorScheme + } } - darkTheme -> darkColorScheme() - else -> expressiveLightColorScheme() + else -> CustomLightColorScheme } val shapes = Shapes(largeIncreased = RoundedCornerShape(36.0.dp)) diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3ba4e35..65291b9 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,6 @@ - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 3ba4e35..65291b9 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,6 @@ - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..208569c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index ea86d36..be2edde 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 2725374..8b1a3bb 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..dff7d28 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index dd1c2f2..d8ffd32 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 8e420db..fd3ea8f 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..0e230dc Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 8f229aa..69e2951 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index a058183..dafa162 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..4ec6f9c Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index f4ab3ca..7d185a7 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 0771c88..3a71eec 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..df11eb0 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 5eeec77..4fb732e 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index ad5db81..2ea1984 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..443b48b --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FCFCFC + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 87a4760..59180f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,31 +1,31 @@ - App Lock + APP Lock by AP Prevents unauthorized people from uninstalling the app. - Allows App Lock to detect when protected apps are opened and show password verification. + Allows APP Lock by AP to detect when protected apps are opened and show password verification. Enter your password to disable admin permission. Incorrect PIN. 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. + Select how you want APP Lock by AP to detect when protected apps are launched. Notification Permission - AppLock needs permission to show notifications to keep you informed and keep running in background properly. Tap \'Next\' to grant permission. + APP Lock by AP needs permission to show notifications to keep you informed and keep running in background properly. Tap \'Next\' to grant permission. Notification permission is automatically granted on your Android version. - Welcome to AppLock - Protect your apps and privacy with AppLock. We\'ll guide you through a quick setup. + Welcome to APP Lock by AP + Protect your apps and privacy with APP Lock by AP. We\'ll guide you through a quick setup. Secure Your Apps Keep your private apps protected with advanced locking mechanisms Display Over Other Apps - AppLock needs permission to display over other apps to show the lock screen. Tap \'Next\' and enable the permission. + APP Lock by AP needs permission to display over other apps to show the lock screen. Tap \'Next\' and enable the permission. - Notification permission is required for AppLock to function properly. + Notification permission is required for APP Lock by AP to function properly. Shizuku permission granted Shizuku permission is required for advanced features. - Please allow AppLock to display over other apps. + Please allow APP Lock by AP to display over other apps. Disable Battery Optimization - To ensure AppLock runs reliably in the background, please disable battery optimizations for the app. Tap \'Next\' to open settings. + To ensure APP Lock by AP runs reliably in the background, please disable battery optimizations for the app. Tap \'Next\' to open settings. Accessibility Service Standard method that works on most devices. Requires accessibility permission. May have slight delays on some OEMs. Usage Stats @@ -34,9 +34,9 @@ Advanced method with better performance and superior experience. Requires Shizuku app installed and enabled via ADB. You can change this later in settings -Accessibility service is required for AppLock to function properly. +Accessibility service is required for APP Lock by AP to function properly. -If you get the message \"Restricted Setting\", please manually go to Settings > Apps > App Lock > Upper Right menu, and press \"Allow restricted settings\". +If you get the message \"Restricted Setting\", please manually go to Settings > Apps > APP Lock by AP > Upper Right menu, and press \"Allow restricted settings\". Tap \'Next\' to enable it. @@ -44,7 +44,7 @@ Tap \'Next\' to enable it. This permission is required to detect when locked apps are launched. -If you get the message \"Restricted Setting\", please manually go to Settings > Apps > App Lock > Upper Right menu, and press \"Allow restricted settings\". +If you get the message \"Restricted Setting\", please manually go to Settings > Apps > APP Lock by AP > Upper Right menu, and press \"Allow restricted settings\". Tap \'Next\' to enable it. @@ -53,7 +53,7 @@ Shizuku provides advanced features like locking system apps and more. Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant permission. Complete Privacy - Your data never leaves your device. AppLock protects your privacy at all times. + Your data never leaves your device. APP Lock by AP protects your privacy at all times. All permissions are required to proceed. Next Get Started @@ -98,7 +98,7 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Please grant the app permission to draw overlays in settings. This is required to allow app to show lock screen. Open Settings Please grant Shizuku permission manually - App Lock requires Device Admin permission to prevent uninstallation. It will not affect your device\'s functionality. + APP Lock by AP requires Device Admin permission to prevent uninstallation. It will not affect your device\'s functionality. Join the Community Join our discord community for updates and support. Join Discord @@ -112,25 +112,39 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Search No apps found Try adjusting your search + App Protection + Are you sure you want to turn ON app protection? + Are you sure you want to turn OFF app protection? Settings Back Support Development - Hi, I\'m Pranav, the developer of App Lock. I\'m a student developer passionate about creating useful apps. If you find it helpful, please consider supporting its development with a small donation. Any amount is greatly appreciated and helps me continue to improve the app and work on new features. Thank you for your support! + Hi, I\'m Pranav, the developer of APP Lock by AP. I\'m a student developer passionate about creating useful apps. If you find it helpful, please consider supporting its development with a small donation. Any amount is greatly appreciated and helps me continue to improve the app and work on new features. Thank you for your support! Donate Permission Required - To enable Anti-Uninstall, App Lock requires two permissions: Device Administrator and Accessibility Service. These are necessary to prevent unauthorized uninstallation and ensure robust protection. Would you like to proceed with granting these permissions? + To enable Anti-Uninstall, APP Lock by AP requires two permissions: Device Administrator and Accessibility Service. These are necessary to prevent unauthorized uninstallation and ensure robust protection. Would you like to proceed with granting these permissions? Proceed Device Administrator Permission - App Lock needs Device Administrator permission to prevent unauthorized uninstallation. Please grant this permission in the next screen. This will not affect your device\'s normal functionality. + APP Lock by AP needs Device Administrator permission to prevent unauthorized uninstallation. Please grant this permission in the next screen. This will not affect your device\'s normal functionality. Grant Permission Accessibility Service Permission - App Lock also needs Accessibility Service permission to monitor app uninstallation attempts and re-activate Device Administrator if disabled. This ensures the Anti-Uninstall feature remains effective. Please enable the Accessibility Service for App Lock in the settings. + APP Lock by AP also needs Accessibility Service permission to monitor app uninstallation attempts and re-activate Device Administrator if disabled. This ensures the Anti-Uninstall feature remains effective. Please enable the Accessibility Service for APP Lock by AP in the settings. Open Settings + App Theme + System + Light + Dark + Developer Info + Developer Name: AP + Email: ap0803apap@gmail.com + Source Code + Report Issue Lock Screen Customization Maximum Brightness Display lock screen at maximum brightness for clarity + AMOLED Mode + When off (default), dynamic colors are used in lock screen customization. When on, dark mode primary color becomes AMOLED black across the app. Biometric Authentication Use fingerprint or face unlock if available Biometric authentication is not available on this device. @@ -152,11 +166,11 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Change PIN Update your current PIN Anti-Uninstall Protection - Prevent unauthorized uninstallation of App Lock + Prevent unauthorized uninstallation of APP Lock by AP Haptic Feedback Disable haptic feedback on key presses Backend Implementation - Select the method AppLock uses to detect foreground apps + Select the method APP Lock by AP uses to detect foreground apps Accessibility Service Standard method, may have slight delays on some OEMs. Usage Stats @@ -174,6 +188,10 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Support Development Help support the development of this app + App Logging + Enable internal logging for debugging purposes + Links & Social + Please allow our app to ignore battery optimizations. Could not find specific settings. Please remove app from battery restrictions. @@ -205,7 +223,7 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Enable Accessibility Service - App Lock needs accessibility permission to detect when protected apps are launched. + APP Lock by AP needs accessibility permission to detect when protected apps are launched. Please follow these steps: 1. Tap \'Open Settings\' below\n @@ -234,20 +252,20 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant %1$d minute 1 hour To enable Anti-Uninstall - protection, App Lock needs two permissions: + protection, APP Lock by AP needs two permissions: 1. Device Administrator - Prevents uninstallation\n2. Accessibility Service - Monitors app usage\n\nThese permissions help protect the app from being removed without your knowledge. - App Lock needs Device Administrator + APP Lock by AP needs Device Administrator permission to prevent uninstallation. This permission allows the app to:\n• Prevent itself from being uninstalled\n• Protect your app lock settings\n\nIt will not affect your device\'s normal functionality. - App Lock needs Accessibility Service permission to monitor app usage. + APP Lock by AP needs Accessibility Service permission to monitor app usage. This permission allows the app to:\n• Detect when apps are opened\n• Show lock screen when needed\n• Provide seamless app @@ -285,7 +303,7 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Shizuku Permission Securely detects when protected apps are launched. Device Admin - Prevents AppLock from being uninstalled. + Prevents APP Lock by AP from being uninstalled. Required Join Community @@ -294,12 +312,12 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Maybe Later Donate - Keep AppLock free and ad-free forever + Keep APP Lock by AP free and ad-free forever Support Development - Hi, I’m Pranav. I’m a student developer building AppLock in my spare time to keep it private and ad-free for everyone. + Hi, I’m Pranav. I’m a student developer building APP Lock by AP in my spare time to keep it private and ad-free for everyone. Juggling this project with my studies is a challenge, but your support makes it possible. - If AppLock has helped you, please consider a small donation—any amount means the world to me and keeps this project alive. + If APP Lock by AP has helped you, please consider a small donation—any amount means the world to me and keeps this project alive. Thank you for believing in my work! diff --git a/app/src/main/res/xml/accessibility_service_config.xml b/app/src/main/res/xml/accessibility_service_config.xml index 7d4e056..e3ee6ca 100644 --- a/app/src/main/res/xml/accessibility_service_config.xml +++ b/app/src/main/res/xml/accessibility_service_config.xml @@ -1,10 +1,9 @@ - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8d533d6..83e9f4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.1" +agp = "9.1.0" annotation = "1.9.1" refine = "4.4.0" hiddenapibypass = "6.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f70ea9e..8f63698 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Aug 02 11:42:34 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index a456f82..1e9637d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,10 +16,11 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://jitpack.io") } } } -rootProject.name = "App Lock" +rootProject.name = "APP Lock by AP" include(":app") include(":appintro") include(":hidden-api")