From 44b4390ff79e6497fbdc3a4260b66283f4c2a7b5 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:00:11 -0300 Subject: [PATCH 01/31] feat: let automation apps set the protection flag Exposes the global protection flag - the same one the shield toggle writes and every backend checks - to MacroDroid, Tasker and adb, so protection can follow a condition such as a trusted Wi-Fi network. Two setters rather than one toggle: an automation app cannot read our state, so a toggle forces it to track state of its own, which drifts out of sync and leaves protection off when you believe it is on. Guarded three ways, since this can switch a security feature off: - the receiver component stays disabled until the user opts in from Settings, so by default it is not part of the exported surface at all; - every action requires the token generated there, compared in constant time; - unknown actions, missing tokens and requests sent before a token exists are rejected and written to the audit log. Enabling protection also clears any unlock state left over from while it was off, and restarts the backend service, so re-enabling always fails closed. Protection changes are broadcast on dev.pranav.applock.action.PROTECTION_STATE so automation can verify the state instead of assuming it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SbmNP15bp6hG5q1rjmFK5 --- AUTOMATION.md | 102 +++++++++++ app/build.gradle.kts | 22 ++- app/src/main/AndroidManifest.xml | 15 ++ .../dev/pranav/applock/AppLockApplication.kt | 6 + .../core/broadcast/AutomationReceiver.kt | 149 +++++++++++++++ .../applock/core/utils/SecurityUtils.kt | 17 ++ .../data/repository/AppLockRepository.kt | 7 + .../data/repository/PreferencesRepository.kt | 24 +++ .../applock/features/applist/ui/MainScreen.kt | 2 + .../features/settings/ui/SettingsScreen.kt | 169 ++++++++++++++++++ .../pranav/applock/services/AppLockManager.kt | 12 ++ app/src/main/res/values/strings.xml | 16 ++ 12 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 AUTOMATION.md create mode 100644 app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt diff --git a/AUTOMATION.md b/AUTOMATION.md new file mode 100644 index 00000000..0a6f576c --- /dev/null +++ b/AUTOMATION.md @@ -0,0 +1,102 @@ +# Automation control + +This fork exposes AppLock's global protection flag to automation apps, so that protection can +follow a condition you choose — a trusted Wi-Fi network, a time of day, a location — instead of +being switched by hand. + +It adds no conditions of its own. AppLock only accepts "protection on" and "protection off"; +MacroDroid, Tasker or `adb` decides when to send them. + +## Setup + +1. Open **Settings → Automation** and turn on **Allow automation apps**. +2. Tap **Access token**, then **Copy token**. + +The receiver stays disabled at the package level until that switch is on, so with the feature off +there is nothing exported to reach. + +## Intents + +| Action | Effect | +| --- | --- | +| `dev.pranav.applock.action.ENABLE_PROTECTION` | Protection on, and any app unlocked while it was off is re-locked | +| `dev.pranav.applock.action.DISABLE_PROTECTION` | Protection off | +| `dev.pranav.applock.action.QUERY_PROTECTION_STATE` | No change; replies with the current state | + +Every action requires the string extra `token`, matching the token from Settings. Requests with a +wrong, missing or stale token are ignored, as is anything sent while automation access is off. + +These are **setters, not a toggle**, on purpose. An automation app cannot read AppLock's state, so +a toggle would force it to track state of its own — and that tracked state drifts out of sync the +first time anything is missed, leaving protection off when you believe it is on. `ENABLE` and +`DISABLE` are idempotent: sending either twice is harmless, and no state can desync. + +Broadcasts must target the package explicitly. Android does not deliver implicit broadcasts to +manifest-declared receivers, so a broadcast with only an action set will silently do nothing. + +## Reading the state back + +Whenever protection changes — by intent or by the shield toggle in the app — AppLock broadcasts: + +``` +dev.pranav.applock.action.PROTECTION_STATE boolean extra "state" +``` + +This one is implicit, so a dynamically registered receiver picks it up. In MacroDroid that is the +**Intent Received** trigger. Use it to verify rather than assume. + +Ordered senders can also read the result code directly: `0` rejected, `1` protection off, +`2` protection on. + +## MacroDroid + +Two macros, no variables, nothing to keep in sync: + +- **Trigger** Wi-Fi → Connected to *your SSID* → **Action** Send Intent +- **Trigger** Wi-Fi → Disconnected from *your SSID* → **Action** Send Intent + +Send Intent settings: + +| Field | Value | +| --- | --- | +| Target | Broadcast | +| Action | `dev.pranav.applock.action.DISABLE_PROTECTION` (or `ENABLE_…`) | +| Package | `dev.pranav.applock` | +| Class | `dev.pranav.applock.core.broadcast.AutomationReceiver` | +| Extra | name `token`, type String, value *your token* | + +Fail closed: put the `ENABLE` macro on *disconnect*, not on "connected to any other network", so +that losing Wi-Fi entirely still re-enables protection. + +## Testing with adb + +```sh +TOKEN= + +adb shell am broadcast \ + -a dev.pranav.applock.action.DISABLE_PROTECTION \ + -n dev.pranav.applock/.core.broadcast.AutomationReceiver \ + --es token "$TOKEN" + +adb shell am broadcast \ + -a dev.pranav.applock.action.QUERY_PROTECTION_STATE \ + -n dev.pranav.applock/.core.broadcast.AutomationReceiver \ + --es token "$TOKEN" +``` + +`am broadcast` sends an ordered broadcast, so it prints the result code and data. +Rejected requests return `result=0`; enable **Settings → Advanced → Logging** to see the reason in +the audit log. + +## Security notes + +Anything that can switch protection off is worth being careful about. Three things guard it: + +- the receiver component is disabled until you opt in, so by default it is not part of the app's + exported surface at all; +- every action requires the token, compared in constant time; +- **Replace** in the token dialog invalidates the old token immediately. + +Anyone who can already run `adb` against an unlocked device could read the token out of the app's +private storage — the token protects against other *apps* on the device, not against someone +holding an unlocked phone with USB debugging on. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12c9f0f8..bf275795 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,22 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + // CI signs with a keystore supplied through the environment when one is configured, so that + // successive builds share a signature and install over each other. Without it the build falls + // back to the debug key, which is regenerated per machine and per CI run. + val keystorePath: String? = System.getenv("APPLOCK_KEYSTORE") + + signingConfigs { + if (!keystorePath.isNullOrBlank()) { + create("release") { + storeFile = file(keystorePath) + storePassword = System.getenv("APPLOCK_KEYSTORE_PASSWORD") + keyAlias = System.getenv("APPLOCK_KEY_ALIAS") + keyPassword = System.getenv("APPLOCK_KEY_PASSWORD") + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -31,7 +47,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("debug") + signingConfig = if (!keystorePath.isNullOrBlank()) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } compileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 15470cdb..d2b3bc9c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -140,6 +140,21 @@ + + + + + + + + + setProtection(context, repository, true) + ACTION_DISABLE_PROTECTION -> setProtection(context, repository, false) + ACTION_QUERY_STATE -> LogUtils.d(TAG, "State queried via automation") + else -> { + reject(context, "unknown action", action) + return + } + } + + val enabled = repository.isProtectEnabled() + reportResult(if (enabled) RESULT_PROTECTION_ON else RESULT_PROTECTION_OFF, enabled.toString()) + notifyStateChanged(context, enabled) + } + + private fun setProtection(context: Context, repository: AppLockRepository, enabled: Boolean) { + repository.setProtectEnabled(enabled) + + if (enabled) { + // Fail closed: an app unlocked while protection was off must not stay unlocked once + // protection comes back, and the backend service may have been stopped meanwhile. + AppLockManager.clearAllUnlockStates() + try { + AppLockServiceStarter.startAppropriateServices(context, repository) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to start services after enabling protection", e) + } + } + + LogUtils.d(TAG, "Protection set to $enabled via automation") + } + + private fun reject(context: Context, reason: String, action: String?) { + LogUtils.e(TAG, "Rejected automation request '$action': $reason") + reportResult(RESULT_REJECTED, reason) + } + + /** + * Ordered senders can read this back; plain [Context.sendBroadcast] senders cannot, and + * calling these outside an ordered broadcast throws, hence the guard. + */ + private fun reportResult(code: Int, data: String) { + if (!isOrderedBroadcast) return + resultCode = code + resultData = data + } + + companion object { + private const val TAG = "AutomationReceiver" + + const val ACTION_ENABLE_PROTECTION = "dev.pranav.applock.action.ENABLE_PROTECTION" + const val ACTION_DISABLE_PROTECTION = "dev.pranav.applock.action.DISABLE_PROTECTION" + const val ACTION_QUERY_STATE = "dev.pranav.applock.action.QUERY_PROTECTION_STATE" + + /** Broadcast whenever protection changes, so automation can verify instead of assume. */ + const val ACTION_PROTECTION_STATE = "dev.pranav.applock.action.PROTECTION_STATE" + + const val EXTRA_TOKEN = "token" + const val EXTRA_STATE = "state" + + const val RESULT_REJECTED = 0 + const val RESULT_PROTECTION_OFF = 1 + const val RESULT_PROTECTION_ON = 2 + + /** + * Announces the current protection state. Sent implicitly so automation apps can pick it + * up with a dynamically registered receiver; it carries nothing but the on/off flag. + */ + fun notifyStateChanged(context: Context, enabled: Boolean) { + try { + context.sendBroadcast( + Intent(ACTION_PROTECTION_STATE).putExtra(EXTRA_STATE, enabled) + ) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to broadcast protection state", e) + } + } + + /** + * Adds or removes this receiver from the app's exported surface entirely, rather than + * leaving it reachable and merely ignoring what it receives. + */ + fun setComponentEnabled(context: Context, enabled: Boolean) { + val state = if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + try { + context.packageManager.setComponentEnabledSetting( + ComponentName(context, AutomationReceiver::class.java), + state, + PackageManager.DONT_KILL_APP + ) + LogUtils.d(TAG, "Automation receiver enabled: $enabled") + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to set automation receiver state", e) + } + } + } +} diff --git a/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt index 48d8c9b4..81be7bce 100644 --- a/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt +++ b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt @@ -9,6 +9,7 @@ object SecurityUtils { private const val HASH_ALGORITHM = "SHA-256" private const val SALT_LENGTH = 16 + private const val TOKEN_LENGTH = 24 const val MAX_PASSWORD_LENGTH = 64 /** @@ -30,6 +31,22 @@ object SecurityUtils { } } + /** + * Generates a random token for automation apps to authenticate broadcasts with. + */ + fun generateToken(): String { + val bytes = ByteArray(TOKEN_LENGTH).also { SecureRandom().nextBytes(it) } + return Base64.encodeToString(bytes, Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE) + } + + /** + * Compares two secrets without leaking their content through timing. + */ + fun constantTimeEquals(a: String?, b: String?): Boolean { + if (a == null || b == null) return false + return MessageDigest.isEqual(a.toByteArray(Charsets.UTF_8), b.toByteArray(Charsets.UTF_8)) + } + fun hashPassword(password: String): String { return hashPassword(password, generateSalt()) } 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 3e928706..3e3cb72e 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 @@ -79,6 +79,13 @@ class AppLockRepository(private val context: Context) { fun setProtectEnabled(enabled: Boolean) = preferencesRepository.setProtectEnabled(enabled) fun isProtectEnabled(): Boolean = preferencesRepository.isProtectEnabled() + fun setAutomationEnabled(enabled: Boolean) = + preferencesRepository.setAutomationEnabled(enabled) + + fun isAutomationEnabled(): Boolean = preferencesRepository.isAutomationEnabled() + fun getAutomationToken(): String? = preferencesRepository.getAutomationToken() + fun regenerateAutomationToken(): String = preferencesRepository.regenerateAutomationToken() + fun setUnlockTimeDuration(minutes: Int) = preferencesRepository.setUnlockTimeDuration(minutes) fun getUnlockTimeDuration(): Int = preferencesRepository.getUnlockTimeDuration() fun setAutoUnlockEnabled(enabled: Boolean) = preferencesRepository.setAutoUnlockEnabled(enabled) 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 522d7128..004251c6 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 @@ -114,6 +114,27 @@ class PreferencesRepository(context: Context) { return settingsPrefs.getBoolean(KEY_APPLOCK_ENABLED, DEFAULT_PROTECT_ENABLED) } + fun setAutomationEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_AUTOMATION_ENABLED, enabled) } + } + + fun isAutomationEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_AUTOMATION_ENABLED, DEFAULT_AUTOMATION_ENABLED) + } + + fun getAutomationToken(): String? { + return appLockPrefs.getString(KEY_AUTOMATION_TOKEN, null) + } + + /** + * Replaces the automation token, invalidating any token already handed to an automation app. + */ + fun regenerateAutomationToken(): String { + val token = SecurityUtils.generateToken() + appLockPrefs.edit(commit = true) { putString(KEY_AUTOMATION_TOKEN, token) } + return token + } + fun setUnlockTimeDuration(minutes: Int) { settingsPrefs.edit { putInt(KEY_UNLOCK_TIME_DURATION, minutes) } } @@ -190,8 +211,11 @@ 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_AUTOMATION_ENABLED = "automation_enabled" + private const val KEY_AUTOMATION_TOKEN = "automation_token" private const val DEFAULT_PROTECT_ENABLED = true + private const val DEFAULT_AUTOMATION_ENABLED = false private const val DEFAULT_UNLOCK_DURATION = 0 const val LOCK_TYPE_PIN = "pin" 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 9e9cf812..dbea83b3 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 @@ -45,6 +45,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import dev.pranav.applock.R +import dev.pranav.applock.core.broadcast.AutomationReceiver import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen import dev.pranav.applock.core.utils.appLockRepository @@ -166,6 +167,7 @@ fun MainScreen( onClick = { appLockRepository.setProtectEnabled(!applockEnabled) applockEnabled = !applockEnabled + AutomationReceiver.notifyStateChanged(context, applockEnabled) }, shape = RoundedCornerShape(16.dp), color = if (applockEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, 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 7438a6a1..0652aa4b 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,10 +1,14 @@ package dev.pranav.applock.features.settings.ui import android.app.admin.DevicePolicyManager +import android.content.ClipData +import android.content.ClipDescription import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build +import android.os.PersistableBundle import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -15,7 +19,10 @@ 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.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +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 @@ -32,11 +39,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.navigation.NavController import dev.pranav.applock.R +import dev.pranav.applock.core.broadcast.AutomationReceiver import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen import dev.pranav.applock.core.utils.LogUtils @@ -90,6 +99,9 @@ fun SettingsScreen( var antiUninstallEnabled by remember { mutableStateOf(appLockRepository.isAntiUninstallEnabled()) } var disableHapticFeedback by remember { mutableStateOf(appLockRepository.shouldDisableHaptics()) } var loggingEnabled by remember { mutableStateOf(appLockRepository.isLoggingEnabled()) } + var automationEnabled by remember { mutableStateOf(appLockRepository.isAutomationEnabled()) } + var automationToken by remember { mutableStateOf(appLockRepository.getAutomationToken()) } + var showAutomationDialog by remember { mutableStateOf(false) } var showPermissionDialog by remember { mutableStateOf(false) } var showDeviceAdminDialog by remember { mutableStateOf(false) } @@ -144,6 +156,29 @@ fun SettingsScreen( ) } + if (showAutomationDialog) { + AutomationTokenDialog( + token = automationToken.orEmpty(), + onCopy = { + copyTokenToClipboard(context, automationToken.orEmpty()) + Toast.makeText( + context, + context.getString(R.string.settings_screen_automation_copied), + Toast.LENGTH_SHORT + ).show() + }, + onRegenerate = { + automationToken = appLockRepository.regenerateAutomationToken() + Toast.makeText( + context, + context.getString(R.string.settings_screen_automation_regenerated), + Toast.LENGTH_LONG + ).show() + }, + onDismiss = { showAutomationDialog = false } + ) + } + if (showPermissionDialog) { PermissionRequiredDialog( onDismiss = { showPermissionDialog = false }, @@ -368,6 +403,55 @@ fun SettingsScreen( ) } + item { + SectionTitle(text = stringResource(R.string.settings_screen_automation_title)) + } + + item { + SettingsGroup( + items = listOf( + ToggleSettingItem( + icon = Icons.Default.SettingsRemote, + title = stringResource(R.string.settings_screen_automation_control_title), + subtitle = stringResource(R.string.settings_screen_automation_control_desc), + checked = automationEnabled, + enabled = true, + onCheckedChange = { isChecked -> + if (isChecked && automationToken.isNullOrEmpty()) { + automationToken = appLockRepository.regenerateAutomationToken() + } + appLockRepository.setAutomationEnabled(isChecked) + AutomationReceiver.setComponentEnabled(context, isChecked) + automationEnabled = isChecked + } + ), + ActionSettingItem( + icon = Icons.Default.Key, + title = stringResource(R.string.settings_screen_automation_token_title), + subtitle = if (automationEnabled) + stringResource(R.string.settings_screen_automation_token_desc) + else + stringResource(R.string.settings_screen_automation_token_desc_disabled), + onClick = { + if (!automationEnabled) { + Toast.makeText( + context, + context.getString(R.string.settings_screen_automation_token_desc_disabled), + Toast.LENGTH_SHORT + ).show() + } else { + if (automationToken.isNullOrEmpty()) { + automationToken = + appLockRepository.regenerateAutomationToken() + } + showAutomationDialog = true + } + } + ) + ) + ) + } + item { SectionTitle(text = stringResource(R.string.settings_screen_advanced_title)) } @@ -1010,6 +1094,91 @@ fun AccessibilityDialog( ) } +@Composable +fun AutomationTokenDialog( + token: String, + onCopy: () -> Unit, + onRegenerate: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_screen_automation_dialog_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.settings_screen_automation_dialog_intro), + style = MaterialTheme.typography.bodyMedium + ) + MonospaceBlock( + listOf( + AutomationReceiver.ACTION_ENABLE_PROTECTION, + AutomationReceiver.ACTION_DISABLE_PROTECTION, + AutomationReceiver.ACTION_QUERY_STATE + ).joinToString("\n") + ) + Text( + text = stringResource(R.string.settings_screen_automation_dialog_token_label), + style = MaterialTheme.typography.labelLarge + ) + MonospaceBlock(token) + } + }, + confirmButton = { + FilledTonalButton(onClick = onCopy) { + Text(stringResource(R.string.settings_screen_automation_copy)) + } + }, + dismissButton = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = onRegenerate) { + Text(stringResource(R.string.settings_screen_automation_regenerate)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_screen_automation_close)) + } + } + }, + containerColor = MaterialTheme.colorScheme.surface + ) +} + +@Composable +private fun MonospaceBlock(text: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp) + ) { + SelectionContainer { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(12.dp) + ) + } + } +} + +/** + * Copies the token, flagged sensitive so Android 13+ keeps it out of the clipboard preview. + */ +private fun copyTokenToClipboard(context: Context, token: String) { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = ClipData.newPlainText("AppLock automation token", token) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + clipboard.setPrimaryClip(clip) +} + @Composable fun LinksSection() { val context = LocalContext.current 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 f5296444..c00d059c 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt @@ -121,6 +121,18 @@ object AppLockManager { temporarilyUnlockedApp = "" } + /** + * Drops every unlock state at once. Used when protection is switched back on, so an app + * left unlocked while protection was off does not stay open afterwards. + */ + fun clearAllUnlockStates() { + temporarilyUnlockedApp = "" + appUnlockTimes.clear() + recentlyLeftApp = "" + recentlyLeftTime = 0L + LogUtils.d(TAG, "Cleared all unlock states") + } + fun clearAppUnlockState(packageName: String) { if (temporarilyUnlockedApp == packageName) { temporarilyUnlockedApp = "" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1873486b..b4f6319b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,4 +316,20 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant If AppLock 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! + + + Automation + Allow automation apps + Let apps like MacroDroid or Tasker turn protection on and off, for example when you reach a trusted Wi-Fi network. Requires the access token below. + Access token + Tap to view, copy or replace the token + Turn on automation access to use a token + Automation access + Send one of these broadcasts to AppLock with the token as a string extra named \"token\". Set dev.pranav.applock as the target package: Android does not deliver implicit broadcasts to manifest-declared receivers. + Token + Copy token + Token copied + Replace + New token generated. Update your automation app. + Done From 8be82f2a78fc0c95b85b4caa255db376b8093435 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:00:18 -0300 Subject: [PATCH 02/31] build: sign release with a stable key when CI secrets are set The release build was signed with the debug config, and the debug keystore is generated fresh on every CI runner, so consecutive builds had different signatures and could not be installed over each other. That is painful on a fork with anti-uninstall enabled, where uninstalling is deliberately hard. Falls back to the previous behaviour when no keystore is configured, so the build is unchanged for anyone without the secrets set. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SbmNP15bp6hG5q1rjmFK5 --- .github/workflows/android.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 68170243..2f9469b4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,6 +9,12 @@ jobs: build: runs-on: ubuntu-latest + env: + APPLOCK_KEYSTORE_BASE64: ${{ secrets.APPLOCK_KEYSTORE_BASE64 }} + APPLOCK_KEYSTORE_PASSWORD: ${{ secrets.APPLOCK_KEYSTORE_PASSWORD }} + APPLOCK_KEY_ALIAS: ${{ secrets.APPLOCK_KEY_ALIAS }} + APPLOCK_KEY_PASSWORD: ${{ secrets.APPLOCK_KEY_PASSWORD }} + steps: - name: Checkout repository uses: actions/checkout@v4.2.2 @@ -28,6 +34,12 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4.4.1 + - name: Decode signing keystore + if: env.APPLOCK_KEYSTORE_BASE64 != '' + run: | + echo "$APPLOCK_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + echo "APPLOCK_KEYSTORE=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV" + - name: Build with gradle run: ./gradlew clean assemble From 8655d0a304b4ab4d22e0cce771403f78f8226943 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:46:22 -0300 Subject: [PATCH 03/31] build: state the keystore type explicitly The keystore is PKCS#12. Java's default store type has been pkcs12 since JDK 9 so this is what would happen anyway, but naming it means a future JDK default cannot quietly break the release build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013SbmNP15bp6hG5q1rjmFK5 --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf275795..e0b2f23d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,6 +29,7 @@ android { if (!keystorePath.isNullOrBlank()) { create("release") { storeFile = file(keystorePath) + storeType = "PKCS12" storePassword = System.getenv("APPLOCK_KEYSTORE_PASSWORD") keyAlias = System.getenv("APPLOCK_KEY_ALIAS") keyPassword = System.getenv("APPLOCK_KEY_PASSWORD") From 64fbb7d2887daeca5930ee3856697a49644c7162 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:47:33 -0300 Subject: [PATCH 04/31] feat: prompt for biometrics without waiting for a tap The accessibility backend was the only one that made you press the fingerprint icon: Shizuku and usage-stats raise the prompt themselves from PasswordOverlayActivity.onPostResume, but the accessibility lock screen is an overlay window and could only reach biometrics through the button. The service now raises the prompt itself whenever biometric unlock is on and the device can actually authenticate. The icon stays as manual retry. The overlay goes up first and stays up until TransparentBiometricActivity is on screen, which then has it removed through a new LockScreenHost channel. The locked app is covered at every moment, and the manual flow no longer flashes its content while the activity starts. This also fixes cancelling the prompt leaving the app wide open: nothing cleared isLockScreenShown, so checkAndLockApp kept bailing out and the lock screen never came back until the screen went off. Cancelling while the prompt is in front now restores the lock screen - without auto-prompting again, or cancelling would loop - and losing the foreground instead clears the flag so the app re-locks the next time it comes forward. Not compiled: this environment has no JDK or Android SDK. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CHVeS9m5jYWWFyRg8rMmbg --- .../pranav/applock/core/utils/AppLockUtils.kt | 11 ++ .../lockscreen/ui/LockScreenOverlayManager.kt | 53 ++----- .../ui/TransparentBiometricActivity.kt | 145 +++++++++++++++--- .../features/settings/ui/SettingsScreen.kt | 10 +- .../services/AppLockAccessibilityService.kt | 43 +++++- .../pranav/applock/services/AppLockManager.kt | 18 +++ 6 files changed, 205 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt index 4f73ff67..04ef1df8 100644 --- a/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt +++ b/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt @@ -10,6 +10,7 @@ import android.os.VibratorManager import android.provider.Settings import android.util.Log import android.widget.Toast +import androidx.biometric.BiometricManager import androidx.core.net.toUri import dev.pranav.applock.AppLockApplication import dev.pranav.applock.data.repository.AppLockRepository @@ -115,3 +116,13 @@ private const val DEFAULT_VIBRATION_DURATION = 500L */ fun Context.appLockRepository(): AppLockRepository = (applicationContext as AppLockApplication).appLockRepository + +/** + * Whether the device can actually authenticate the user right now - biometric hardware is present, + * available and something is enrolled. + */ +fun Context.canAuthenticateBiometrics(): Boolean = + BiometricManager.from(this).canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.BIOMETRIC_STRONG + ) == BiometricManager.BIOMETRIC_SUCCESS diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt index ee2c2286..bca7c154 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/LockScreenOverlayManager.kt @@ -2,7 +2,6 @@ package dev.pranav.applock.features.lockscreen.ui import android.annotation.SuppressLint import android.content.Context -import android.content.Intent import android.graphics.PixelFormat import android.view.WindowManager import androidx.activity.OnBackPressedDispatcher @@ -28,7 +27,6 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.data.repository.PreferencesRepository -import dev.pranav.applock.services.AppLockManager import dev.pranav.applock.ui.theme.AppLockTheme @SuppressLint("ViewConstructor") @@ -107,6 +105,15 @@ class LockScreenOverlayManager(private val context: Context): isValid } + // The overlay deliberately stays up until the prompt is actually on + // screen; TransparentBiometricActivity removes it through the host. + val onBiometricAuthCallback: () -> Unit = { + context.startBiometricPrompt( + lockedPackageName, + triggeringPackageName + ) + } + BackHandler { onExit() removeOverlay() @@ -126,19 +133,7 @@ class LockScreenOverlayManager(private val context: Context): lockedAppName = appName, triggeringPackageName = triggeringPackageName, onPatternAttempt = onPatternAttemptCallback, - onBiometricAuth = { - val intent = Intent( - context, - TransparentBiometricActivity::class.java - ).apply { - flags = - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION - putExtra("locked_package", lockedPackageName) - } - AppLockManager.reportBiometricAuthStarted() - removeOverlay() - context.startActivity(intent) - } + onBiometricAuth = onBiometricAuthCallback ) } @@ -157,19 +152,7 @@ class LockScreenOverlayManager(private val context: Context): onUnlock() removeOverlay() }, - onBiometricAuth = { - val intent = Intent( - context, - TransparentBiometricActivity::class.java - ).apply { - flags = - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION - putExtra("locked_package", lockedPackageName) - } - AppLockManager.reportBiometricAuthStarted() - removeOverlay() - context.startActivity(intent) - }, + onBiometricAuth = onBiometricAuthCallback, onPasswordAttempt = onPinAttemptCallback ) } @@ -189,19 +172,7 @@ class LockScreenOverlayManager(private val context: Context): onUnlock() removeOverlay() }, - onBiometricAuth = { - val intent = Intent( - context, - TransparentBiometricActivity::class.java - ).apply { - flags = - Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION - putExtra("locked_package", lockedPackageName) - } - AppLockManager.reportBiometricAuthStarted() - removeOverlay() - context.startActivity(intent) - }, + onBiometricAuth = onBiometricAuthCallback, onPinAttempt = onPinAttemptCallback ) } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt index 92313840..89c1dfac 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt @@ -1,5 +1,7 @@ package dev.pranav.applock.features.lockscreen.ui +import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import androidx.activity.compose.setContent @@ -15,13 +17,37 @@ import dev.pranav.applock.R import dev.pranav.applock.services.AppLockManager import dev.pranav.applock.ui.theme.AppLockTheme +/** + * Raises the biometric prompt for [lockedPackage]. Callers keep whatever lock screen they are + * showing on screen: [TransparentBiometricActivity] asks for it to be taken down only once it is + * itself visible, so the locked app is never exposed in between. + */ +fun Context.startBiometricPrompt(lockedPackage: String, triggeringPackage: String? = null) { + AppLockManager.reportBiometricAuthStarted() + try { + startActivity(Intent(this, TransparentBiometricActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION + putExtra("locked_package", lockedPackage) + putExtra("triggering_package", triggeringPackage) + }) + } catch (e: Exception) { + // The caller's lock screen is still up, so leaving it there is the safe outcome. + Log.e("BiometricPrompt", "Could not start the biometric prompt", e) + AppLockManager.reportBiometricAuthFinished() + } +} + class TransparentBiometricActivity: FragmentActivity() { private val TAG = "TransparentBiometric" private var lockedPackageName: String? = null + private var triggeringPackageName: String? = null + + private var isActivityResumed = false + private var hasHiddenLockScreen = false + private var isAuthenticated = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - lockedPackageName = intent.getStringExtra("locked_package") setContent { AppLockTheme { @@ -32,32 +58,26 @@ class TransparentBiometricActivity: FragmentActivity() { } } + startAuthentication(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + // This activity is singleInstance, so a re-launch reuses it and never runs onCreate again. + startAuthentication(intent) + } + + private fun startAuthentication(intent: Intent) { + lockedPackageName = intent.getStringExtra("locked_package") + triggeringPackageName = intent.getStringExtra("triggering_package") + isAuthenticated = false + hasHiddenLockScreen = false + AppLockManager.reportBiometricAuthStarted() val executor = ContextCompat.getMainExecutor(this) - val biometricPrompt = BiometricPrompt( - this, executor, - object: BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - AppLockManager.reportBiometricAuthFinished() - finish() // Close transparent activity if failed/canceled - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - AppLockManager.reportBiometricAuthFinished() - AppLockManager.isLockScreenShown.set(false) - lockedPackageName?.let { - AppLockManager.temporarilyUnlockAppWithBiometrics(it) - } - - - // The Accessibility service will detect the unlock state - // and close the Service View automatically - finish() - } - }) + val biometricPrompt = BiometricPrompt(this, executor, authenticationCallback) val appNameForPrompt = getString(R.string.this_app) val promptInfo = BiometricPrompt.PromptInfo.Builder() @@ -73,16 +93,93 @@ class TransparentBiometricActivity: FragmentActivity() { try { biometricPrompt.authenticate(promptInfo) + if (isActivityResumed) hideLockScreen() } catch (e: Exception) { Log.e(TAG, "Biometric failed to start", e) + fallBackToLockScreen() + } + } + + private val authenticationCallback = object: BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + Log.d(TAG, "Biometric authentication error: $errString ($errorCode)") + fallBackToLockScreen() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + isAuthenticated = true + AppLockManager.reportBiometricAuthFinished() + AppLockManager.isLockScreenShown.set(false) + lockedPackageName?.let { + AppLockManager.temporarilyUnlockAppWithBiometrics(it) + } + + // The Accessibility service will detect the unlock state + // and close the Service View automatically finish() } } + /** + * Takes the caller's lock screen down, now that this activity covers the locked app itself. + */ + private fun hideLockScreen() { + if (hasHiddenLockScreen) return + hasHiddenLockScreen = true + AppLockManager.lockScreenHost?.hideLockScreen() + } + + /** + * Hands control back to the lock screen after a failed or cancelled prompt. + * + * While this activity is still in front, the user dismissed the prompt themselves ("Use PIN" or + * a cancelled dialog), so the lock screen goes back up - without auto-prompting again, or + * cancelling would loop straight back into the prompt. If the foreground is already gone (Home + * pressed, or the system cancelled the prompt), no lock screen is forced over whatever the user + * moved to; clearing [AppLockManager.isLockScreenShown] instead lets the accessibility service + * lock the app again the next time it comes forward. + */ + private fun fallBackToLockScreen() { + if (isFinishing || isAuthenticated) return + + AppLockManager.reportBiometricAuthFinished() + + val host = AppLockManager.lockScreenHost + val lockedPackage = lockedPackageName + if (isActivityResumed && host != null && lockedPackage != null) { + host.showLockScreen( + packageName = lockedPackage, + triggeringPackage = triggeringPackageName ?: "", + autoPromptBiometrics = false + ) + } else { + AppLockManager.isLockScreenShown.set(false) + } + + finish() + } + + override fun onResume() { + super.onResume() + isActivityResumed = true + hideLockScreen() + } + override fun onPause() { super.onPause() + isActivityResumed = false if (isFinishing) { AppLockManager.reportBiometricAuthFinished() } } + + override fun onDestroy() { + super.onDestroy() + // Never leave the backends believing an authentication is still in flight. + if (!isAuthenticated) { + AppLockManager.reportBiometricAuthFinished() + } + } } 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 0652aa4b..16feeb54 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 @@ -12,7 +12,6 @@ import android.os.PersistableBundle import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.biometric.BiometricManager import androidx.compose.animation.* import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring @@ -49,6 +48,7 @@ import dev.pranav.applock.core.broadcast.AutomationReceiver import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen import dev.pranav.applock.core.utils.LogUtils +import dev.pranav.applock.core.utils.canAuthenticateBiometrics import dev.pranav.applock.core.utils.hasUsagePermission import dev.pranav.applock.core.utils.isAccessibilityServiceEnabled import dev.pranav.applock.core.utils.openAccessibilitySettings @@ -107,13 +107,7 @@ fun SettingsScreen( var showDeviceAdminDialog by remember { mutableStateOf(false) } var showAccessibilityDialog by remember { mutableStateOf(false) } - val biometricManager = remember { BiometricManager.from(context) } - val isBiometricAvailable = remember { - biometricManager.canAuthenticate( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) == BiometricManager.BIOMETRIC_SUCCESS - } + val isBiometricAvailable = remember { context.canAuthenticateBiometrics() } if (showDialog) { AlertDialog( 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 dad4b8a1..854e092c 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -19,10 +19,12 @@ import androidx.core.content.getSystemService import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.appLockRepository +import dev.pranav.applock.core.utils.canAuthenticateBiometrics import dev.pranav.applock.core.utils.enableAccessibilityServiceWithShizuku import dev.pranav.applock.data.repository.AppLockRepository import dev.pranav.applock.data.repository.BackendImplementation import dev.pranav.applock.features.lockscreen.ui.LockScreenOverlayManager +import dev.pranav.applock.features.lockscreen.ui.startBiometricPrompt import dev.pranav.applock.services.AppLockConstants.ACCESSIBILITY_SETTINGS_CLASSES import dev.pranav.applock.services.AppLockConstants.EXCLUDED_APPS import rikka.shizuku.Shizuku @@ -77,6 +79,7 @@ class AppLockAccessibilityService : AccessibilityService() { mainHandler = Handler(mainLooper) overlayManager = LockScreenOverlayManager(this) + AppLockManager.lockScreenHost = lockScreenHost val filter = android.content.IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) @@ -327,8 +330,31 @@ class AppLockAccessibilityService : AccessibilityService() { showLockScreenOverlay(packageName, triggeringPackage) } - private fun showLockScreenOverlay(packageName: String, triggeringPackage: String) { - if (AppLockManager.isLockScreenShown.get()) return + /** + * Lets the biometric prompt put this service's overlay back up once it is done with it. + */ + private val lockScreenHost = object: AppLockManager.LockScreenHost { + override fun showLockScreen( + packageName: String, + triggeringPackage: String, + autoPromptBiometrics: Boolean + ) { + showLockScreenOverlay(packageName, triggeringPackage, autoPromptBiometrics) + } + + override fun hideLockScreen() { + mainHandler.post { overlayManager?.removeOverlay() } + } + } + + private fun showLockScreenOverlay( + packageName: String, + triggeringPackage: String, + autoPromptBiometrics: Boolean = true + ) { + // A lock screen returning from a cancelled prompt has to get through while the flag is + // still set - it is the same lock session, not a second one. + if (autoPromptBiometrics && AppLockManager.isLockScreenShown.get()) return LogUtils.d(TAG, "Showing overlay for: $packageName") @@ -339,17 +365,29 @@ class AppLockAccessibilityService : AccessibilityService() { triggeringPackageName = triggeringPackage, onUnlock = { AppLockManager.isLockScreenShown.set(false) + AppLockManager.reportBiometricAuthFinished() AppLockManager.unlockApp(packageName) }, onExit = { performGlobalAction(GLOBAL_ACTION_HOME) Thread.sleep(200) AppLockManager.isLockScreenShown.set(false) + AppLockManager.reportBiometricAuthFinished() } ) + + // The overlay is up first and stays up until the prompt is on screen, so the locked + // app is never briefly visible behind it. + if (autoPromptBiometrics && canPromptBiometrics()) { + LogUtils.d(TAG, "Auto-prompting biometrics for: $packageName") + startBiometricPrompt(packageName, triggeringPackage) + } } } + private fun canPromptBiometrics(): Boolean = + appLockRepository.isBiometricAuthEnabled() && canAuthenticateBiometrics() + //private fun showLockScreenOverlay(packageName: String, triggeringPackage: String) { // LogUtils.d(TAG, "Locked app detected: $packageName. Showing overlay.") // AppLockManager.isLockScreenShown.set(true) @@ -570,6 +608,7 @@ class AppLockAccessibilityService : AccessibilityService() { isServiceRunning = false LogUtils.d(TAG, "Accessibility service destroyed") + AppLockManager.lockScreenHost = null overlayManager?.removeOverlay() try { 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 c00d059c..29baffa5 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt @@ -57,6 +57,24 @@ object AppLockManager { val isLockScreenShown = AtomicBoolean(false) var currentBiometricState: AppLockAccessibilityService.BiometricState? = null + /** + * Lets the biometric prompt hand control of the lock screen back and forth with whichever + * service is showing it. Registered by [AppLockAccessibilityService] while it is alive; null + * when another backend is in charge, since those draw their lock screen as an activity. + */ + interface LockScreenHost { + fun showLockScreen( + packageName: String, + triggeringPackage: String, + autoPromptBiometrics: Boolean + ) + + fun hideLockScreen() + } + + @Volatile + var lockScreenHost: LockScreenHost? = null + // Grace period tracking private var recentlyLeftApp: String = "" private var recentlyLeftTime: Long = 0L From 70fccac3a233ee250bd08453fbf229cf344ca783 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:14 -0300 Subject: [PATCH 05/31] fix: keep an app unlocked when you minimise it and come straight back Unlocking an app and then pressing Home or opening recents asked for the password again the moment you returned to that same app. Part of that was the documented behaviour of the default "Lock immediately" duration, but three real defects sat underneath it. The return grace period was dead code. setRecentlyLeftApp still fired every time you left an unlocked app, but its only reader, checkAndRestoreRecentlyLeftApp, had no callers since 8b87ee9 removed the one in processPackageLocking - so nothing could tell "minimised and came back" from "switched apps". It is now a 5s window: holdUnlockForReturn on the way out, consumeReturnGrace on the way back. 300ms never covered a trip through recents. Trigger exclusions were bypassed on the home path. handleWindowStateChanged called clearTemporarilyUnlockedAppIfNeeded with newPackage = null, which hardcoded shouldClear = true, so excluding your launcher did nothing. The parameter existed for exactly this case and no caller ever passed it. The recents flag was inert. isRecentsOpen was set and cleared but never read. Recents is now covered by the launcher neutral-surface check in all three backends, and the launcher lookup moved to a shared Context extension since Shizuku needs it too. All three backends now share one AppLockManager.shouldShowLockScreen instead of three near-duplicate copies of the duration logic. It also re-adopts the app as unlocked on the duration path, which the old code never did - that omission left the manager believing nothing was unlocked while a still-unlocked app sat in the foreground, so the next switch away never held it. Two things the fix turned up: dropPendingReturn has to be driven off the incoming package, not the "leaving an unlocked app" block. By the time you open a different app the unlock was already cleared on the way to the launcher, so that block no longer runs and the window would have survived an app switch. The held app itself is exempt - that is the return we allow. Four reset paths cleared unlock state by hand and would have left a pending return alive, including two isDeviceLocked() paths where the keyguard is up. That was a bypass: lock the phone, unlock it, return inside the window. Every reset now goes through clearAllUnlockStates. Accepted trade-off: for ~5s after you leave, someone handed the phone can reopen the app from recents. Bounded, and screen-off still drops everything. Not compiled: this environment has no JDK or Android SDK. The device pass is still outstanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Uh5HmvGCnijUH4EMeViQh2 --- .../core/broadcast/DeviceUnlockReciever.kt | 3 +- .../services/AppLockAccessibilityService.kt | 127 +++++-------- .../pranav/applock/services/AppLockManager.kt | 171 +++++++++++++++--- .../applock/services/ShizukuAppLockService.kt | 42 ++--- .../applock/services/UsageLockService.kt | 38 ++-- 5 files changed, 226 insertions(+), 155 deletions(-) diff --git a/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceUnlockReciever.kt b/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceUnlockReciever.kt index 008630ac..896b9f95 100644 --- a/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceUnlockReciever.kt +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/DeviceUnlockReciever.kt @@ -16,8 +16,7 @@ class DeviceUnlockReceiver(private val onDeviceUnlocked: () -> Unit) : Broadcast } Intent.ACTION_SCREEN_OFF -> { - AppLockManager.clearTemporarilyUnlockedApp() - AppLockManager.appUnlockTimes.clear() + AppLockManager.clearAllUnlockStates() LogUtils.d("DeviceUnlockReceiver", "Screen turned OFF (locked)") } } 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 854e092c..36df8f5d 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -7,8 +7,6 @@ import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ResolveInfo import android.os.Handler import android.util.Log import android.view.accessibility.AccessibilityEvent @@ -35,7 +33,6 @@ class AppLockAccessibilityService : AccessibilityService() { private val keyboardPackages: List by lazy { getKeyboardPackageNames() } private var lastForegroundPackage = "" - private var isRecentsOpen: Boolean = false private var overlayManager: LockScreenOverlayManager? = null private lateinit var mainHandler: Handler @@ -59,8 +56,7 @@ class AppLockAccessibilityService : AccessibilityService() { if (intent?.action == Intent.ACTION_SCREEN_OFF) { LogUtils.d(TAG, "Screen off detected. Resetting AppLock state.") AppLockManager.isLockScreenShown.set(false) - AppLockManager.clearTemporarilyUnlockedApp() - AppLockManager.appUnlockTimes.clear() + AppLockManager.clearAllUnlockStates() } } catch (e: Exception) { logError("Error in screenStateReceiver", e) @@ -145,8 +141,7 @@ class AppLockAccessibilityService : AccessibilityService() { Log.d(TAG, "Ignoring recents bug event: ${event.text}") return } - if (!isRecentsOpen && isRecentlyOpened(event)) { - isRecentsOpen = true + if (isRecentlyOpened(event)) { Log.d(TAG, "Recents opened") } handleWindowStateChanged(event) @@ -174,13 +169,12 @@ class AppLockAccessibilityService : AccessibilityService() { when { isHomeScreenTransition(event) -> { LogUtils.d(TAG, "Transitioning to home screen from recents") - clearTemporarilyUnlockedAppIfNeeded() - isRecentsOpen = false + releaseUnlockForNeutralSurface(event.packageName?.toString()) } isHomeScreen -> { LogUtils.d(TAG, "On home screen") - clearTemporarilyUnlockedAppIfNeeded() + releaseUnlockForNeutralSurface(event.packageName?.toString()) } } } @@ -204,22 +198,40 @@ class AppLockAccessibilityService : AccessibilityService() { event.packageName == getSystemDefaultLauncherPackageName() } - private fun clearTemporarilyUnlockedAppIfNeeded(newPackage: String? = null) { - val shouldClear = newPackage == null || - (newPackage != AppLockManager.temporarilyUnlockedApp && - newPackage !in appLockRepository.getTriggerExcludedApps()) + /** + * Called when the launcher - home screen or recents - takes the foreground. The user has not + * necessarily moved on to anything else, so the unlock is held for a short return window + * rather than dropped outright. + */ + private fun releaseUnlockForNeutralSurface(newPackage: String?) { + val unlockedApp = AppLockManager.temporarilyUnlockedApp + if (unlockedApp.isEmpty() || newPackage == unlockedApp) return - if (shouldClear) { - LogUtils.d(TAG, "Clearing temporarily unlocked app") - AppLockManager.clearTemporarilyUnlockedApp() + if (newPackage != null && newPackage in appLockRepository.getTriggerExcludedApps()) { + LogUtils.d(TAG, "$newPackage is trigger excluded, keeping $unlockedApp unlocked") + return } + + LogUtils.d(TAG, "Holding unlock for $unlockedApp while on $newPackage") + AppLockManager.holdUnlockForReturn(unlockedApp) + AppLockManager.clearTemporarilyUnlockedApp() + } + + /** + * The launcher draws both the home screen and, on most devices, the recents switcher. Neither + * means the user has opened something else, so an unlock survives them for a short window. + * Everything else that is not a real app - system UI, the intent resolver, keyboards - is + * already filtered out by [isValidPackageForLocking] before this is reached. + */ + private fun isNeutralSurface(packageName: String): Boolean { + val launcher = getSystemDefaultLauncherPackageName() + return launcher.isNotEmpty() && packageName == launcher } private fun isValidPackageForLocking(packageName: String): Boolean { // Check if device is locked if (applicationContext.isDeviceLocked()) { - AppLockManager.appUnlockTimes.clear() - AppLockManager.clearTemporarilyUnlockedApp() + AppLockManager.clearAllUnlockStates() return false } @@ -250,6 +262,8 @@ class AppLockAccessibilityService : AccessibilityService() { return } + val isNeutral = isNeutralSurface(currentForegroundPackage) + // Fix for "Lock Immediately" not working when switching between apps val unlockedApp = AppLockManager.temporarilyUnlockedApp if (unlockedApp.isNotEmpty() && @@ -260,10 +274,18 @@ class AppLockAccessibilityService : AccessibilityService() { TAG, "Switched from unlocked app $unlockedApp to $currentForegroundPackage." ) - AppLockManager.setRecentlyLeftApp(unlockedApp) + if (isNeutral) { + AppLockManager.holdUnlockForReturn(unlockedApp) + } AppLockManager.clearTemporarilyUnlockedApp() } + // A real app taking over ends any pending return - the user went somewhere else rather + // than coming back. The held app itself is exempt: that is exactly the return we allow. + if (!isNeutral && !AppLockManager.isPendingReturn(currentForegroundPackage)) { + AppLockManager.dropPendingReturn() + } + checkAndLockApp(currentForegroundPackage, triggeringPackage, System.currentTimeMillis()) } @@ -282,42 +304,15 @@ class AppLockAccessibilityService : AccessibilityService() { return } - // Return if app is temporarily unlocked - if (AppLockManager.isAppTemporarilyUnlocked(packageName)) { - return - } - - AppLockManager.clearTemporarilyUnlockedApp() - 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()}" + "checkAndLockApp: pkg=$packageName, duration=$unlockDurationMinutes min, 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() + if (!AppLockManager.shouldShowLockScreen(packageName, currentTime, unlockDurationMinutes)) { + return } if (AppLockManager.isLockScreenShown.get() || @@ -523,36 +518,8 @@ 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) - "" - } - } + fun getSystemDefaultLauncherPackageName(): String = + applicationContext.defaultLauncherPackageName() private fun startPrimaryBackendService() { try { 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 29baffa5..f18ff98f 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockManager.kt @@ -4,6 +4,8 @@ import android.app.ActivityManager import android.app.KeyguardManager import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.services.AppLockAccessibilityService.BiometricState import java.util.concurrent.ConcurrentHashMap @@ -42,6 +44,36 @@ fun Context.isDeviceLocked(): Boolean { return keyguardManager?.isKeyguardLocked ?: false } +/** + * The system launcher's package, or "" if it cannot be resolved. Shared by the backends that need + * to tell "the user went home or opened recents" apart from "the user opened another app". + */ +fun Context.defaultLauncherPackageName(): String { + return try { + val homeIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + } + + val resolveInfoList = packageManager.queryIntentActivities( + homeIntent, + PackageManager.MATCH_DEFAULT_ONLY + ) + + val systemLauncher = resolveInfoList.find { resolveInfo -> + val isSystemApp = (resolveInfo.activityInfo.applicationInfo.flags and + ApplicationInfo.FLAG_SYSTEM) != 0 + val isOurApp = resolveInfo.activityInfo.packageName == packageName + + isSystemApp && !isOurApp + } + + systemLauncher?.activityInfo?.packageName ?: "" + } catch (e: Exception) { + LogUtils.e("AppLockManager", "Error getting system default launcher package", e) + "" + } +} + @Suppress("DEPRECATION") fun Context.isServiceRunning(serviceClass: Class<*>): Boolean { val manager = getSystemService(ActivityManager::class.java) ?: return false @@ -75,34 +107,119 @@ object AppLockManager { @Volatile var lockScreenHost: LockScreenHost? = null - // Grace period tracking - private var recentlyLeftApp: String = "" - private var recentlyLeftTime: Long = 0L - private const val GRACE_PERIOD_MS = 300L + /** + * How long an app keeps its unlock after being backgrounded for a neutral surface - the + * launcher, recents, or a system window. Returning to the same app inside this window is + * treated as never having left it, which is what makes "Lock immediately" bearable without + * making it meaningless: hand the phone to someone else and the window has almost always + * already expired. + */ + private const val RETURN_GRACE_PERIOD_MS = 5_000L + + /** + * Unlock durations at or above this are the "Until Screen Off" option rather than a real + * number of minutes. Screen-off wipes [appUnlockTimes], so the timestamp alone carries it. + */ + private const val UNTIL_SCREEN_OFF_THRESHOLD = 10_000 + + private var pendingReturnApp: String = "" + private var pendingReturnSince: Long = 0L + + /** + * Records that [packageName] was backgrounded for a neutral surface and may come straight + * back. Only the caller can tell a neutral surface from a real app switch, so this must not + * be called when the user actually opened something else - use [dropPendingReturn] there. + */ + fun holdUnlockForReturn(packageName: String) { + if (packageName.isEmpty()) return + pendingReturnApp = packageName + pendingReturnSince = System.currentTimeMillis() + LogUtils.d(TAG, "Holding unlock for $packageName, returnable until +${RETURN_GRACE_PERIOD_MS}ms") + } + + /** + * Abandons any pending return. Called once a different app really has the foreground, so the + * window cannot survive an app switch and let the user bounce back in. + */ + fun dropPendingReturn() { + if (pendingReturnApp.isEmpty()) return + LogUtils.d(TAG, "Dropping pending return for $pendingReturnApp") + pendingReturnApp = "" + pendingReturnSince = 0L + } + + /** + * Whether [packageName] is the app currently being held for a return. Lets callers leave the + * hold alone when the held app itself comes back, without consuming it. + */ + fun isPendingReturn(packageName: String): Boolean = + packageName.isNotEmpty() && packageName == pendingReturnApp + + /** + * Restores the unlock if [packageName] is the app we are holding and the window has not + * expired. Consumes the hold either way, so it can never fire twice. + */ + fun consumeReturnGrace(packageName: String, now: Long): Boolean { + if (packageName.isEmpty() || packageName != pendingReturnApp) return false + + val elapsed = now - pendingReturnSince + pendingReturnApp = "" + pendingReturnSince = 0L + + if (elapsed !in 0..RETURN_GRACE_PERIOD_MS) { + LogUtils.d(TAG, "Return grace expired for $packageName (elapsed: ${elapsed}ms)") + return false + } - fun setRecentlyLeftApp(packageName: String) { - recentlyLeftApp = packageName - recentlyLeftTime = System.currentTimeMillis() - LogUtils.d(TAG, "Left app $packageName at $recentlyLeftTime") + LogUtils.d(TAG, "Restoring unlock for $packageName (elapsed: ${elapsed}ms)") + temporarilyUnlockedApp = packageName + return true } - fun checkAndRestoreRecentlyLeftApp(packageName: String): Boolean { - // If we are returning to the same app we just left within the grace period - if (packageName == recentlyLeftApp && packageName.isNotEmpty()) { - val elapsed = System.currentTimeMillis() - recentlyLeftTime - if (elapsed <= GRACE_PERIOD_MS) { - LogUtils.d(TAG, "Restoring unlock state for $packageName (elapsed: ${elapsed}ms)") + /** + * The single answer to "should the lock screen come up for this app right now", shared by all + * three backends so they cannot drift apart. + * + * Deliberately does not consider whether the app is locked at all, whether a lock screen is + * already showing, or whether biometrics are mid-prompt - those are the caller's to check. + */ + fun shouldShowLockScreen( + packageName: String, + now: Long, + unlockDurationMinutes: Int + ): Boolean { + if (isAppTemporarilyUnlocked(packageName)) return false + + if (consumeReturnGrace(packageName, now)) return false + + val unlockTimestamp = appUnlockTimes[packageName] ?: 0L + + if (unlockDurationMinutes > 0 && unlockTimestamp > 0L) { + if (unlockDurationMinutes >= UNTIL_SCREEN_OFF_THRESHOLD) { temporarilyUnlockedApp = packageName - // Clear the tracking so it doesn't trigger again inappropriately - recentlyLeftApp = "" - recentlyLeftTime = 0L - return true - } else { - LogUtils.d(TAG, "Grace period expired for $packageName (elapsed: ${elapsed}ms)") - recentlyLeftApp = "" // Expired + return false } + + val durationMillis = unlockDurationMinutes.toLong() * 60L * 1000L + val elapsedMillis = now - unlockTimestamp + + LogUtils.d( + TAG, + "Grace period check for $packageName: elapsed=${elapsedMillis}ms, duration=${durationMillis}ms" + ) + + if (elapsedMillis < durationMillis) { + // Re-adopt the app, otherwise the manager believes nothing is unlocked while it + // sits in the foreground and never holds the unlock on the next switch away. + temporarilyUnlockedApp = packageName + return false + } + + LogUtils.d(TAG, "Unlock duration expired for $packageName") } - return false + + clearAppUnlockState(packageName) + return true } private val ALL_APP_LOCK_SERVICES = setOf( @@ -146,8 +263,8 @@ object AppLockManager { fun clearAllUnlockStates() { temporarilyUnlockedApp = "" appUnlockTimes.clear() - recentlyLeftApp = "" - recentlyLeftTime = 0L + pendingReturnApp = "" + pendingReturnSince = 0L LogUtils.d(TAG, "Cleared all unlock states") } @@ -156,9 +273,9 @@ object AppLockManager { temporarilyUnlockedApp = "" } appUnlockTimes.remove(packageName) - if (packageName == recentlyLeftApp) { - recentlyLeftApp = "" - recentlyLeftTime = 0L + if (packageName == pendingReturnApp) { + pendingReturnApp = "" + pendingReturnSince = 0L } LogUtils.d(TAG, "Cleared stale unlock state for $packageName") } 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 ff7fa5b8..2d91220a 100644 --- a/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt @@ -30,6 +30,7 @@ class ShizukuAppLockService : Service() { private val appLockRepository: AppLockRepository by lazy { applicationContext.appLockRepository() } private var shizukuActivityManager: ShizukuActivityManager? = null private var previousForegroundPackage = "" + private val launcherPackage: String by lazy { applicationContext.defaultLauncherPackageName() } private val notificationManager: NotificationManager by lazy { getSystemService(NotificationManager::class.java) @@ -187,6 +188,22 @@ class ShizukuAppLockService : Service() { return@ShizukuActivityManager } + // The launcher draws home and recents; landing there is not the same as opening + // another app, so the unlock is held for a short return window instead of dropped. + val isNeutral = packageName == launcherPackage + if (packageName !in triggerExclusions) { + val unlockedApp = AppLockManager.temporarilyUnlockedApp + if (unlockedApp.isNotEmpty() && unlockedApp != packageName) { + if (isNeutral) { + AppLockManager.holdUnlockForReturn(unlockedApp) + } + AppLockManager.clearTemporarilyUnlockedApp() + } + if (!isNeutral && !AppLockManager.isPendingReturn(packageName)) { + AppLockManager.dropPendingReturn() + } + } + LogUtils.d(TAG, "Current package=$packageName, trigger=$triggeringPackage") checkAndLockApp(packageName, triggeringPackage, timeMillis) } @@ -198,33 +215,14 @@ class ShizukuAppLockService : Service() { if (packageName !in lockedApps) return 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()}" + "checkAndLockApp: pkg=$packageName, duration=$unlockDurationMinutes min, currentTime=$currentTime, isLockScreenShown=${AppLockManager.isLockScreenShown.get()}" ) - if (unlockDurationMinutes > 0 && unlockTimestamp > 0) { - if (unlockDurationMinutes >= 10_000) { - return - } - - val durationMillis = unlockDurationMinutes.toLong() * 60_000L - - 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) + if (!AppLockManager.shouldShowLockScreen(packageName, currentTime, unlockDurationMinutes)) { + return } if (AppLockManager.isLockScreenShown.get()) { diff --git a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt index c454f24c..6a063fa7 100644 --- a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt @@ -57,7 +57,7 @@ class UsageLockService: Service() { "Screen off detected in Usage Stats fallback. Resetting AppLock state." ) AppLockManager.isLockScreenShown.set(false) - AppLockManager.clearTemporarilyUnlockedApp() + AppLockManager.clearAllUnlockStates() previousForegroundPackage = "" pauseMonitoring = true } else if (intent?.action == Intent.ACTION_USER_PRESENT) { @@ -133,7 +133,7 @@ class UsageLockService: Service() { try { if (!appLockRepository.isProtectEnabled() || applicationContext.isDeviceLocked()) { if (applicationContext.isDeviceLocked()) { - AppLockManager.appUnlockTimes.clear() + AppLockManager.clearAllUnlockStates() previousForegroundPackage = "" } return @@ -153,6 +153,14 @@ class UsageLockService: Service() { if (isExclusionApp(currentPackage)) return + // A real app taking over ends any pending return - the user went somewhere else + // rather than coming back. The held app itself is exempt: that is the return we allow. + if (!AppLockManager.isPendingReturn(currentPackage) && + currentPackage !in appLockRepository.getTriggerExcludedApps() + ) { + AppLockManager.dropPendingReturn() + } + if (triggeringPackage in appLockRepository.getTriggerExcludedApps()) { return } @@ -206,6 +214,7 @@ class UsageLockService: Service() { if (event.className == "com.android.launcher3.uioverrides.QuickstepLauncher" && event.timeStamp != recentAppTime) { recentApp = null + AppLockManager.holdUnlockForReturn(AppLockManager.temporarilyUnlockedApp) AppLockManager.clearTemporarilyUnlockedApp() continue } @@ -230,33 +239,14 @@ class UsageLockService: Service() { if (packageName !in lockedApps) return 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()}" + "checkAndLockApp: pkg=$packageName, duration=$unlockDurationMinutes min, currentTime=$currentTime, isLockScreenShown=${AppLockManager.isLockScreenShown.get()}" ) - if (unlockDurationMinutes > 0 && unlockTimestamp > 0) { - if (unlockDurationMinutes >= 10_000) { - return - } - - val durationMillis = unlockDurationMinutes.toLong() * 60_000L - - 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) + if (!AppLockManager.shouldShowLockScreen(packageName, currentTime, unlockDurationMinutes)) { + return } if (AppLockManager.isLockScreenShown.get() || AppLockManager.currentBiometricState.toString() == biometricAuthStarted) { From 907ddac480e7aeda8d459c9782a37b1248bc3170 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:45:37 -0300 Subject: [PATCH 06/31] feat: disguise the app as System Services A thief looking for a lock app to uninstall found this one at a glance: a padlock icon named "App Lock" in the launcher, the same name in Settings > Apps, Accessibility, Device admin apps, the uninstall dialog and Recents, and a shield notification saying "Protecting your apps". Everywhere outside the PIN it now passes for a system component: "System Services" with a generic gear. The disguise is fixed at build time rather than switchable from settings. Only the launcher entry can change at runtime, through activity-aliases. Settings > Apps, Accessibility, Device admin and the uninstall dialog read the label baked into the APK, so a switchable disguise would still say "App Lock" exactly where a thief goes to remove it. - app_name, the accessibility and device admin descriptions, and the service notification (title, text, channel name, icon) no longer mention locking. - The adaptive launcher icon is a Material gear on blue-grey, with a monochrome layer for themed icons. The padlock bitmaps are gone. - MainActivity turns off its Recents screenshot on Android 13+, which would otherwise show the list of locked apps under the disguised name. - The toast shown when device admin deactivation is blocked no longer tells whoever holds the phone where anti-uninstall is switched off. The anti-uninstall guard recognised its own Settings pages by the literal text "App Lock", so the rename alone would have silently disabled it. It now matches the app's resolved label and follows any future rename. Its uninstall-dialog check lowercased the event text before looking for "App Lock", so it could never match; that is fixed along the way. Unchanged: the package name, since changing it makes a new app and drops the accessibility and device admin grants, and the screens behind the PIN. Not compiled: this environment has no JDK or Android SDK. The device pass is still outstanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nk8mXJmfRpdZNfLbKEwxUy --- .../java/dev/pranav/applock/MainActivity.kt | 6 + .../services/AppLockAccessibilityService.kt | 11 +- .../applock/services/ShizukuAppLockService.kt | 8 +- .../applock/services/UsageLockService.kt | 8 +- .../main/res/drawable/baseline_shield_24.xml | 12 -- .../res/drawable/ic_launcher_background.xml | 162 +----------------- .../res/drawable/ic_launcher_foreground.xml | 35 ++-- app/src/main/res/drawable/ic_notification.xml | 7 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 3 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 3 +- .../mipmap-hdpi/ic_launcher_foreground.webp | Bin 6506 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 4754 -> 0 bytes .../mipmap-mdpi/ic_launcher_foreground.webp | Bin 3696 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 2816 -> 0 bytes .../mipmap-xhdpi/ic_launcher_foreground.webp | Bin 9910 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 6998 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_foreground.webp | Bin 17210 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 12264 -> 0 bytes .../ic_launcher_foreground.webp | Bin 26036 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 18222 -> 0 bytes app/src/main/res/values-ar/strings.xml | 8 +- app/src/main/res/values/strings.xml | 13 +- 22 files changed, 53 insertions(+), 223 deletions(-) delete mode 100644 app/src/main/res/drawable/baseline_shield_24.xml delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/app/src/main/java/dev/pranav/applock/MainActivity.kt b/app/src/main/java/dev/pranav/applock/MainActivity.kt index 22d8d958..a8195055 100644 --- a/app/src/main/java/dev/pranav/applock/MainActivity.kt +++ b/app/src/main/java/dev/pranav/applock/MainActivity.kt @@ -1,5 +1,6 @@ package dev.pranav.applock +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -20,6 +21,11 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + // The Recents thumbnail would show the list of locked apps and give the disguise away. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(false) + } + navigationManager = NavigationManager(this) setContent { 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 36df8f5d..a2448140 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -32,6 +32,9 @@ class AppLockAccessibilityService : AccessibilityService() { private val appLockRepository: AppLockRepository by lazy { applicationContext.appLockRepository() } private val keyboardPackages: List by lazy { getKeyboardPackageNames() } + // Our label as Settings displays it; the anti-uninstall checks match on it, so they follow any rename. + private val ownLabel: String by lazy { applicationInfo.loadLabel(packageManager).toString() } + private var lastForegroundPackage = "" private var overlayManager: LockScreenOverlayManager? = null @@ -431,12 +434,12 @@ class AppLockAccessibilityService : AccessibilityService() { private fun isDeactivationAttempt(event: AccessibilityEvent): Boolean { val isAccessibilitySettings = event.className in ACCESSIBILITY_SETTINGS_CLASSES && - event.text.any { it.contains("App Lock") } + event.text.any { it.contains(ownLabel) } val isSubSettings = event.className == "com.android.settings.SubSettings" && - event.text.any { it.contains("App Lock") } + event.text.any { it.contains(ownLabel) } val isAlertDialog = event.packageName == "com.google.android.packageinstaller" && event.className == "android.app.AlertDialog" && event.text.toString() - .lowercase().contains("App Lock") + .contains(ownLabel) return isAccessibilitySettings || isSubSettings || isAlertDialog } @@ -477,7 +480,7 @@ class AppLockAccessibilityService : AccessibilityService() { performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) Toast.makeText( this, - "Disable anti-uninstall from AppLock settings to remove this restriction.", + "This action isn't allowed.", Toast.LENGTH_LONG ).show() Log.w(TAG, "Blocked device admin deactivation attempt.") 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 2d91220a..87c36d11 100644 --- a/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/ShizukuAppLockService.kt @@ -153,7 +153,7 @@ class ShizukuAppLockService : Service() { private fun createNotificationChannel() { val serviceChannel = NotificationChannel( CHANNEL_ID, - "AppLock Service", + getString(R.string.service_notification_channel), NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(serviceChannel) @@ -161,9 +161,9 @@ class ShizukuAppLockService : Service() { private fun createNotification(): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("AppLock") - .setContentText("Protecting your apps with Shizuku") - .setSmallIcon(R.drawable.baseline_shield_24) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.service_notification_text)) + .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_MIN) .setOngoing(true) .build() diff --git a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt index 6a063fa7..4aac8ed6 100644 --- a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt @@ -304,7 +304,7 @@ class UsageLockService: Service() { private fun createNotificationChannel() { val serviceChannel = NotificationChannel( CHANNEL_ID, - "AppLock Service (Usage Stats)", + getString(R.string.service_notification_channel), NotificationManager.IMPORTANCE_DEFAULT ) notificationManager.createNotificationChannel(serviceChannel) @@ -312,9 +312,9 @@ class UsageLockService: Service() { private fun createNotification(): Notification { return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("App Lock") - .setContentText("Protecting your apps") - .setSmallIcon(R.drawable.baseline_shield_24) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.service_notification_text)) + .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_MIN) .setOngoing(true) .build() diff --git a/app/src/main/res/drawable/baseline_shield_24.xml b/app/src/main/res/drawable/baseline_shield_24.xml deleted file mode 100644 index 2f2c80c3..00000000 --- a/app/src/main/res/drawable/baseline_shield_24.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index 07d5da9c..1da29101 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -5,166 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 7706ab9e..799ee287 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,17 @@ + + - - - - - - - - - + + + diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml index a10bbb92..87e5c07c 100644 --- a/app/src/main/res/drawable/ic_notification.xml +++ b/app/src/main/res/drawable/ic_notification.xml @@ -2,10 +2,9 @@ + android:viewportWidth="24" + android:viewportHeight="24"> + android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" /> - 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 3ba4e35c..b3e26b4c 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 @@ - + + 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 3ba4e35c..b3e26b4c 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 @@ - + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp deleted file mode 100644 index ea86d36a886d1757c0eeb27df3987a183f4fe750..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6506 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 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp deleted file mode 100644 index dd1c2f2addb0afd5a3bad58c0469e694e1f2f790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3696 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 Oo7?jBD(6N(e7Rv^;r0gr diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 8e420dbc0c52c4e14059e5758b6b84a3f73731b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2816 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 S<`}=uWHaLhM%U7iuwDRkx-T>U diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp deleted file mode 100644 index 8f229aa26bec966a8f192f25d0d56b1873f88a70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9910 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 NRp@8`|Lp(&mT-sy?AQPR diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 0771c88f01e5c3d2fada9f5a6da70beebc1e72e1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12264 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 - قفل التطبيقات - يمنع الأشخاص غير المصرح لهم من إلغاء تثبيت التطبيق. - يسمح لقفل التطبيقات باكتشاف فتح التطبيقات المحمية وعرض شاشة التحقق بكلمة المرور. + خدمات النظام + مطلوب لكي تعمل خدمات النظام بشكل صحيح. + مطلوب لكي تعمل خدمات النظام بشكل صحيح. + خدمة في الخلفية + قيد التشغيل diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4f6319b..c5989a09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,11 @@ - App Lock + + System Services - Prevents unauthorized people from uninstalling the app. - Allows App Lock to detect when protected apps are opened and show password verification. + Required for system services to work correctly. + Required for system services to work correctly. + Background service + Running Enter your password to disable admin permission. Incorrect PIN. Please try again. @@ -37,7 +40,7 @@ Accessibility service is required for AppLock 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 > System Services > Upper Right menu, and press \"Allow restricted settings\". Tap \'Next\' to enable it. @@ -45,7 +48,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 > System Services > Upper Right menu, and press \"Allow restricted settings\". Tap \'Next\' to enable it. From b80e67afe05762512cea58632013ed972504ce71 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:50:15 -0300 Subject: [PATCH 07/31] fix: block our own App info page on OEMs that title it generically The App info screen holds Uninstall, Force stop and Clear data, so anti-uninstall tried to bounce the user off it. It only ever looked at the accessibility event's text for the app name, but on One UI (and most OEMs) that event carries the generic page title - "App info" - and never the app name, so the check could not match under any name. Confirmed from a device log on a Galaxy S24 Ultra, One UI 8.5: the page arrives as SubSettings with text "App info". Read the window content instead, where the name sits as the header under the icon. Restricted to window transitions: the apps list is also a SubSettings window, but it opens scrolled to the top with our entry off screen, and scrolling it emits a content-changed event we ignore - so this does not fire just because our row is visible in the list. Device admin already greys Force stop and blocks Uninstall while it is active; this closes the reach to Clear data and covers the moments admin is briefly off. Not compiled: this environment has no JDK or Android SDK. The device pass is still outstanding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nk8mXJmfRpdZNfLbKEwxUy --- .../services/AppLockAccessibilityService.kt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 a2448140..01a4e654 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -418,6 +418,13 @@ class AppLockAccessibilityService : AccessibilityService() { return } + // Check if user reached our own App info page (Uninstall / Force stop / Clear data live here) + if (isOwnAppInfoPage(event)) { + Log.d(TAG, "Blocking own app info page") + blockDeactivationAttempt() + return + } + // Check if on device admin page and our app is visible val isDeviceAdminPage = isDeviceAdminPage(event) //val isOurAppVisible = findNodeWithTextContaining(rootNode, "App Lock") != null || @@ -444,6 +451,27 @@ class AppLockAccessibilityService : AccessibilityService() { return isAccessibilitySettings || isSubSettings || isAlertDialog } + /** + * True when the foreground window is our own App info page in Settings, where Uninstall, Force + * stop and Clear data live. + * + * The page title arrives in [AccessibilityEvent.getText] as a generic string ("App info" on + * One UI), never the app name, so the old name-in-event check could not catch it on most OEMs. + * The name is in the window content instead, as the header under the icon, so we read that. + * + * Only inspected on a window transition ([AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED]): the + * apps list is also a SubSettings window, but it opens scrolled to the top with our entry off + * screen, and scrolling it emits TYPE_WINDOW_CONTENT_CHANGED, which we ignore here. So this + * does not fire merely because our row happens to be visible in the list. + */ + private fun isOwnAppInfoPage(event: AccessibilityEvent): Boolean { + if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return false + if (event.packageName != DEVICE_ADMIN_SETTINGS_PACKAGE) return false + + val root = rootInActiveWindow ?: return false + return findNodeWithTextContaining(root, ownLabel) != null + } + @SuppressLint("InlinedApi") private fun blockDeactivationAttempt() { try { From 528002e31158353f11bb836f7da9330094d8cd70 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:55:19 -0300 Subject: [PATCH 08/31] docs: document the disguise and anti-uninstall behaviour The disguise and what anti-uninstall does on each Settings page were not written down anywhere - the README only listed "anti-uninstall protection" as a feature. Add ANTI_UNINSTALL.md, alongside AUTOMATION.md, covering what the app disguises itself as and why that is fixed at build time, what each Settings surface does when protection is on, that it blocks the owner too (disable it with the PIN first), how each button is covered, and the limitations - OEM variance, the generic disguise name, and that it is not tamper-proof against adb or recovery. Link it from the README feature list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nk8mXJmfRpdZNfLbKEwxUy --- ANTI_UNINSTALL.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 ANTI_UNINSTALL.md diff --git a/ANTI_UNINSTALL.md b/ANTI_UNINSTALL.md new file mode 100644 index 00000000..793dc8b9 --- /dev/null +++ b/ANTI_UNINSTALL.md @@ -0,0 +1,70 @@ +# Disguise and anti-uninstall + +This fork hardens AppLock against a thief who steals an unlocked phone and wants the lock gone. It +does two things: it stops looking like a lock app, and it resists the ways an app is normally +removed or stopped. + +## Disguise + +Outside the PIN screen the app presents itself as a system component, so a thief scanning the +launcher or Settings has nothing obvious to remove: + +- **Name:** *System Services* (Arabic: *خدمات النظام*). +- **Icon:** a plain gear, matching themed icons on Android 13+. +- **Notification:** the background-service notification reads *System Services / Running*, not + "Protecting your apps". +- **Recents:** on Android 13+ the app shows a blank thumbnail instead of your list of locked apps. + +The disguise is **fixed at build time, not a setting.** Only the launcher entry could be swapped at +runtime; Settings → Apps, Accessibility, Device admin apps and the uninstall dialog all read the +name baked into the APK, which is exactly where a thief goes to remove the app. Changing the +disguise means editing `app_name` and the launcher icon and rebuilding. + +Two things are deliberately left alone: + +- **Screens behind the PIN** (intro, settings, dialogs) still say AppLock. You only reach them after + unlocking, and the PIN pad itself carries no branding. +- **The package name** `dev.pranav.applock` is unchanged, so the update installs over your existing + app and keeps its settings and permissions. It shows only in advanced App info or over `adb`. + +## Anti-uninstall + +Turn on **Settings → Anti-Uninstall Protection**. It needs two permissions — Device Administrator +and the Accessibility Service — and with both granted the app resists removal. + +With it **on**, these are what each Settings surface does: + +| On the phone, go to | What happens | +|---|---| +| Settings → Accessibility → System Services | Sent to the home screen and the screen locks | +| Settings → Apps → System Services | Bounced off the page and the screen locks | +| Settings → Apps → System Services → Uninstall | Uninstall is blocked by device admin | +| Device admin apps (path varies by phone) | Bounced off, with the toast "This action isn't allowed." | +| Ordinary pages (Wi-Fi, Display, …) | Nothing happens; they work normally | + +> **It blocks you, too.** These pages bounce whoever opens them, including you. To change any of +> these settings yourself, first turn Anti-Uninstall Protection **off** — that asks for your PIN — +> and then open the page. + +### How each button is covered + +- **Uninstall** — blocked directly. Granting Device Admin calls `setUninstallBlocked`, which greys + the button out. +- **Force stop** — greyed out by Android for any app with an active device admin. It briefly becomes + tappable only while device admin is off (for example, right after an update, before the app + re-arms). +- **Clear data / Force stop / anything else on App info** — the accessibility service bounces you + off the app's own App info page before you can reach them. + +### Limitations + +- **OEM variance.** Settings pages are named differently on each skin. The detection is tuned + against stock Android and One UI (tested on a Galaxy S24 Ultra, One UI 8.5); another skin may + title a page differently and slip past. If a page isn't caught on your phone, enable + **Settings → Advanced → Logging**, reproduce it, and export the security log — the class name and + on-screen text in there are what the check needs. +- **The disguise name is generic.** The App info bounce fires when "System Services" appears on a + Settings page as it opens. If some unrelated page happens to contain that text, it could bounce + you off it too. +- **Not tamper-proof.** A determined attacker with `adb`, recovery, or safe mode can still remove + the app. This raises the bar against an opportunistic thief, not a forensic one. diff --git a/README.md b/README.md index 43b060fb..254bd52b 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ root required. - Biometric and PIN authentication - Fingerprint, Face Unlock, and PIN support - Lock any app on your device -- Anti-uninstall protection +- Anti-uninstall protection ([how it behaves, and the "System Services" disguise](ANTI_UNINSTALL.md)) - Anti-uninstall protection for user apps - Unlock timeout for convenience - No root required From f8680babc65379b0845aaeea94498602653ec703 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:10:40 -0300 Subject: [PATCH 09/31] feat: make the biometric prompt read like a native system prompt The system biometric sheet always shows the calling app's label and icon at the top - now "System Services" with the gear - which an app cannot hide. The lines below it are ours, and they gave the game away or clashed with the disguise: the in-app prompt said "Confirm password", and the over-app prompt said "Unlock WhatsApp", naming the very app a locker would hide. All three prompt sites (in-app unlock, the transparent biometric activity, and the overlay activity) now share one title, "Verify it's you" - the phrasing the platform's own prompts use - drawn from a single string resource. The redundant subtitle is dropped; the system already renders "Scan your fingerprint" beneath it. "Use PIN" stays as the fallback button. Removes the now-unused this_app, unlock_app_title and confirm_biometric_subtitle strings and the hardcoded title/subtitle constants in AppNavigator. The device-credential prompt for resetting the PIN is a separate, behind-the-PIN flow and is left as it was. Not compiled: this environment has no JDK or Android SDK. The device pass is still outstanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nk8mXJmfRpdZNfLbKEwxUy --- .../pranav/applock/core/navigation/AppNavigator.kt | 13 +++++-------- .../features/lockscreen/ui/PasswordOverlayScreen.kt | 4 +--- .../lockscreen/ui/TransparentBiometricActivity.kt | 4 +--- app/src/main/res/values/strings.xml | 5 ++--- 4 files changed, 9 insertions(+), 17 deletions(-) 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 2b73fb30..84c15485 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 @@ -16,6 +16,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import dev.pranav.applock.AppLockApplication +import dev.pranav.applock.R import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.antiuninstall.ui.AntiUninstallScreen @@ -189,18 +190,17 @@ private fun handleBiometricAuthentication( } ) - val promptInfo = createBiometricPromptInfo() + val promptInfo = createBiometricPromptInfo(context) biometricPrompt.authenticate(promptInfo) } catch (e: Exception) { Log.e(TAG, "Error during biometric authentication", e) } } -private fun createBiometricPromptInfo(): BiometricPrompt.PromptInfo { +private fun createBiometricPromptInfo(context: android.content.Context): BiometricPrompt.PromptInfo { return BiometricPrompt.PromptInfo.Builder() - .setTitle(BIOMETRIC_TITLE) - .setSubtitle(BIOMETRIC_SUBTITLE) - .setNegativeButtonText(BIOMETRIC_NEGATIVE_BUTTON) + .setTitle(context.getString(R.string.biometric_verify_title)) + .setNegativeButtonText(context.getString(R.string.use_pin_button)) .setAllowedAuthenticators( BiometricManager.Authenticators.BIOMETRIC_WEAK or BiometricManager.Authenticators.BIOMETRIC_STRONG @@ -226,6 +226,3 @@ 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_SUBTITLE = "Confirm biometric to continue" -private const val BIOMETRIC_NEGATIVE_BUTTON = "Use PIN" 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 c485bd3c..828f7139 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 @@ -248,10 +248,8 @@ class PasswordOverlayActivity: FragmentActivity() { biometricPrompt = BiometricPrompt(this@PasswordOverlayActivity, executor, authenticationCallbackInternal) - val appNameForPrompt = appName.ifEmpty { getString(R.string.this_app) } promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.unlock_app_title, appNameForPrompt)) - .setSubtitle(getString(R.string.confirm_biometric_subtitle)) + .setTitle(getString(R.string.biometric_verify_title)) .setNegativeButtonText(getString(R.string.use_pin_button)) .setAllowedAuthenticators( BiometricManager.Authenticators.BIOMETRIC_WEAK or diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt index 89c1dfac..8bcbab74 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt @@ -79,10 +79,8 @@ class TransparentBiometricActivity: FragmentActivity() { val executor = ContextCompat.getMainExecutor(this) val biometricPrompt = BiometricPrompt(this, executor, authenticationCallback) - val appNameForPrompt = getString(R.string.this_app) val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(getString(R.string.unlock_app_title, appNameForPrompt)) - .setSubtitle(getString(R.string.confirm_biometric_subtitle)) + .setTitle(getString(R.string.biometric_verify_title)) .setNegativeButtonText(getString(R.string.use_pin_button)) .setAllowedAuthenticators( BiometricManager.Authenticators.BIOMETRIC_WEAK or diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5989a09..66e276d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -64,9 +64,8 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant App - this app - Unlock %1$s - Confirm biometric to continue + + Verify it\'s you Use PIN Enter password to continue Biometric Authentication From 5d9935c519719559247cf4112c70de8cbc0cf63d Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:59:52 -0300 Subject: [PATCH 10/31] fix: stop anti-uninstall locking the phone while browsing Settings Three ways the guard fired on the wrong screen, all with anti-uninstall on: - Settings > Accessibility > Installed apps, and Settings > Apps after returning to a list scrolled so our row shows, locked the phone. The App info check keyed on our name being anywhere in the window, and those lists carry our name as one row. It also assumed the apps list always opens at the top; pressing back restores the scroll position instead. Require our version number on screen too: Settings prints it on the App info page, app lists never do, and a version needs no localised string to match. It fails open - if a page has no version the guard stays quiet rather than locking the wrong screen, and Uninstall and Force stop there are already blocked and greyed by device admin. - Settings > Security > Device admin apps bounced the user for every app, and any popup there locked the screen. isDeviceAdminPage matched on the class name alone, the admin list and other apps' admin pages included. Narrow it to the single-admin confirmation page (DeviceAdminAdd) for our own admin, confirmed by our name in the window. Deactivating ours still routes through that page, and the block only acts while our admin is active, so granting it still works. Drop the dead content-description branch that lowercased its text before matching a capitalised string. Not compiled: this environment has no JDK or Android SDK. The device pass is still outstanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nk8mXJmfRpdZNfLbKEwxUy --- .../services/AppLockAccessibilityService.kt | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) 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 01a4e654..38b4946d 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -35,6 +35,17 @@ class AppLockAccessibilityService : AccessibilityService() { // Our label as Settings displays it; the anti-uninstall checks match on it, so they follow any rename. private val ownLabel: String by lazy { applicationInfo.loadLabel(packageManager).toString() } + // Our version as Settings prints it on the App info page. Lists of apps never show a version, + // which is what separates that page from a list our name merely appears in. + private val ownVersionName: String by lazy { + try { + packageManager.getPackageInfo(packageName, 0).versionName.orEmpty() + } catch (e: Exception) { + logError("Could not read own version name", e) + "" + } + } + private var lastForegroundPackage = "" private var overlayManager: LockScreenOverlayManager? = null @@ -425,14 +436,12 @@ class AppLockAccessibilityService : AccessibilityService() { 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 + // Check if on the confirmation page for our own device admin + val isOwnDeviceAdminPage = isOwnDeviceAdminPage(event) - LogUtils.d(TAG, "User is on device admin page: $isDeviceAdminPage, $event") + LogUtils.d(TAG, "User is on our device admin page: $isOwnDeviceAdminPage, $event") - if (!isDeviceAdminPage) { + if (!isOwnDeviceAdminPage) { return } @@ -459,17 +468,24 @@ class AppLockAccessibilityService : AccessibilityService() { * One UI), never the app name, so the old name-in-event check could not catch it on most OEMs. * The name is in the window content instead, as the header under the icon, so we read that. * - * Only inspected on a window transition ([AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED]): the - * apps list is also a SubSettings window, but it opens scrolled to the top with our entry off - * screen, and scrolling it emits TYPE_WINDOW_CONTENT_CHANGED, which we ignore here. So this - * does not fire merely because our row happens to be visible in the list. + * Matching the name alone is not enough. The apps list and Accessibility > Installed apps are + * windows that carry our name too, as one row among many, and returning to either restores the + * scroll position that shows it - which locked the phone while merely browsing Settings. So + * require our version number on screen as well: Settings prints it on the App info page, app + * lists never do, and a version needs no localised string to recognise. + * + * Fails open. If an OEM's page omits the version this does not fire, leaving that page + * unguarded rather than locking the phone on the wrong screen - and Uninstall and Force stop + * there are blocked and greyed out by device admin regardless. */ private fun isOwnAppInfoPage(event: AccessibilityEvent): Boolean { if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return false if (event.packageName != DEVICE_ADMIN_SETTINGS_PACKAGE) return false + if (ownVersionName.isEmpty()) return false val root = rootInActiveWindow ?: return false - return findNodeWithTextContaining(root, ownLabel) != null + return findNodeWithTextContaining(root, ownLabel) != null && + findNodeWithTextContaining(root, ownVersionName) != null } @SuppressLint("InlinedApi") @@ -483,15 +499,23 @@ class AppLockAccessibilityService : AccessibilityService() { } } - 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") + /** + * True when the foreground window is the confirmation page for *our* device admin - the page + * carrying the button that deactivates it. + * + * Narrower than it was. It used to match on the class name alone, the device admin list + * included, so opening that section or any other app's admin page bounced the user out. + * Deactivating ours still has to pass through this page, and [blockDeviceAdminDeactivation] + * only acts while our admin is already active, so granting it in the first place still works. + * + * The old content-description branch is gone with it: it lowercased the text and then looked + * for "Device admin app", so it could never match. + */ + private fun isOwnDeviceAdminPage(event: AccessibilityEvent): Boolean { + if (event.className?.contains("DeviceAdminAdd") != true) return false - return hasDeviceAdminDescription || isAdminConfigClass + val root = rootInActiveWindow ?: return false + return findNodeWithTextContaining(root, ownLabel) != null } @SuppressLint("InlinedApi") From fcae9e7807a07b79e489293e4db9f450971f985e Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:09:37 -0300 Subject: [PATCH 11/31] fix: stop granting device admin from crashing the app DeviceAdmin.onEnabled called DevicePolicyManager.setUninstallBlocked, which only a device owner or profile owner may call. This app is neither, so the call threw SecurityException and crashed the app every time device admin was granted, possibly before the anti_uninstall flag written just above it with apply() had reached disk. The call never blocked anything. What stops the app being uninstalled is the admin being active, and the system records that before onEnabled runs. Remove the call, and write the flag with commit so it is on disk before the receiver returns. ANTI_UNINSTALL.md credited setUninstallBlocked with blocking Uninstall; it now credits the active admin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- ANTI_UNINSTALL.md | 4 ++-- .../dev/pranav/applock/core/broadcast/DeviceAdmin.kt | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/ANTI_UNINSTALL.md b/ANTI_UNINSTALL.md index 793dc8b9..13fc51d5 100644 --- a/ANTI_UNINSTALL.md +++ b/ANTI_UNINSTALL.md @@ -48,8 +48,8 @@ With it **on**, these are what each Settings surface does: ### How each button is covered -- **Uninstall** — blocked directly. Granting Device Admin calls `setUninstallBlocked`, which greys - the button out. +- **Uninstall** — blocked by Android for as long as Device Admin is active: the app can't be removed + until its admin is turned off, and the page for turning it off bounces you (see the table above). - **Force stop** — greyed out by Android for any app with an active device admin. It briefly becomes tappable only while device admin is off (for example, right after an update, before the app re-arms). 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 dca23886..6569a512 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 @@ -1,7 +1,6 @@ package dev.pranav.applock.core.broadcast import android.app.admin.DeviceAdminReceiver -import android.content.ComponentName import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit @@ -12,15 +11,15 @@ class DeviceAdmin : DeviceAdminReceiver() { private const val KEY_PASSWORD_VERIFIED = "password_verified" } + /** + * An active admin is itself what blocks uninstalling the app. Don't call setUninstallBlocked + * here: only a device or profile owner may, so it throws and crashes the app. + */ override fun onEnabled(context: Context, intent: android.content.Intent) { super.onEnabled(context, intent) - context.getSharedPreferences("app_lock_settings", Context.MODE_PRIVATE).edit { + context.getSharedPreferences("app_lock_settings", Context.MODE_PRIVATE).edit(commit = true) { 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) { From 10d89dd7824cc63e8cd0e52cad8df12469928e28 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:09:37 -0300 Subject: [PATCH 12/31] fix: re-lock unlocked apps when protection is switched back on in the app Turning protection back on through automation clears every unlock and restarts the lock service, so nothing unlocked while protection was off stays open. The shield toggle on the main screen wrote the same flag but skipped both steps: switch it off and on again, and an app you had already unlocked stayed unlocked. Both paths now go through AutomationReceiver.setProtection, so they cannot drift apart again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../core/broadcast/AutomationReceiver.kt | 44 ++++++++++--------- .../applock/features/applist/ui/MainScreen.kt | 2 +- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt b/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt index 170961a5..b3fd6d79 100644 --- a/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageManager import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.SecurityUtils import dev.pranav.applock.core.utils.appLockRepository -import dev.pranav.applock.data.repository.AppLockRepository import dev.pranav.applock.services.AppLockManager /** @@ -47,8 +46,8 @@ class AutomationReceiver : BroadcastReceiver() { } when (action) { - ACTION_ENABLE_PROTECTION -> setProtection(context, repository, true) - ACTION_DISABLE_PROTECTION -> setProtection(context, repository, false) + ACTION_ENABLE_PROTECTION -> setProtection(context, true) + ACTION_DISABLE_PROTECTION -> setProtection(context, false) ACTION_QUERY_STATE -> LogUtils.d(TAG, "State queried via automation") else -> { reject(context, "unknown action", action) @@ -61,23 +60,6 @@ class AutomationReceiver : BroadcastReceiver() { notifyStateChanged(context, enabled) } - private fun setProtection(context: Context, repository: AppLockRepository, enabled: Boolean) { - repository.setProtectEnabled(enabled) - - if (enabled) { - // Fail closed: an app unlocked while protection was off must not stay unlocked once - // protection comes back, and the backend service may have been stopped meanwhile. - AppLockManager.clearAllUnlockStates() - try { - AppLockServiceStarter.startAppropriateServices(context, repository) - } catch (e: Exception) { - LogUtils.e(TAG, "Failed to start services after enabling protection", e) - } - } - - LogUtils.d(TAG, "Protection set to $enabled via automation") - } - private fun reject(context: Context, reason: String, action: String?) { LogUtils.e(TAG, "Rejected automation request '$action': $reason") reportResult(RESULT_REJECTED, reason) @@ -110,6 +92,28 @@ class AutomationReceiver : BroadcastReceiver() { const val RESULT_PROTECTION_OFF = 1 const val RESULT_PROTECTION_ON = 2 + /** + * Switches protection on or off. The shield toggle on the main screen and automation both + * go through here, so turning protection back on re-locks apps the same way from either. + */ + fun setProtection(context: Context, enabled: Boolean) { + val repository = context.appLockRepository() + repository.setProtectEnabled(enabled) + + if (enabled) { + // Fail closed: an app unlocked while protection was off must not stay unlocked once + // protection comes back, and the backend service may have been stopped meanwhile. + AppLockManager.clearAllUnlockStates() + try { + AppLockServiceStarter.startAppropriateServices(context, repository) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to start services after enabling protection", e) + } + } + + LogUtils.d(TAG, "Protection set to $enabled") + } + /** * Announces the current protection state. Sent implicitly so automation apps can pick it * up with a dynamically registered receiver; it carries nothing but the on/off flag. 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 dbea83b3..369572ac 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 @@ -165,7 +165,7 @@ fun MainScreen( actions = { Surface( onClick = { - appLockRepository.setProtectEnabled(!applockEnabled) + AutomationReceiver.setProtection(context, !applockEnabled) applockEnabled = !applockEnabled AutomationReceiver.notifyStateChanged(context, applockEnabled) }, From 4541a14b4c5f2d2c0b36fb3affbb89607ee41e06 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:09:37 -0300 Subject: [PATCH 13/31] fix: accept only strong biometrics for unlocking The biometric prompts accepted BIOMETRIC_WEAK as well as BIOMETRIC_STRONG. On many phones the weak class is 2D face unlock, which a photo can fool: an easier way past the lock than the PIN behind it. All three prompts now ask for BIOMETRIC_STRONG only: the lock screen activity, the prompt raised over the accessibility overlay, and the one guarding the app itself. canAuthenticateBiometrics asks the same, so on a phone with only weak biometrics the overlay no longer auto-prompts and Settings shows biometrics as unavailable. If biometrics were switched on before, the lock screens still show the button, but the prompt fails straight back to the PIN. On a Galaxy S24 Ultra this leaves fingerprint only, since Samsung's camera face unlock is not Class 3. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../dev/pranav/applock/core/navigation/AppNavigator.kt | 5 +---- .../java/dev/pranav/applock/core/utils/AppLockUtils.kt | 9 +++++---- .../features/lockscreen/ui/PasswordOverlayScreen.kt | 5 +---- .../lockscreen/ui/TransparentBiometricActivity.kt | 5 +---- 4 files changed, 8 insertions(+), 16 deletions(-) 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 84c15485..00ca6b3e 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 @@ -201,10 +201,7 @@ private fun createBiometricPromptInfo(context: android.content.Context): Biometr return BiometricPrompt.PromptInfo.Builder() .setTitle(context.getString(R.string.biometric_verify_title)) .setNegativeButtonText(context.getString(R.string.use_pin_button)) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() } diff --git a/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt index 04ef1df8..e5377746 100644 --- a/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt +++ b/app/src/main/java/dev/pranav/applock/core/utils/AppLockUtils.kt @@ -118,11 +118,12 @@ fun Context.appLockRepository(): AppLockRepository = (applicationContext as AppLockApplication).appLockRepository /** - * Whether the device can actually authenticate the user right now - biometric hardware is present, - * available and something is enrolled. + * Whether the device can actually authenticate the user right now with a strong (Class 3) + * biometric - the hardware is present, available and something is enrolled. The prompts accept + * nothing weaker, since weak biometrics such as 2D face unlock can be fooled by a photo, so a device + * that only has weak ones counts as having none. */ fun Context.canAuthenticateBiometrics(): Boolean = BiometricManager.from(this).canAuthenticate( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG + BiometricManager.Authenticators.BIOMETRIC_STRONG ) == BiometricManager.BIOMETRIC_SUCCESS 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 828f7139..91f78c7a 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 @@ -251,10 +251,7 @@ class PasswordOverlayActivity: FragmentActivity() { promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(getString(R.string.biometric_verify_title)) .setNegativeButtonText(getString(R.string.use_pin_button)) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt index 8bcbab74..2c2f327b 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt @@ -82,10 +82,7 @@ class TransparentBiometricActivity: FragmentActivity() { val promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(getString(R.string.biometric_verify_title)) .setNegativeButtonText(getString(R.string.use_pin_button)) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() From 085524767093efb16a5f1a1f00507e05817d70a5 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:09:37 -0300 Subject: [PATCH 14/31] fix: keep the anti-uninstall guards working after a language change The guards on the Accessibility page, the app's own App info page and its device admin page recognise them by the app's name as Settings shows it. That name is translated, but the service read it once and kept it until the service restarted, normally at reboot. After a change of language, every guard kept looking for the old name while Settings showed the new one, so none of them fired. Read the label on every check instead. PackageManager already caches label strings and clears that cache on a configuration change, so the lookup stays cheap and follows the current language. Found by reading the code; not yet reproduced on a device. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../applock/services/AppLockAccessibilityService.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 38b4946d..4cd4467a 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -33,7 +33,11 @@ class AppLockAccessibilityService : AccessibilityService() { private val keyboardPackages: List by lazy { getKeyboardPackageNames() } // Our label as Settings displays it; the anti-uninstall checks match on it, so they follow any rename. - private val ownLabel: String by lazy { applicationInfo.loadLabel(packageManager).toString() } + // Looked up on every check rather than cached: the label is translated, and a cached copy keeps + // the old language's name after the phone's language changes, so every check would miss. + // PackageManager caches the string itself and drops that cache on a configuration change. + private val ownLabel: String + get() = applicationInfo.loadLabel(packageManager).toString() // Our version as Settings prints it on the App info page. Lists of apps never show a version, // which is what separates that page from a list our name merely appears in. @@ -449,13 +453,14 @@ class AppLockAccessibilityService : AccessibilityService() { } private fun isDeactivationAttempt(event: AccessibilityEvent): Boolean { + val label = ownLabel val isAccessibilitySettings = event.className in ACCESSIBILITY_SETTINGS_CLASSES && - event.text.any { it.contains(ownLabel) } + event.text.any { it.contains(label) } val isSubSettings = event.className == "com.android.settings.SubSettings" && - event.text.any { it.contains(ownLabel) } + event.text.any { it.contains(label) } val isAlertDialog = event.packageName == "com.google.android.packageinstaller" && event.className == "android.app.AlertDialog" && event.text.toString() - .contains(ownLabel) + .contains(label) return isAccessibilitySettings || isSubSettings || isAlertDialog } From 8aff4bd36a3cd44cd1e5762e6fc7adc9b9a0c5ba Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:44:11 -0300 Subject: [PATCH 15/31] fix: keep the anti-uninstall toggle in Settings up to date The Settings screen read the anti-uninstall flag once, when it was first shown, but the flag changes away from that screen: granting device admin turns it on in DeviceAdmin.onEnabled, and turning it off goes through AdminDisableActivity. Coming back, the toggle still showed the old value until Settings was left and reopened. The crash on granting device admin used to hide this in that direction by restarting the app, so fixing the crash made it visible: the toggle showed OFF while anti-uninstall was on. Listen for changes to the stored flag instead. Re-reading it when the screen resumes would race the broadcast that delivers onEnabled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../applock/features/settings/ui/SettingsScreen.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 16feeb54..f15d18ce 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 @@ -109,6 +109,19 @@ fun SettingsScreen( val isBiometricAvailable = remember { context.canAuthenticateBiometrics() } + // Anti-uninstall also changes away from this screen - granting device admin turns it on, and + // AdminDisableActivity turns it off - so follow the stored flag rather than reading it once. + DisposableEffect(Unit) { + val prefs = context.getSharedPreferences("app_lock_settings", Context.MODE_PRIVATE) + val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "anti_uninstall") { + antiUninstallEnabled = appLockRepository.isAntiUninstallEnabled() + } + } + prefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + } + if (showDialog) { AlertDialog( onDismissRequest = { showDialog = false }, From f83f1974a1325b061803e9ae0c96bc50ed659c37 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:30:58 -0300 Subject: [PATCH 16/31] fix: make the user wait after too many wrong unlock attempts Nothing limited wrong PIN, pattern or password entries, so a 4-digit PIN fell to 10,000 taps on the lock screen, and the same check guards opening the app and turning anti-uninstall off. Every check now goes through one counter stored with the credentials. Four wrong tries are free; the fifth starts a 30 second wait, and each one after it doubles the wait, up to 30 minutes. During a wait nothing is checked, so even the right entry is refused. The deadline runs on elapsedRealtime, so changing the time in Settings doesn't end it, and a reboot starts it again in full. A correct entry or a strong biometric forgets the wrong tries. Auto Unlock checked the PIN after every digit, which would now count the user's own typing. It checks only once as many digits as the PIN has are in. The length is stored when the PIN is set, and recorded on the next unlock for a PIN set before this change; until then Auto Unlock waits for the proceed key. The lock screens still show a wait as a wrong entry; the next commit adds the countdown. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../applock/core/navigation/AppNavigator.kt | 2 + .../data/repository/AppLockRepository.kt | 4 + .../data/repository/PreferencesRepository.kt | 62 ++++++++++- .../data/repository/UnlockAttemptLimiter.kt | 105 ++++++++++++++++++ .../lockscreen/ui/PasswordOverlayScreen.kt | 35 +++--- .../ui/TransparentBiometricActivity.kt | 2 + 6 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/pranav/applock/data/repository/UnlockAttemptLimiter.kt 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 00ca6b3e..5a9fd040 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 @@ -18,6 +18,7 @@ import androidx.navigation.compose.composable import dev.pranav.applock.AppLockApplication import dev.pranav.applock.R import dev.pranav.applock.core.utils.LogUtils +import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.antiuninstall.ui.AntiUninstallScreen import dev.pranav.applock.features.appintro.ui.AppIntroScreen @@ -180,6 +181,7 @@ private fun handleBiometricAuthentication( override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) LogUtils.d(TAG, "Biometric authentication succeeded") + context.appLockRepository().clearFailedAttempts() navigateToMain(navController) } 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 3e3cb72e..b4441a72 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 @@ -57,6 +57,10 @@ class AppLockRepository(private val context: Context) { fun validatePattern(inputPattern: String): Boolean = preferencesRepository.validatePattern(inputPattern) + fun getPinLength(): Int = preferencesRepository.getPinLength() + fun getLockoutRemainingMillis(): Long = preferencesRepository.getLockoutRemainingMillis() + fun clearFailedAttempts() = preferencesRepository.clearFailedAttempts() + fun setLockType(lockType: String) = preferencesRepository.setLockType(lockType) fun getLockType(): String = preferencesRepository.getLockType() 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 004251c6..2e4bbb1d 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 @@ -17,24 +17,36 @@ class PreferencesRepository(context: Context) { private val settingsPrefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME_SETTINGS, Context.MODE_PRIVATE) + private val attemptLimiter = UnlockAttemptLimiter(context) + fun setPassword(password: String) { val salt = SecurityUtils.generateSalt() val saltedHash = SecurityUtils.hashPassword(password, salt) - appLockPrefs.edit(commit = true) { putString(KEY_PASSWORD, saltedHash) } + appLockPrefs.edit(commit = true) { + putString(KEY_PASSWORD, saltedHash) + putInt(KEY_PIN_LENGTH, SecurityUtils.sanitizePassword(password).length) + } } fun getPassword(): String? { return appLockPrefs.getString(KEY_PASSWORD, null) } - fun validatePassword(input: String): Boolean { + fun validatePassword(input: String): Boolean = limitAttempts(input) { checkPassword(it) } + + private fun checkPassword(input: String): Boolean { val stored = getPassword() if (stored.isNullOrBlank()) return false val sanitizedInput = SecurityUtils.sanitizePassword(input) if (SecurityUtils.isSaltedHash(stored)) { - return SecurityUtils.verifyPassword(sanitizedInput, stored) + val isValid = SecurityUtils.verifyPassword(sanitizedInput, stored) + // A PIN set before its length was stored gets the length recorded on its next unlock. + if (isValid && getPinLength() != sanitizedInput.length) { + appLockPrefs.edit(commit = true) { putInt(KEY_PIN_LENGTH, sanitizedInput.length) } + } + return isValid } if (stored == input || stored == sanitizedInput) { @@ -45,6 +57,15 @@ class PreferencesRepository(context: Context) { return false } + /** + * How many characters the PIN or password has, so Auto Unlock can check a PIN only once that + * many digits are in. 0 while unknown: it is stored when the PIN is set, or on the next unlock + * for a PIN set before that. + */ + fun getPinLength(): Int { + return appLockPrefs.getInt(KEY_PIN_LENGTH, 0) + } + fun setPattern(pattern: String) { appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, pattern) } } @@ -53,11 +74,40 @@ class PreferencesRepository(context: Context) { return appLockPrefs.getString(KEY_PATTERN, null) } - fun validatePattern(inputPattern: String): Boolean { + fun validatePattern(inputPattern: String): Boolean = + limitAttempts(inputPattern) { checkPattern(it) } + + private fun checkPattern(inputPattern: String): Boolean { val storedPattern = getPattern() return storedPattern != null && inputPattern == storedPattern } + /** How long until another PIN, pattern or password may be tried, or 0 if one may be now. */ + fun getLockoutRemainingMillis(): Long = attemptLimiter.remainingMillis() + + /** + * Forgets wrong entries and ends any wait. Called after a strong biometric, which only the owner + * can pass, so a thief's wrong tries don't leave the owner one typo away from a long wait. + */ + fun clearFailedAttempts() = attemptLimiter.recordSuccess() + + /** + * Runs [check] under the attempt limit. During a wait nothing is checked, so even the right entry + * is refused. A miss shorter than any PIN, pattern or password that can be set isn't counted: it + * can never match, so it gives nothing away, and a stray tap on the pattern grid doesn't cost a try. + */ + private fun limitAttempts(input: String, check: (String) -> Boolean): Boolean { + if (attemptLimiter.remainingMillis() > 0L) return false + + val isValid = check(input) + if (isValid) { + attemptLimiter.recordSuccess() + } else if (input.length >= MIN_CREDENTIAL_LENGTH) { + attemptLimiter.recordFailure() + } + return isValid + } + fun setLockType(lockType: String) { settingsPrefs.edit(commit = true) { putString(KEY_LOCK_TYPE, lockType) } } @@ -213,11 +263,15 @@ class PreferencesRepository(context: Context) { private const val KEY_LOCK_TYPE = "lock_type" private const val KEY_AUTOMATION_ENABLED = "automation_enabled" private const val KEY_AUTOMATION_TOKEN = "automation_token" + private const val KEY_PIN_LENGTH = "pin_length" private const val DEFAULT_PROTECT_ENABLED = true private const val DEFAULT_AUTOMATION_ENABLED = false private const val DEFAULT_UNLOCK_DURATION = 0 + /** The shortest PIN, pattern or password the set screens accept. */ + private const val MIN_CREDENTIAL_LENGTH = 4 + const val LOCK_TYPE_PIN = "pin" const val LOCK_TYPE_PATTERN = "pattern" const val LOCK_TYPE_PASSWORD = "password" diff --git a/app/src/main/java/dev/pranav/applock/data/repository/UnlockAttemptLimiter.kt b/app/src/main/java/dev/pranav/applock/data/repository/UnlockAttemptLimiter.kt new file mode 100644 index 00000000..bd40a8c5 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/data/repository/UnlockAttemptLimiter.kt @@ -0,0 +1,105 @@ +package dev.pranav.applock.data.repository + +import android.content.ContentResolver +import android.content.Context +import android.content.SharedPreferences +import android.os.SystemClock +import android.provider.Settings +import androidx.core.content.edit + +/** + * Makes the user wait after too many wrong PINs, patterns or passwords, so the lock can't be guessed + * from the screen. Every check goes through [PreferencesRepository], so the lock screens, the app's + * own screen, turning anti-uninstall off and changing the PIN all share one count. + * + * The count is stored rather than kept in memory, so closing the lock screen or killing the app + * doesn't reset it. Wrong tries are forgiven only by a correct entry or a strong biometric. + */ +class UnlockAttemptLimiter(context: Context) { + + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val contentResolver: ContentResolver = context.contentResolver + + /** + * How long until another attempt is allowed, or 0 if one is allowed now. + * + * The deadline is on [SystemClock.elapsedRealtime], which changing the time in Settings can't + * move. That clock starts over on a reboot, so a wait set before one is started again in full. + */ + fun remainingMillis(): Long { + synchronized(LOCK) { + val deadline = prefs.getLong(KEY_DEADLINE, 0L) + if (deadline == 0L) return 0L + + val now = SystemClock.elapsedRealtime() + val fullDelay = delayFor(prefs.getInt(KEY_FAILURES, 0)) + if (prefs.getInt(KEY_BOOT_COUNT, -1) != bootCount() || deadline - now > fullDelay) { + prefs.edit(commit = true) { + putLong(KEY_DEADLINE, now + fullDelay) + putInt(KEY_BOOT_COUNT, bootCount()) + } + return fullDelay + } + + return (deadline - now).coerceAtLeast(0L) + } + } + + /** Counts a wrong entry, starting or lengthening the wait once the free tries are used up. */ + fun recordFailure() { + synchronized(LOCK) { + val failures = prefs.getInt(KEY_FAILURES, 0) + 1 + val delay = delayFor(failures) + prefs.edit(commit = true) { + putInt(KEY_FAILURES, failures) + if (delay > 0L) { + putLong(KEY_DEADLINE, SystemClock.elapsedRealtime() + delay) + putInt(KEY_BOOT_COUNT, bootCount()) + } + } + } + } + + /** Forgets every wrong entry and ends any wait. Writes only when there is something to forget. */ + fun recordSuccess() { + synchronized(LOCK) { + if (!prefs.contains(KEY_FAILURES) && !prefs.contains(KEY_DEADLINE)) return + prefs.edit(commit = true) { + remove(KEY_FAILURES) + remove(KEY_DEADLINE) + remove(KEY_BOOT_COUNT) + } + } + } + + private fun bootCount(): Int { + return try { + Settings.Global.getInt(contentResolver, Settings.Global.BOOT_COUNT, 0) + } catch (_: Exception) { + // Without it, a reboot is still caught by the deadline lying further ahead than a full wait. + 0 + } + } + + companion object { + private const val PREFS_NAME = "app_lock_prefs" + private const val KEY_FAILURES = "failed_unlock_attempts" + private const val KEY_DEADLINE = "lockout_deadline" + private const val KEY_BOOT_COUNT = "lockout_boot_count" + + private const val FREE_ATTEMPTS = 4 + private const val FIRST_DELAY_MS = 30_000L + private const val MAX_DELAY_MS = 30 * 60_000L + + /** Each repository creates its own limiter, so they share one lock around the count. */ + private val LOCK = Any() + + /** 30s after the 5th wrong try, doubling with each one after it, up to 30 min. */ + private fun delayFor(failures: Int): Long { + if (failures <= FREE_ATTEMPTS) return 0L + val doublings = (failures - FREE_ATTEMPTS - 1).coerceAtMost(6) + return (FIRST_DELAY_MS shl doublings).coerceAtMost(MAX_DELAY_MS) + } + } +} 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 91f78c7a..65c6b480 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 @@ -268,6 +268,7 @@ class PasswordOverlayActivity: FragmentActivity() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) isBiometricPromptShowingLocal = false + appLockRepository.clearFailedAttempts() lockedPackageNameFromIntent?.let { pkgName -> AppLockManager.temporarilyUnlockAppWithBiometrics(pkgName) // Fix: Do NOT relaunch the app. Just finish the overlay to reveal the underlying activity. @@ -393,6 +394,24 @@ fun PinPasswordOverlayScreen( var showError by remember { mutableStateOf(false) } val minLength = 4 + // Auto Unlock checks the PIN only once it has as many digits as the real one: every + // wrong check counts towards the lockout, so checking each digit would count the user's + // own typing. Until the length is known, Auto Unlock waits for the proceed key. + val onPasswordChange: () -> Unit = { + showError = false + + if (appLockRepository.isAutoUnlockEnabled() && + passwordState.value.length == appLockRepository.getPinLength() + ) { + onPinAttempt?.let { attempt -> + if (!attempt(passwordState.value)) { + passwordState.value = "" + showError = true + } + } + } + } + if (isLandscape) { Row( modifier = Modifier @@ -455,13 +474,7 @@ fun PinPasswordOverlayScreen( onBiometricAuth = onBiometricAuth, onAuthSuccess = onAuthSuccess, onPinAttempt = onPinAttempt, - onPasswordChange = { - showError = false - - if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) - } - }, + onPasswordChange = onPasswordChange, onPinIncorrect = { showError = true } ) } @@ -524,13 +537,7 @@ fun PinPasswordOverlayScreen( onBiometricAuth = onBiometricAuth, onAuthSuccess = onAuthSuccess, onPinAttempt = onPinAttempt, - onPasswordChange = { - showError = false - - if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) - } - }, + onPasswordChange = onPasswordChange, onPinIncorrect = { showError = true } ) } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt index 2c2f327b..8da08392 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import dev.pranav.applock.R +import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.services.AppLockManager import dev.pranav.applock.ui.theme.AppLockTheme @@ -105,6 +106,7 @@ class TransparentBiometricActivity: FragmentActivity() { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) isAuthenticated = true + appLockRepository().clearFailedAttempts() AppLockManager.reportBiometricAuthFinished() AppLockManager.isLockScreenShown.set(false) lockedPackageName?.let { From 70efb2c8b7251da7440658ed26a2bcadf91eebc1 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:35:26 -0300 Subject: [PATCH 17/31] fix: show the lockout countdown on the lock screens A wait read as a wrong entry, because validation only returns true or false. Every screen that checks the PIN, pattern or password now shows "Too many attempts. Try again in 0:30" in place of the error while a wait runs, and takes no input until it ends: the keypad greys out, the password field and Verify button are disabled, and a pattern drawn then is ignored and the grid dimmed. The fingerprint button stays usable. The lock overlay, the app's own screen and the anti-uninstall screen all read the same stored deadline, so a wait started on one shows on the others. The change-PIN screens only check the current PIN when it's submitted, so they show the wait as a toast instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../features/admin/AdminDisableActivity.kt | 25 ++++++++--- .../ui/AlphanumericPasswordOverlayScreen.kt | 11 ++++- .../applock/features/lockscreen/ui/Lockout.kt | 45 +++++++++++++++++++ .../lockscreen/ui/PasswordOverlayScreen.kt | 32 +++++++++++-- .../lockscreen/ui/PatternLockScreen.kt | 26 ++++++++--- .../ui/AlphanumericSetPasswordScreen.kt | 14 +++++- .../ui/PatternSetPasswordScreen.kt | 14 +++++- .../setpassword/ui/SetPasswordScreen.kt | 26 ++++++++++- app/src/main/res/values/strings.xml | 1 + 9 files changed, 173 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/dev/pranav/applock/features/lockscreen/ui/Lockout.kt 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 16e9cc78..a8949667 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 @@ -34,6 +34,8 @@ import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.lockscreen.ui.KeypadSection import dev.pranav.applock.features.lockscreen.ui.PasswordIndicators import dev.pranav.applock.features.lockscreen.ui.PatternLockScreen +import dev.pranav.applock.features.lockscreen.ui.lockoutMessage +import dev.pranav.applock.features.lockscreen.ui.rememberLockoutSeconds import dev.pranav.applock.ui.theme.AppLockTheme class AdminDisableActivity : ComponentActivity() { @@ -195,6 +197,7 @@ fun AdminDisableScreen( ) { val passwordState = remember { mutableStateOf("") } val showError = remember { mutableStateOf(false) } + val lockoutSeconds = rememberLockoutSeconds() Column( modifier = Modifier @@ -217,9 +220,13 @@ fun AdminDisableScreen( passwordLength = passwordState.value.length ) - if (showError.value) { + if (lockoutSeconds > 0L || showError.value) { Text( - text = stringResource(R.string.incorrect_pin_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_pin_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) @@ -231,6 +238,7 @@ fun AdminDisableScreen( KeypadSection( passwordState = passwordState, minLength = 4, + enabled = lockoutSeconds == 0L, showBiometricButton = false, fromMainActivity = false, onBiometricAuth = {}, @@ -266,6 +274,7 @@ fun AdminDisablePasswordScreen( var showError by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } + val lockoutSeconds = rememberLockoutSeconds() LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -293,6 +302,7 @@ fun AdminDisablePasswordScreen( showError = false }, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + enabled = lockoutSeconds == 0L, label = { Text(stringResource(R.string.password_hint)) }, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( @@ -312,9 +322,13 @@ fun AdminDisablePasswordScreen( singleLine = true ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Text( - text = stringResource(R.string.incorrect_password_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_password_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 4.dp).align(Alignment.Start) @@ -343,7 +357,8 @@ fun AdminDisablePasswordScreen( passwordState = "" } }, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + enabled = lockoutSeconds == 0L ) { Text(stringResource(R.string.verify_button)) } diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt index d125de11..e59ca74f 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt @@ -78,6 +78,7 @@ fun AlphanumericPasswordOverlayScreen( } val minLength = 4 + val lockoutSeconds = rememberLockoutSeconds() Surface( modifier = modifier.fillMaxSize(), @@ -129,6 +130,7 @@ fun AlphanumericPasswordOverlayScreen( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), + enabled = lockoutSeconds == 0L, label = { Text(stringResource(R.string.password_hint)) }, visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), keyboardOptions = KeyboardOptions( @@ -165,9 +167,13 @@ fun AlphanumericPasswordOverlayScreen( singleLine = true ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Text( - text = stringResource(R.string.incorrect_password_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_password_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelMedium, modifier = Modifier @@ -215,6 +221,7 @@ fun AlphanumericPasswordOverlayScreen( } }, modifier = Modifier.weight(1f), + enabled = lockoutSeconds == 0L, shapes = ButtonDefaults.shapes() ) { Text(stringResource(R.string.verify_button)) diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/Lockout.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/Lockout.kt new file mode 100644 index 00000000..6d7435c8 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/Lockout.kt @@ -0,0 +1,45 @@ +package dev.pranav.applock.features.lockscreen.ui + +import android.content.Context +import android.text.format.DateUtils +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import dev.pranav.applock.R +import dev.pranav.applock.core.utils.appLockRepository +import dev.pranav.applock.data.repository.AppLockRepository +import kotlinx.coroutines.delay + +/** + * Whole seconds left before another unlock attempt is allowed, or 0 when one is allowed now. + * + * Re-read a few times a second while shown, so a wait started by a wrong try - on this screen or + * another - shows up at once and ends on time. The caller only recomposes when the second changes. + */ +@Composable +fun rememberLockoutSeconds(): Long { + val appLockRepository = LocalContext.current.appLockRepository() + val seconds by produceState(initialValue = appLockRepository.lockoutSeconds()) { + while (true) { + value = appLockRepository.lockoutSeconds() + delay(LOCKOUT_POLL_INTERVAL_MS) + } + } + return seconds +} + +/** Whole seconds left on the wait, rounded up so the countdown never shows 0:00 while it runs. */ +fun AppLockRepository.lockoutSeconds(): Long = (getLockoutRemainingMillis() + 999L) / 1000L + +/** "Too many attempts. Try again in 0:30", for a wait with [seconds] left. */ +@Composable +fun lockoutMessage(seconds: Long): String = + stringResource(R.string.too_many_attempts_try_again_in, DateUtils.formatElapsedTime(seconds)) + +/** [lockoutMessage] outside composition, e.g. for a Toast. */ +fun Context.getLockoutMessage(seconds: Long): String = + getString(R.string.too_many_attempts_try_again_in, DateUtils.formatElapsedTime(seconds)) + +private const val LOCKOUT_POLL_INTERVAL_MS = 250L 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 65c6b480..3fd8bfe0 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 @@ -393,6 +393,13 @@ fun PinPasswordOverlayScreen( val passwordState = remember { mutableStateOf("") } var showError by remember { mutableStateOf(false) } val minLength = 4 + val lockoutSeconds = rememberLockoutSeconds() + + // Start and end a wait with an empty PIN and no leftover "Incorrect PIN". + LaunchedEffect(lockoutSeconds > 0L) { + passwordState.value = "" + showError = false + } // Auto Unlock checks the PIN only once it has as many digits as the real one: every // wrong check counts towards the lockout, so checking each digit would count the user's @@ -452,10 +459,14 @@ fun PinPasswordOverlayScreen( passwordLength = passwordState.value.length, ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.incorrect_pin_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_pin_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, ) @@ -469,6 +480,7 @@ fun PinPasswordOverlayScreen( KeypadSection( passwordState = passwordState, minLength = minLength, + enabled = lockoutSeconds == 0L, showBiometricButton = showBiometricButton, fromMainActivity = fromMainActivity, onBiometricAuth = onBiometricAuth, @@ -518,9 +530,13 @@ fun PinPasswordOverlayScreen( passwordLength = passwordState.value.length, ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Text( - text = stringResource(R.string.incorrect_pin_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_pin_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) @@ -532,6 +548,7 @@ fun PinPasswordOverlayScreen( KeypadSection( passwordState = passwordState, minLength = minLength, + enabled = lockoutSeconds == 0L, showBiometricButton = showBiometricButton, fromMainActivity = fromMainActivity, onBiometricAuth = onBiometricAuth, @@ -692,6 +709,7 @@ fun PasswordIndicators( fun KeypadSection( passwordState: MutableState, minLength: Int, + enabled: Boolean = true, showBiometricButton: Boolean, fromMainActivity: Boolean = false, onBiometricAuth: () -> Unit, @@ -839,6 +857,7 @@ fun KeypadSection( } KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("1", "2", "3"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -846,6 +865,7 @@ fun KeypadSection( ) KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("4", "5", "6"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -853,6 +873,7 @@ fun KeypadSection( ) KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("7", "8", "9"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -860,6 +881,7 @@ fun KeypadSection( ) KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("backspace", "0", "proceed"), icons = listOf(Backspace, null, Icons.AutoMirrored.Rounded.KeyboardArrowRight), onKeyClick = onSpecialKeyClick, @@ -945,6 +967,7 @@ private fun handleKeypadSpecialButtonLogic( @Composable fun KeypadRow( disableHaptics: Boolean = false, + enabled: Boolean = true, keys: List, icons: List = emptyList(), onKeyClick: (String) -> Unit, @@ -991,6 +1014,7 @@ fun KeypadRow( onKeyClick(key) }, modifier = Modifier.size(buttonSize), + enabled = enabled, interactionSource = interactionSource, shapes = ButtonShapes( shape = CircleShape, 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 aae44837..6716a2ab 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 @@ -52,6 +52,7 @@ fun PatternLockScreen( ) { Box(modifier = Modifier.fillMaxSize()) { var showError by remember { mutableStateOf(false) } + val lockoutSeconds = rememberLockoutSeconds() @Suppress("ASSIGNED_BUT_NOT_ACCESSED_WARNING") var errorShakeOffset by remember { mutableStateOf(0f) } @@ -79,6 +80,11 @@ fun PatternLockScreen( } override fun onResult(result: List) { + // Nothing is checked during a wait, so a pattern drawn then is simply ignored. + // Read the repository rather than lockoutSeconds: the pattern view may keep hold + // of an older copy of this callback. + if (appLockRepository.getLockoutRemainingMillis() > 0L) return + patternIds.value = result.map { it.id } val patternString = result.joinToString("") { it.id.toString() } @@ -119,10 +125,14 @@ fun PatternLockScreen( textAlign = TextAlign.Center ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.incorrect_pattern_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_pattern_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center @@ -152,7 +162,7 @@ fun PatternLockScreen( modifier = Modifier .weight(1f) .aspectRatio(1f) - .graphicsLayer(translationX = shakeAnimation), + .graphicsLayer(translationX = shakeAnimation, alpha = if (lockoutSeconds > 0L) 0.38f else 1f), dimension = 3, sensitivity = 50f, dotsColor = MaterialTheme.colorScheme.primary, @@ -191,10 +201,14 @@ fun PatternLockScreen( textAlign = TextAlign.Center ) - if (showError) { + if (lockoutSeconds > 0L || showError) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.incorrect_pattern_try_again), + text = if (lockoutSeconds > 0L) { + lockoutMessage(lockoutSeconds) + } else { + stringResource(R.string.incorrect_pattern_try_again) + }, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center @@ -228,7 +242,7 @@ fun PatternLockScreen( modifier = Modifier .fillMaxWidth() .aspectRatio(1f) - .graphicsLayer(translationX = shakeAnimation), + .graphicsLayer(translationX = shakeAnimation, alpha = if (lockoutSeconds > 0L) 0.38f else 1f), dimension = 3, sensitivity = 50f, dotsColor = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt index 696d2e6d..a8173393 100644 --- a/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/setpassword/ui/AlphanumericSetPasswordScreen.kt @@ -57,6 +57,8 @@ import dev.pranav.applock.core.navigation.Screen import dev.pranav.applock.core.navigation.finishPasswordSetup import dev.pranav.applock.core.utils.SecurityUtils import dev.pranav.applock.data.repository.PreferencesRepository +import dev.pranav.applock.features.lockscreen.ui.getLockoutMessage +import dev.pranav.applock.features.lockscreen.ui.lockoutSeconds @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -158,7 +160,17 @@ fun AlphanumericSetPasswordScreen( passwordState = "" showInvalidOldPasswordError = false } else { - showInvalidOldPasswordError = true + // Nothing is checked during a wait, so say that instead. + val lockoutSeconds = appLockRepository.lockoutSeconds() + if (lockoutSeconds > 0L) { + Toast.makeText( + context, + context.getLockoutMessage(lockoutSeconds), + Toast.LENGTH_SHORT + ).show() + } else { + showInvalidOldPasswordError = true + } passwordState = "" } } 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 450291d3..41a30fe6 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 @@ -48,6 +48,8 @@ import dev.pranav.applock.core.navigation.Screen import dev.pranav.applock.core.navigation.finishPasswordSetup import dev.pranav.applock.core.utils.vibrate import dev.pranav.applock.data.repository.PreferencesRepository +import dev.pranav.applock.features.lockscreen.ui.getLockoutMessage +import dev.pranav.applock.features.lockscreen.ui.lockoutSeconds @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -145,7 +147,17 @@ fun PatternSetPasswordScreen( patternState = "" showInvalidOldPasswordError = false } else { - showInvalidOldPasswordError = true + // Nothing is checked during a wait, so say that instead. + val lockoutSeconds = appLockRepository.lockoutSeconds() + if (lockoutSeconds > 0L) { + Toast.makeText( + context, + context.getLockoutMessage(lockoutSeconds), + Toast.LENGTH_SHORT + ).show() + } else { + showInvalidOldPasswordError = true + } patternState = "" } } 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 d31b753f..7981888d 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 @@ -47,6 +47,8 @@ import dev.pranav.applock.core.navigation.finishPasswordSetup import dev.pranav.applock.data.repository.PreferencesRepository import dev.pranav.applock.features.lockscreen.ui.KeypadRow import dev.pranav.applock.features.lockscreen.ui.PasswordIndicators +import dev.pranav.applock.features.lockscreen.ui.getLockoutMessage +import dev.pranav.applock.features.lockscreen.ui.lockoutSeconds import dev.pranav.applock.ui.icons.Backspace @OptIn( @@ -337,7 +339,17 @@ fun SetPasswordScreen( passwordState = "" showInvalidOldPasswordError = false } else { - showInvalidOldPasswordError = true + // Nothing is checked during a wait, so say that instead. + val lockoutSeconds = appLockRepository.lockoutSeconds() + if (lockoutSeconds > 0L) { + Toast.makeText( + context, + context.getLockoutMessage(lockoutSeconds), + Toast.LENGTH_SHORT + ).show() + } else { + showInvalidOldPasswordError = true + } passwordState = "" } } @@ -584,7 +596,17 @@ fun SetPasswordScreen( passwordState = "" showInvalidOldPasswordError = false } else { - showInvalidOldPasswordError = true + // Nothing is checked during a wait, so say that instead. + val lockoutSeconds = appLockRepository.lockoutSeconds() + if (lockoutSeconds > 0L) { + Toast.makeText( + context, + context.getLockoutMessage(lockoutSeconds), + Toast.LENGTH_SHORT + ).show() + } else { + showInvalidOldPasswordError = true + } passwordState = "" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66e276d3..567f8a1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Enter your password to disable admin permission. Incorrect PIN. Please try again. + Too many attempts. Try again in %1$s Incorrect password. Please try again. Password verified, you can now disable admin permission Choose App Detection Method From 8f790a93e5a5592bda4f44f9adfc0a4d72364cae Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:54:11 -0300 Subject: [PATCH 18/31] fix: tidy the lock screens when a wait ends When a wait ended, the pattern and password screens showed the "Incorrect ..." message from the try that started it again, and the password field, disabled during the wait, stayed unfocused with the keyboard closed. Clear the message and the field when a wait starts or ends, matching the PIN screen, and focus the password field again once no wait is running. That covers the lock screen and the anti-uninstall screen for the password lock type. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../applock/features/admin/AdminDisableActivity.kt | 8 ++++++-- .../ui/AlphanumericPasswordOverlayScreen.kt | 12 ++++++++---- .../features/lockscreen/ui/PatternLockScreen.kt | 5 +++++ 3 files changed, 19 insertions(+), 6 deletions(-) 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 a8949667..635d32d9 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 @@ -276,8 +276,12 @@ fun AdminDisablePasswordScreen( val focusRequester = remember { FocusRequester() } val lockoutSeconds = rememberLockoutSeconds() - LaunchedEffect(Unit) { - focusRequester.requestFocus() + // Clear the field on open and whenever a wait starts or ends, and focus it once no wait is + // running: disabling it for the wait drops focus and closes the keyboard. + LaunchedEffect(lockoutSeconds > 0L) { + passwordState = "" + showError = false + if (lockoutSeconds == 0L) focusRequester.requestFocus() } Column( diff --git a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt index e59ca74f..8de4be01 100644 --- a/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt +++ b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/AlphanumericPasswordOverlayScreen.kt @@ -73,13 +73,17 @@ fun AlphanumericPasswordOverlayScreen( val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - val minLength = 4 val lockoutSeconds = rememberLockoutSeconds() + // Clear the field on open and whenever a wait starts or ends, and focus it once no wait is + // running: disabling it for the wait drops focus and closes the keyboard. + LaunchedEffect(lockoutSeconds > 0L) { + passwordState = "" + showError = false + if (lockoutSeconds == 0L) focusRequester.requestFocus() + } + Surface( modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer 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 6716a2ab..fd46ef16 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 @@ -54,6 +54,11 @@ fun PatternLockScreen( var showError by remember { mutableStateOf(false) } val lockoutSeconds = rememberLockoutSeconds() + // Start and end a wait with no leftover "Incorrect pattern". + LaunchedEffect(lockoutSeconds > 0L) { + showError = false + } + @Suppress("ASSIGNED_BUT_NOT_ACCESSED_WARNING") var errorShakeOffset by remember { mutableStateOf(0f) } From a5d4dee4c4479a8be592c248c4385ce9b5b87c88 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:37:57 -0300 Subject: [PATCH 19/31] fix: store the unlock pattern as a salted hash The pattern was written to app_lock_prefs as plain text and compared with ==, so anyone who copied that file had it. It now goes through the same salted SHA-256 as the PIN and password. A pattern already stored as plain text is hashed when the app starts, before any lock screen checks it, rather than waiting for the next unlock. If that upgrade couldn't run, a plain-text match still unlocks and re-stores the pattern hashed. Patterns are digits only, so a plain-text one can never be mistaken for a salted hash. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../dev/pranav/applock/AppLockApplication.kt | 7 +++++ .../data/repository/AppLockRepository.kt | 2 ++ .../data/repository/PreferencesRepository.kt | 27 +++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/pranav/applock/AppLockApplication.kt b/app/src/main/java/dev/pranav/applock/AppLockApplication.kt index 83c1ec60..4780ac94 100644 --- a/app/src/main/java/dev/pranav/applock/AppLockApplication.kt +++ b/app/src/main/java/dev/pranav/applock/AppLockApplication.kt @@ -25,6 +25,13 @@ class AppLockApplication : Application() { super.onCreate() initializeComponents() + // Bring stored credentials up to the current format before any lock screen checks them. + try { + appLockRepository.upgradeStoredCredentials() + } catch (e: Exception) { + Log.e(TAG, "Failed to upgrade stored credentials", e) + } + LogUtils.initialize(this) LogUtils.setLoggingEnabled(appLockRepository.isLoggingEnabled()) 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 b4441a72..50b47321 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 @@ -57,6 +57,8 @@ class AppLockRepository(private val context: Context) { fun validatePattern(inputPattern: String): Boolean = preferencesRepository.validatePattern(inputPattern) + fun upgradeStoredCredentials() = preferencesRepository.upgradeStoredCredentials() + fun getPinLength(): Int = preferencesRepository.getPinLength() fun getLockoutRemainingMillis(): Long = preferencesRepository.getLockoutRemainingMillis() fun clearFailedAttempts() = preferencesRepository.clearFailedAttempts() 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 2e4bbb1d..84c724de 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 @@ -67,7 +67,7 @@ class PreferencesRepository(context: Context) { } fun setPattern(pattern: String) { - appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, pattern) } + appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, SecurityUtils.hashPassword(pattern)) } } fun getPattern(): String? { @@ -78,8 +78,31 @@ class PreferencesRepository(context: Context) { limitAttempts(inputPattern) { checkPattern(it) } private fun checkPattern(inputPattern: String): Boolean { + val storedPattern = getPattern() ?: return false + + if (SecurityUtils.isSaltedHash(storedPattern)) { + return SecurityUtils.verifyPassword(inputPattern, storedPattern) + } + + // A pattern set before patterns were hashed, if the upgrade at startup didn't get to it. + if (SecurityUtils.constantTimeEquals(storedPattern, inputPattern)) { + setPattern(inputPattern) + return true + } + + return false + } + + /** + * Brings stored credentials up to the current format when the app starts, before any lock + * screen checks them. A pattern stored as plain text is hashed; patterns are digits only, so + * one can never be mistaken for a salted hash. + */ + fun upgradeStoredCredentials() { val storedPattern = getPattern() - return storedPattern != null && inputPattern == storedPattern + if (!storedPattern.isNullOrBlank() && !SecurityUtils.isSaltedHash(storedPattern)) { + setPattern(storedPattern) + } } /** How long until another PIN, pattern or password may be tried, or 0 if one may be now. */ From 1c17e4354f38af92f60de01d4f3367b819357cc6 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:41:26 -0300 Subject: [PATCH 20/31] fix: tie stored PIN and pattern hashes to a key that stays on the phone A single salted SHA-256 falls in under a second for a 4-6 digit PIN once app_lock_prefs is copied off the phone, and no amount of key stretching fixes that for a PIN or a pattern. The PIN, password and pattern are now stored as v2:salt:mac, an HMAC-SHA256 under a non-exportable Android Keystore key over that same salted hash, so a copy can only be checked on this phone. Because the MAC covers the old hash, existing values upgrade when the app starts, without waiting for an unlock. If the Keystore fails, new values fall back to salt:hash and older ones are still checked, so setting or checking a PIN never crashes and the next start tries again. Values restored from a backup, or copied over by a phone-transfer app, arrive without the key and could never match. A marker in the no-backup folder records the phone's Android ID when the key is made. When the marker is missing or from another phone and the key is absent, the stored credentials are erased and the app asks for a new PIN. On the phone that made it, a key that fails to load is an error and is never replaced, so no stored hash is orphaned and nothing is erased on a Keystore hiccup. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- .../applock/core/utils/SecurityUtils.kt | 23 ++- .../data/repository/CredentialHasher.kt | 159 ++++++++++++++++++ .../data/repository/PreferencesRepository.kt | 82 +++++---- 3 files changed, 217 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/dev/pranav/applock/data/repository/CredentialHasher.kt diff --git a/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt index 81be7bce..d8878fbe 100644 --- a/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt +++ b/app/src/main/java/dev/pranav/applock/core/utils/SecurityUtils.kt @@ -57,11 +57,7 @@ object SecurityUtils { * Format: salt:hash */ fun hashPassword(password: String, salt: ByteArray): String { - val sanitizedPassword = sanitizePassword(password) - - val md = MessageDigest.getInstance(HASH_ALGORITHM) - md.update(salt) - val hash = md.digest(sanitizedPassword.toByteArray(Charsets.UTF_8)) + val hash = saltedDigest(password, salt) val saltBase64 = Base64.encodeToString(salt, Base64.NO_WRAP) val hashBase64 = Base64.encodeToString(hash, Base64.NO_WRAP) @@ -69,6 +65,15 @@ object SecurityUtils { return "$saltBase64:$hashBase64" } + /** + * SHA-256 of the salt followed by the sanitized password: the hash inside `salt:hash`. + */ + fun saltedDigest(password: String, salt: ByteArray): ByteArray { + val md = MessageDigest.getInstance(HASH_ALGORITHM) + md.update(salt) + return md.digest(sanitizePassword(password).toByteArray(Charsets.UTF_8)) + } + fun isSaltedHash(value: String): Boolean { val parts = value.split(":") if (parts.size != 2) return false @@ -93,13 +98,7 @@ object SecurityUtils { val salt = Base64.decode(parts[0], Base64.NO_WRAP) val expectedHash = Base64.decode(parts[1], Base64.NO_WRAP) - val sanitizedInput = sanitizePassword(inputPassword) - - val md = MessageDigest.getInstance(HASH_ALGORITHM) - md.update(salt) - val actualHash = md.digest(sanitizedInput.toByteArray(Charsets.UTF_8)) - - MessageDigest.isEqual(actualHash, expectedHash) + MessageDigest.isEqual(saltedDigest(inputPassword, salt), expectedHash) } catch (_: Exception) { false } diff --git a/app/src/main/java/dev/pranav/applock/data/repository/CredentialHasher.kt b/app/src/main/java/dev/pranav/applock/data/repository/CredentialHasher.kt new file mode 100644 index 00000000..ae6769e6 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/data/repository/CredentialHasher.kt @@ -0,0 +1,159 @@ +package dev.pranav.applock.data.repository + +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Context +import android.provider.Settings +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import dev.pranav.applock.core.utils.SecurityUtils +import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +/** + * Hashes and checks the PIN, password and pattern under a key in the Android Keystore, which never + * leaves the phone's secure hardware. A hash copied off the phone can't be checked anywhere else, so + * even a short PIN can't be guessed from it. + * + * Values are stored as `v2:salt:mac`, the mac being an HMAC under that key of the SHA-256 an older + * `salt:hash` value holds. So an older value upgrades without the PIN, and one the Keystore couldn't + * upgrade is still checked the old way. + */ +class CredentialHasher(context: Context) { + + private val contentResolver: ContentResolver = context.contentResolver + + /** + * Holds the ID of the phone the key was made on. Android never backs up this folder, so a missing + * marker, or one copied from another phone, means no key was made for this install. + */ + private val keyMarker = File(context.noBackupFilesDir, KEY_MARKER) + + /** Hashes a new PIN, password or pattern, falling back to `salt:hash` if the Keystore fails. */ + fun hash(value: String): String { + val salt = SecurityUtils.generateSalt() + return try { + keyedHash(salt, SecurityUtils.saltedDigest(value, salt), key(create = true)) + } catch (e: Exception) { + // Still salted, and the upgrade at the next start tries the key again. + Log.w(TAG, "Keystore unavailable, storing an unkeyed hash", e) + SecurityUtils.hashPassword(value, salt) + } + } + + /** Whether [input] matches [stored], in any format the app has stored. Never makes a key. */ + fun verify(input: String, stored: String): Boolean { + // Before isSaltedHash, which rejects the three parts of a current value. + if (isCurrent(stored)) { + return try { + val parts = stored.split(":") + if (parts.size != 3) return false + + val salt = Base64.decode(parts[1], Base64.NO_WRAP) + val expectedMac = Base64.decode(parts[2], Base64.NO_WRAP) + val actualMac = mac(SecurityUtils.saltedDigest(input, salt), key(create = false)) + + MessageDigest.isEqual(actualMac, expectedMac) + } catch (_: Exception) { + false + } + } + + if (SecurityUtils.isSaltedHash(stored)) return SecurityUtils.verifyPassword(input, stored) + + return SecurityUtils.constantTimeEquals( + SecurityUtils.sanitizePassword(stored), + SecurityUtils.sanitizePassword(input) + ) + } + + fun isCurrent(stored: String): Boolean = stored.startsWith(PREFIX) + + /** + * Brings an older value up to `v2` without the PIN. If the Keystore fails, a salted hash stays as + * it is and plain text becomes one. + */ + fun upgrade(stored: String): String { + if (isCurrent(stored)) return stored + if (!SecurityUtils.isSaltedHash(stored)) return hash(stored) + + return try { + val parts = stored.split(":") + val salt = Base64.decode(parts[0], Base64.NO_WRAP) + val hash = Base64.decode(parts[1], Base64.NO_WRAP) + keyedHash(salt, hash, key(create = true)) + } catch (e: Exception) { + Log.w(TAG, "Keystore unavailable, keeping the unkeyed hash", e) + stored + } + } + + /** + * Whether this install has no key of its own, so stored `v2` values came from a backup or another + * phone and can never match. Any error answers false, so a Keystore hiccup never looks like that. + */ + fun isRestoredWithoutKey(): Boolean { + return try { + !isKeyMadeHere() && !keyStore().containsAlias(KEY_ALIAS) + } catch (_: Exception) { + false + } + } + + private fun keyedHash(salt: ByteArray, digest: ByteArray, key: SecretKey): String { + val saltBase64 = Base64.encodeToString(salt, Base64.NO_WRAP) + val macBase64 = Base64.encodeToString(mac(digest, key), Base64.NO_WRAP) + + return "$PREFIX$saltBase64:$macBase64" + } + + private fun mac(data: ByteArray, key: SecretKey): ByteArray { + val mac = Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256) + mac.init(key) + return mac.doFinal(data) + } + + private fun key(create: Boolean): SecretKey { + synchronized(LOCK) { + (keyStore().getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + + // Once this install has made a key, one that fails to load is an error, not a missing + // key: a new key under the same alias would leave every stored hash unable to match. + check(create && !isKeyMadeHere()) { "Credential key unavailable" } + + val spec = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_SIGN).build() + val generator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE) + generator.init(spec) + val key = generator.generateKey() + keyMarker.writeText(phoneId()) + return key + } + } + + private fun isKeyMadeHere(): Boolean = keyMarker.exists() && keyMarker.readText() == phoneId() + + private fun keyStore(): KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + // Only compared with the marker, on this phone; it's never sent anywhere. + @SuppressLint("HardwareIds") + private fun phoneId(): String = + Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty() + + companion object { + private const val TAG = "CredentialHasher" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "credential_hmac" + private const val KEY_MARKER = "credential_key" + private const val PREFIX = "v2:" + + /** Each repository creates its own hasher, so they share one lock around making the key. */ + private val LOCK = Any() + } +} 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 84c724de..a5da4846 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 @@ -19,11 +19,11 @@ class PreferencesRepository(context: Context) { private val attemptLimiter = UnlockAttemptLimiter(context) + private val credentialHasher = CredentialHasher(context) + fun setPassword(password: String) { - val salt = SecurityUtils.generateSalt() - val saltedHash = SecurityUtils.hashPassword(password, salt) appLockPrefs.edit(commit = true) { - putString(KEY_PASSWORD, saltedHash) + putString(KEY_PASSWORD, credentialHasher.hash(password)) putInt(KEY_PIN_LENGTH, SecurityUtils.sanitizePassword(password).length) } } @@ -39,22 +39,16 @@ class PreferencesRepository(context: Context) { if (stored.isNullOrBlank()) return false val sanitizedInput = SecurityUtils.sanitizePassword(input) + if (!credentialHasher.verify(sanitizedInput, stored)) return false - if (SecurityUtils.isSaltedHash(stored)) { - val isValid = SecurityUtils.verifyPassword(sanitizedInput, stored) - // A PIN set before its length was stored gets the length recorded on its next unlock. - if (isValid && getPinLength() != sanitizedInput.length) { - appLockPrefs.edit(commit = true) { putInt(KEY_PIN_LENGTH, sanitizedInput.length) } - } - return isValid - } - - if (stored == input || stored == sanitizedInput) { + if (!credentialHasher.isCurrent(stored)) { + // Still in an older format the startup upgrade couldn't finish; re-store it. setPassword(sanitizedInput) - return true + } else if (getPinLength() != sanitizedInput.length) { + // A PIN set before its length was stored gets the length recorded on its next unlock. + appLockPrefs.edit(commit = true) { putInt(KEY_PIN_LENGTH, sanitizedInput.length) } } - - return false + return true } /** @@ -67,7 +61,7 @@ class PreferencesRepository(context: Context) { } fun setPattern(pattern: String) { - appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, SecurityUtils.hashPassword(pattern)) } + appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, credentialHasher.hash(pattern)) } } fun getPattern(): String? { @@ -78,30 +72,48 @@ class PreferencesRepository(context: Context) { limitAttempts(inputPattern) { checkPattern(it) } private fun checkPattern(inputPattern: String): Boolean { - val storedPattern = getPattern() ?: return false - - if (SecurityUtils.isSaltedHash(storedPattern)) { - return SecurityUtils.verifyPassword(inputPattern, storedPattern) - } - - // A pattern set before patterns were hashed, if the upgrade at startup didn't get to it. - if (SecurityUtils.constantTimeEquals(storedPattern, inputPattern)) { - setPattern(inputPattern) - return true - } + val storedPattern = getPattern() + if (storedPattern.isNullOrBlank()) return false + if (!credentialHasher.verify(inputPattern, storedPattern)) return false - return false + // Still in an older format the startup upgrade couldn't finish; re-store it. + if (!credentialHasher.isCurrent(storedPattern)) setPattern(inputPattern) + return true } /** - * Brings stored credentials up to the current format when the app starts, before any lock - * screen checks them. A pattern stored as plain text is hashed; patterns are digits only, so - * one can never be mistaken for a salted hash. + * Brings the stored PIN, password and pattern up to the current format when the app starts, + * before any lock screen checks them and without waiting for an unlock. Patterns are digits + * only, so a plain-text one can never be mistaken for a hash. + * + * Ones restored from a backup, or copied over by a phone-transfer app, arrive without the key + * that checks them and could never match again. They're erased instead, so the app asks for a + * new PIN; locked apps and settings stay. */ fun upgradeStoredCredentials() { - val storedPattern = getPattern() - if (!storedPattern.isNullOrBlank() && !SecurityUtils.isSaltedHash(storedPattern)) { - setPattern(storedPattern) + val password = getPassword()?.takeIf { it.isNotBlank() } + val pattern = getPattern()?.takeIf { it.isNotBlank() } + + if (listOfNotNull(password, pattern).any(credentialHasher::isCurrent) && + credentialHasher.isRestoredWithoutKey() + ) { + appLockPrefs.edit(commit = true) { + remove(KEY_PASSWORD) + remove(KEY_PATTERN) + remove(KEY_PIN_LENGTH) + } + return + } + + val upgradedPassword = + password?.takeUnless(credentialHasher::isCurrent)?.let(credentialHasher::upgrade) + val upgradedPattern = + pattern?.takeUnless(credentialHasher::isCurrent)?.let(credentialHasher::upgrade) + if (upgradedPassword == null && upgradedPattern == null) return + + appLockPrefs.edit(commit = true) { + upgradedPassword?.let { putString(KEY_PASSWORD, it) } + upgradedPattern?.let { putString(KEY_PATTERN, it) } } } From b11b75cfabb229cc4501efe801c23489ef613597 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:31:35 -0300 Subject: [PATCH 21/31] docs: add the security audit and the record of its fixes SECURITY_AUDIT.md is the static review the recent fixes came from. It is kept as written at review time, with a Status column added to its findings table: F2, F3, F4, F5, F8, F11, F19, F20 and F22 are fixed, F9 is partly fixed, and every finding still marked Open was re-checked against the code and still holds. SECURITY_FIXES.md records how the fixes shipped in #5, #6 and #7: what each commit changed, what differed from the plan, and the behaviour worth knowing about, such as older builds being unable to read upgraded credentials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GEdeg5PjsskeVpwU2A9ufs --- SECURITY_AUDIT.md | 398 ++++++++++++++++++++++++++++++++++++++++++++++ SECURITY_FIXES.md | 131 +++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 SECURITY_AUDIT.md create mode 100644 SECURITY_FIXES.md diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 00000000..1de0972d --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,398 @@ +# Security audit + +A static review of the whole repository: all four module manifests, both Gradle build files, the +version catalog, the CI workflow, and every Kotlin and Java source file across `app`, `appintro`, +`patternlock` and `hidden-api`. + +| | | +| --- | --- | +| Commit | `8655d0a` | +| Branch | `feature/automation-intents` | +| Version | 2.5.1 (251) | +| Reviewed | 2026-09-09 | +| Follow-up | Anti-uninstall only, at `f5aa5a3` on `master`, 2026-09-10 | +| Fix status | As of 2026-09-11, in the Status column below; details in `SECURITY_FIXES.md` | + +Findings are cited by file and line against that commit and will drift as the branch moves. F18–F23 +come from the follow-up and are cited against `f5aa5a3`. The findings are left as written at review +time; only the Status column tracks what has changed since. + +## Bottom line + +No malicious code, no telemetry, no network egress of any kind. The app does what it claims to do, +and nothing it observes can leave the device. + +It is not, however, risk free. This is a deliberately high-privilege app — accessibility with +window-content retrieval, device admin, screen overlay, full package visibility — and it stores and +checks its own credentials in ways that fall short of what that privilege level warrants. Its +anti-uninstall protection deters a casual attempt but does not survive safe mode, a force stop or +ADB. + +Twenty-three findings; six are worth acting on. Four of the six — F2, F3, F4 and F5 — have since +been fixed, along with F8, F11, F19, F20 and F22; F1 and F18 remain open. + +| Severity | Count | +| --- | --- | +| High and medium | 6 | +| Low | 12 | +| Hygiene | 5 | +| Malicious / exfiltration | 0 | + +## The safety case + +Why the app is trustworthy despite the permissions it asks for. + +- **No `INTERNET` permission in any of the four module manifests.** This is the load-bearing + property of the whole review: an accessibility service that can read window content is only as + dangerous as its ability to transmit, and this one has none. +- **No HTTP client, analytics or ad SDK anywhere.** The only URLs in the source are user-tapped + browser intents — GitHub, Discord, the donate page, Play Store. +- **No obfuscated payloads or dynamic code loading.** No `DexClassLoader`, no long base64 literals, + no decode-and-execute. Reflection is confined to the documented Shizuku and HiddenApiBypass paths. +- **`FLAG_SECURE` on both lock-screen paths** (`LockScreenOverlayManager.kt:222`, + `PasswordOverlayScreen.kt:126`). PIN entry cannot be screenshotted or screen-recorded. +- **The Gradle wrapper is the stock wrapper.** 34 entries, all `org/gradle/*`, nothing injected. +- **No secrets in the tree or in git history.** CI signing takes its keystore from injected + environment secrets, and GitHub withholds those from fork pull requests. +- **The automation receiver is soundly built.** The component stays disabled until opt-in, the token + is 192 bits of `SecureRandom`, comparison is constant-time, and enabling protection fails closed + by dropping every unlock state. + +## Permission inventory + +The privileged half of the manifest, and the one line that makes the rest defensible. + +| Permission | What it buys | +| --- | --- | +| `BIND_ACCESSIBILITY_SERVICE` | Primary lock backend. Configured with `canRetrieveWindowContent` and `flagRetrieveInteractiveWindows` — the broadest observational power on Android. | +| `SYSTEM_ALERT_WINDOW` | Draws the lock overlay above the locked app. This is what Play Protect objects to. | +| `QUERY_ALL_PACKAGES` | Enumerates installed apps for the lock list. Policy-sensitive on Play, unremarkable on F-Droid. | +| `PACKAGE_USAGE_STATS` | Alternate foreground-app detection backend. | +| `FOREGROUND_SERVICE_SPECIAL_USE`, `FOREGROUND_SERVICE_SYSTEM_EXEMPTED` | Keeps the lock backend alive across Doze and task killers. | +| `RECEIVE_BOOT_COMPLETED` | Restarts protection after reboot. | +| `USE_BIOMETRIC`, `VIBRATE`, `POST_NOTIFICATIONS`, `SCHEDULE_EXACT_ALARM`, `HIDE_OVERLAY_WINDOWS`, `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Routine, and each is used for the obvious thing. | +| **`INTERNET` — not declared** | Absent from the app module and from all three library modules. Nothing observed can be transmitted. Any future dependency that introduces it invalidates this entire review. | + +## Findings + +| ID | Severity | Finding | Status | +| --- | --- | --- | --- | +| F1 | High/medium | Credentials are eligible for cloud backup | Open | +| F2 | High/medium | The unlock pattern is stored in cleartext | Fixed in #7 (`a5d4dee`) | +| F3 | High/medium | Password hashing has no key stretching | Fixed in #7 (`1c17e43`), with a Keystore-bound HMAC rather than stretching | +| F4 | High/medium | No rate limiting on unlock attempts | Fixed in #6 | +| F5 | High/medium | Weak biometrics are accepted | Fixed in #5 (`4541a14`) | +| F6 | Low | Protection state is broadcast to every app on the device | Open | +| F7 | Low | Rejection reasons leak configuration to unauthenticated callers | Open | +| F8 | Low | The in-app shield toggle does not re-lock on enable | Fixed in #5 (`10d89dd`) | +| F9 | Low | Anti-uninstall detection has two conditions that can never match | Partly: both casing bugs are gone (`907ddac`, `5d9935c`); the package-installer check is unchanged and was not re-verified | +| F10 | Low | Uninstall blocking targets the wrong user | Open | +| F11 | Low | Non-null assertion on a nullable framework field | Fixed in `5d9935c` | +| F12 | Low | Blocking sleeps on the main thread | Open | +| F13 | Low | Log retention does not match its documentation | Open | +| F14 | Hygiene | 110 MB of history from committed APKs | Open | +| F15 | Hygiene | IDE state tracked against its own ignore rule | Open | +| F16 | Hygiene | Empty ProGuard rules with minification enabled | Open | +| F17 | Hygiene | CI actions pinned to tags, and every branch builds twice | Open | +| F18 | High/medium | Anti-uninstall can be bypassed without the PIN | Open; a fix was considered and dropped as too complex | +| F19 | Low | Granting device admin crashes the app | Fixed in #5 (`fcae9e7`) | +| F20 | Low | Accessibility-settings detection only works in English | Fixed in `907ddac`; #5 closed the gap it left | +| F21 | Low | Manually added packages show as protected but are never blocked | Open | +| F22 | Low | The device-admin guard blocks every app's admin screens | Fixed in `5d9935c` | +| F23 | Hygiene | Unused admin-verification code | Open | + +Every finding marked Open was re-checked against the code on 2026-09-11 and still holds. + +### High and medium + +#### F1 — Credentials are eligible for cloud backup + +`AndroidManifest.xml:32,34,36` · `res/xml/backup_rules.xml` · `res/xml/data_extraction_rules.xml` + +The manifest sets `allowBackup="true"`, and both rules files it points at are unedited Android +Studio templates with every rule commented out. So `app_lock_prefs` — password hash, unlock pattern +and automation token — is carried into Google cloud backup and device-to-device transfer. For an app +whose entire purpose is keeping a credential on one device, that is the wrong default. + +**Fix:** exclude both preference files explicitly in `data_extraction_rules.xml` and +`backup_rules.xml`, or set `allowBackup="false"`. + +#### F2 — The unlock pattern is stored in cleartext + +`PreferencesRepository.kt:48` · `PreferencesRepository.kt:56-58` + +`setPattern` writes the raw pattern string to SharedPreferences, and `validatePattern` compares it +with `==`. PINs and alphanumeric passwords get a salted hash; patterns get nothing at all. Combined +with F1, a pattern lock leaves the device in plaintext. + +**Fix:** route patterns through the same `SecurityUtils` hash path as passwords, with a migration +for existing users mirroring the one already in `validatePassword`. + +#### F3 — Password hashing has no key stretching + +`SecurityUtils.kt:10` · `SecurityUtils.kt:59-73` + +Hashing is a single round of salted SHA-256. The salt is correct — 16 bytes from `SecureRandom`, per +password — and it defeats rainbow tables, but it does nothing about speed. Against a 4-6 digit PIN +the entire keyspace falls in well under a second on commodity hardware once the hash is in hand. F1 +is what puts it in hand. + +**Fix:** PBKDF2, scrypt or Argon2 with a calibrated iteration count, keeping the existing +`salt:hash` envelope and versioning the prefix so old hashes upgrade on next successful unlock. + +#### F4 — No rate limiting on unlock attempts + +`features/lockscreen/` · `features/admin/AdminDisableActivity.kt` + +There is no attempt counter, backoff or lockout anywhere in the lock screen, the admin-disable +screen or the set-password flow. A 4-digit PIN with unlimited guesses is 10,000 taps from the front, +no extraction required — and the same screen guards disabling anti-uninstall. + +**Fix:** a persisted failure counter with escalating delay, shared by every entry point that calls +`validatePassword` or `validatePattern`. + +#### F5 — Weak biometrics are accepted + +`PasswordOverlayScreen.kt:256-260` + +The prompt allows `BIOMETRIC_WEAK or BIOMETRIC_STRONG` with `setConfirmationRequired(false)`. On many +devices the weak class is 2D face unlock, which is photo-spoofable — an easier path past the lock +than the PIN behind it. An earlier commit in this repo is titled "Enable Strong Biometric +Authenticators". + +**Fix:** request `BIOMETRIC_STRONG` only, and fall back to the PIN where the device cannot supply it. + +### Low + +#### F6 — Protection state is broadcast to every app on the device + +`AutomationReceiver.kt:117-124` + +`PROTECTION_STATE` is sent as an implicit broadcast with no permission attached, so any installed app +can register a receiver and learn the moment protection goes off — which, given the intended Wi-Fi +and location triggers, is also a presence signal. The design note explains why it is implicit and the +reasoning holds; the cost is simply worth stating. + +#### F7 — Rejection reasons leak configuration to unauthenticated callers + +`AutomationReceiver.kt:81-84` + +`reject()` returns its reason as `resultData` to ordered senders, distinguishing "automation control +is off" from "no token has been generated" from "invalid or missing token". An app with no token +learns your configuration. The token itself is 192 bits and not brute-forceable, so this is +disclosure, not bypass. + +#### F8 — The in-app shield toggle does not re-lock on enable + +`MainScreen.kt:167-170` · `AutomationReceiver.kt:65-72` + +The automation path calls `clearAllUnlockStates()` when protection returns, so nothing left unlocked +stays open. The shield toggle on the main screen writes the same flag but skips that step. Toggle off +then on in the UI and an already-unlocked app remains accessible — the same sequence, two different +security outcomes. + +#### F9 — Anti-uninstall detection has two conditions that can never match + +`AppLockAccessibilityService.kt:406` · `AppLockAccessibilityService.kt:423-424` + +Both lowercase the haystack and then search for a capitalised needle — +`.lowercase().contains("App Lock")` and `.lowercase()?.contains("Device admin app")`. The +package-installer dialog check and the device-admin content-description check are therefore dead. The +two `SubSettings` checks above them do not lowercase and do work, so the feature partly functions, +but it is weaker than it reads. + +#### F10 — Uninstall blocking targets the wrong user + +`core/utils/Shizuku.kt:12` · `core/utils/Shizuku.kt:16` + +`Process.myUserHandle().describeContents()` is the Parcelable contract method — it returns 0, not a +user id. On a single-user device that is accidentally correct; under a work profile or secondary +user, anti-uninstall is silently applied to the wrong user. + +#### F11 — Non-null assertion on a nullable framework field + +`AppLockAccessibilityService.kt:428` + +`event.className!!` throws when an accessibility event carries no class name. The outer handler +catches and logs it, so there is no crash — but the device-admin page check silently returns nothing +for those events, which is precisely when anti-uninstall is meant to fire. + +#### F12 — Blocking sleeps on the main thread + +`AppLockAccessibilityService.kt:346` · `AppLockAccessibilityService.kt:443` + +`Thread.sleep(200)` and `Thread.sleep(100)` run inside main-thread handlers between global actions. +ANR risk on a slow device, at exactly the moment the app is trying to assert control. + +#### F13 — Log retention does not match its documentation + +`LogUtils.kt:162` + +The variable is named `threeDaysAgo`, the KDoc says three days, the log messages say three days, and +the value is `minus(7, ChronoUnit.DAYS)`. Audit logs live twice as long as documented. + +### Hygiene + +#### F14 — 110 MB of history from committed APKs + +`app/debug/app-debug.apk` · `app/release/app-release.apk` + +Seven debug builds at roughly 20 MB each, plus eight release builds, were committed and later +deleted. They are gone from `HEAD` but remain in history, so `.git` is 110 MB against a working tree +of about 3 MB. Every clone pays for it. Rewriting history fixes it but breaks existing forks — a +judgement call, not a defect. + +#### F15 — IDE state tracked against its own ignore rule + +`.idea/misc.xml` · `.gitignore` + +`.gitignore` lists `/.idea`, but `misc.xml` was committed before the rule existed and is still +tracked, so the rule does not apply to it. + +#### F16 — Empty ProGuard rules with minification enabled + +`app/proguard-rules.pro` · `app/build.gradle.kts:41-52` + +All four rules files contain only the default comments while release builds run `isMinifyEnabled` and +`isShrinkResources`. Nothing is broken today, but the AIDL interfaces and hidden-API stubs are exactly +the surface R8 tends to strip in ways that surface only at runtime on a user's device. + +#### F17 — CI actions pinned to tags, and every branch builds twice + +`.github/workflows/android.yml` + +All five actions are pinned to version tags rather than commit SHAs — a tag can be moved. The signing +path itself is sound: the keystore is decoded from secrets into `RUNNER_TEMP`, and GitHub withholds +secrets from fork pull requests, so the release key is not reachable from an untrusted PR. +Separately, `on: push` plus `pull_request` means each branch builds twice. + +## Anti-uninstall follow-up + +A second pass over the anti-uninstall path alone, at `f5aa5a3` on `master`: the accessibility +service's detection and blocking, the device admin receiver, the admin-disable flow, and the Shizuku +uninstall block on the Anti-Uninstall screen. Line numbers in this section are against that commit. + +The feature is two separate mechanisms. The settings toggle relies on Android refusing to uninstall +an app whose device admin is active. The system enforces that, but the admin can be switched off +from Settings, and the only thing guarding that switch is the accessibility service recognising the +page and pressing Back, Home and Lock Screen. The Anti-Uninstall screen instead calls +`setBlockUninstallForUser` through Shizuku, which the package manager enforces across reboots and in +safe mode. It covers only the apps switched on there — App Lock is not one of them by default — and +anyone with ADB or Shizuku can reverse it. + +### High and medium + +#### F18 — Anti-uninstall can be bypassed without the PIN + +`AppLockAccessibilityService.kt:408-488` · `AppLockAccessibilityService.kt:561-563` · `res/values/strings.xml:169` · `README.md:69-70` + +Three routes never meet the accessibility guard: + +- **Safe mode.** No third-party code runs, the accessibility service included. Deactivate the device + admin in Settings, then uninstall. +- **Force stop.** On stock Android, force-stopping an app also removes it from the enabled + accessibility services. No check is aimed at App info or its confirmation dialog, and the Shizuku + re-enable in `onUnbind` cannot run once the process is dead. +- **ADB.** Either `am force-stop` or `settings put secure enabled_accessibility_services` does the + same without touching the Settings screens being watched. + +Where the guard does fire, it fires after the page is already on screen, and locking the phone stops +no one who knows its PIN from unlocking and trying again. The settings text promises to "Prevent +unauthorized uninstallation of App Lock" and the README lists anti-uninstall protection as a +feature; against anyone who knows these routes, neither holds. + +**Fix:** only a Device Owner can close these routes, with `setUninstallBlocked` and the +`DISALLOW_SAFE_BOOT`, `DISALLOW_APPS_CONTROL` and `DISALLOW_DEBUGGING_FEATURES` restrictions. That +means provisioning on a device with no accounts, or QR or MDM enrolment — a heavy ask for this app. +Short of that, describe the feature in the settings text and README as a deterrent, not prevention. + +### Low + +#### F19 — Granting device admin crashes the app + +`core/broadcast/DeviceAdmin.kt:23` + +`onEnabled` calls `DevicePolicyManager.setUninstallBlocked`, which only a device owner or profile +owner may call. App Lock is neither, so the call throws `SecurityException`, nothing catches it, and +the process crashes each time the user grants device admin. The call has never blocked anything: +what stops uninstall is the admin being active, which the system records before `onEnabled` runs. +The `anti_uninstall` flag written just above uses `apply()` and may not reach disk before the crash. + +#### F20 — Accessibility-settings detection only works in English + +`AppLockAccessibilityService.kt:434,436` · `AndroidManifest.xml:97` · `res/values-ar/strings.xml:3` + +Both accessibility-settings checks search for the literal `"App Lock"`, case-sensitively, but the +name Settings shows comes from `@string/app_name`. The one shipped translation, Arabic, renders it +as "قفل التطبيقات", so on an Arabic-locale device neither check can match. The device-admin check +keys on class names and still works, but it runs inside the accessibility service — and the page for +switching that service off is the one left unguarded. + +#### F21 — Manually added packages show as protected but are never blocked + +`features/antiuninstall/ui/AntiUninstallScreen.kt:142-157` · `features/antiuninstall/ui/AntiUninstallScreen.kt:159-170` + +`addManualPackage` saves the package and shows its switch on, but never calls +`blockUninstallForUser`; only `toggleAppProtection` does. A manually added package stays +uninstallable until it is toggled off and on again. `toggleAppProtection` also saves before calling +Shizuku, so if that call throws, the app crashes with the package already recorded as protected. + +#### F22 — The device-admin guard blocks every app's admin screens + +`AppLockAccessibilityService.kt:455-464` · `AppLockAccessibilityService.kt:472` + +`isDeviceAdminPage` matches on class name alone, and `blockDeviceAdminDeactivation` checks only that +App Lock's own admin is active, not which admin the page belongs to. While anti-uninstall is on, +opening the device-admin list, or any other app's activation or removal screen, sends the user to +the lock screen. + +### Hygiene + +#### F23 — Unused admin-verification code + +`core/broadcast/DeviceAdmin.kt:33-35` · `ui/components/AdminPasswordVerificationDialog.kt` · `features/admin/AdminDisableActivity.kt:77,115,153` + +`AdminDisableActivity` calls `setPasswordVerified` in seven places and nothing reads the flag. +`AdminPasswordVerificationDialog` is never used, and `DeviceAdmin` does not override +`onDisableRequested`. Disabling anti-uninstall in the app only clears the `anti_uninstall` flag; the +device admin stays active until it is removed in Settings. Wiring this up would not close F18: +`onDisableRequested` can show a warning but cannot refuse. + +### Earlier findings at this commit + +F9–F12 all still hold at `f5aa5a3`. F10 has not moved; the other three now sit at: + +| ID | Location at `f5aa5a3` | +| --- | --- | +| F9 | `AppLockAccessibilityService.kt:439,456-457` | +| F11 | `AppLockAccessibilityService.kt:461` | +| F12 | `AppLockAccessibilityService.kt:368,476` | + +F9 also understates its first case. Fixing the casing would not revive the package-installer check +at `AppLockAccessibilityService.kt:437-439`: detection only runs for events from +`com.android.settings` (`AppLockAccessibilityService.kt:120-121`), and that check requires +`com.google.android.packageinstaller`. + +## One behavioural caveat + +With anti-uninstall enabled, opening accessibility settings or the device-admin page triggers +`GLOBAL_ACTION_BACK`, `GLOBAL_ACTION_HOME` and then `GLOBAL_ACTION_LOCK_SCREEN` — the app locks the +phone to stop you reaching the switch. That is the advertised feature working as designed, and it is +the right shape for a parental-control or shared-device threat model — though, as F18 shows, not a +barrier within one. It can also trap a user who legitimately wants out, and it is a far more likely +explanation for Play Protect's warning than the overlay permission the README blames. + +## Scope and method + +- Static review of the complete tree at `8655d0a`. +- **Follow-up:** the anti-uninstall path was re-read in full at `f5aa5a3`, also statically. Claims + about platform behaviour — safe mode, force stop, the Shizuku uninstall block persisting — follow + from stock Android and were not reproduced on a device. +- Git history was scanned for committed secrets and binaries; the Gradle wrapper JAR was opened and + its entries enumerated. +- **Not covered:** the project was not built or run. No dynamic analysis, no instrumented testing, no + APK inspection. +- **Not covered:** third-party dependencies — Shizuku, hiddenapibypass, AndroidX, Compose — resolve + as binaries at build time from Maven Central and Google's repository, and were not examined. +- The absence of `INTERNET` is the assumption the safety case rests on. It holds at this commit + across all four modules; a dependency that introduces it later would need this review redone. diff --git a/SECURITY_FIXES.md b/SECURITY_FIXES.md new file mode 100644 index 00000000..431d18ea --- /dev/null +++ b/SECURITY_FIXES.md @@ -0,0 +1,131 @@ +# Security fixes + +How the findings in `SECURITY_AUDIT.md` were fixed: three PRs, each built by CI and tested on a phone +before merging. The audit's findings table records the status of every finding, including the ones +left open. + +In scope: F2, F3, F4, F5, F8, F19, F20 and F22. F1 and F18 were not taken on; F18 was considered and +dropped as too complex. + +## Status + +| Chunk | Findings | Branch | PR | CI | On device | Merged | +| --- | --- | --- | --- | --- | --- | --- | +| 1 — Quick fixes | F5, F8, F19, F20 | `fix/security-quick-fixes` | [#5](https://github.com/thiagosoeiro/AppLock/pull/5) | green (`8aff4bd`) | done | `1a77897` | +| 2 — Rate limiting | F4 | `fix/security-rate-limiting` | [#6](https://github.com/thiagosoeiro/AppLock/pull/6) | green (`8f790a9`) | LGTM | `fa1e06b` | +| 3 — Credential storage | F2, F3 | `fix/security-credential-storage` | [#7](https://github.com/thiagosoeiro/AppLock/pull/7) | green (`1c17e43`) | LGTM | in #7 | + +F22 was already done (fixed in `5d9935c`). F20's main fix shipped in `907ddac`, and chunk 1 closed +the gap it left. + +## How the chunks ran + +- Each chunk branched off an up-to-date `master` once the previous one had merged. Chunks 2 and 3 + both rewrite `PreferencesRepository`, so they were not stacked. +- One commit per finding, so any one can be reverted alone. Nothing was compiled locally; CI was the + first build. +- Each PR was tested on a phone before merging, and its plan here was then cut down to what shipped. + +## Why this split and order + +- Chunk 1 is close to risk-free and ships the crash fix (F19) and strong-only biometrics (F5) + without waiting on the risky work. +- Chunks 2 and 3 can lock you out of your own apps. Separate PRs keep a lockout traceable to one + change and revertible on its own. +- F4 before F3: F4 adds the PIN-length gate, which keeps F3's Keystore check off every Auto Unlock + keypress. +- Against the threat this fork targets — a thief holding an unlocked phone — F4 is worth more than + F2 and F3, which only matter once the hash is off the device. + +## Chunk 1 — Quick fixes (done) + +Merged in PR #5 as `1a77897`, after CI and the on-device checklist. + +- **F19** (`fcae9e7`). Removed the `setUninstallBlocked` call that crashed `DeviceAdmin.onEnabled` + (only a device or profile owner may call it); the flag is written with `commit`. + `ANTI_UNINSTALL.md` now credits the active admin for blocking Uninstall. +- **F8** (`10d89dd`). The shield toggle and automation both call `AutomationReceiver.setProtection`, + so turning protection back on clears unlocks and restarts the lock service from either. +- **F5** (`4541a14`). `BIOMETRIC_STRONG` only, in all three prompts and `canAuthenticateBiometrics`. + On the S24 Ultra that means fingerprint only. +- **F20** (`0855247`). `ownLabel` is a getter read on every check, so the guards follow a language + change without a reboot. +- **Differed from the plan.** A regression review found that the Settings Anti-Uninstall toggle read + the flag once, which the F19 crash had hidden by restarting the app. `8aff4bd` makes + `SettingsScreen` listen for changes to the flag, which also stops the toggle showing ON after + protection is turned off. +- **Known edge cases, left alone.** The lock-screen biometric button still shows on phones without + strong biometrics; its prompt falls back to the PIN. With the Usage Stats backend and usage access + revoked, turning the shield on can crash the app, because `UsageLockService` stops before calling + `startForeground`; automation and boot already had that path. + +## Chunk 2 — Rate limiting (done) + +Merged in PR #6 as `fa1e06b`, after CI; signed off as LGTM. + +- **Limit** (`f83f197`). New `UnlockAttemptLimiter`, stored in `app_lock_prefs`: 4 free wrong tries, + then 30s doubling to a 30 min cap. The deadline is on `elapsedRealtime` plus the boot count, so the + clock in Settings can't end it and a reboot restarts it in full. + - `PreferencesRepository.validatePassword` / `validatePattern` go through `limitAttempts`, which + refuses everything during a wait and doesn't count misses under 4 characters. The old bodies are + private `checkPassword` / `checkPattern`. + - `setPassword` stores `pin_length`; a successful `checkPassword` records it for older PINs. Auto + Unlock checks only at that length. + - A strong biometric success calls `clearFailedAttempts()` in all three prompts. +- **Countdown** (`70efb2c`). `Lockout.kt` (`rememberLockoutSeconds`, composable `lockoutMessage`, + `Context.getLockoutMessage`) and one English string. The PIN, pattern and password lock screens and + both anti-uninstall-off screens show "Too many attempts. Try again in 0:30" and disable input; the + change-PIN screens show it as a toast. +- **Differed from the plan.** `8f790a9`, from the regression review: the pattern and password screens + clear the leftover "Incorrect …" when a wait starts or ends, and the password fields refocus so the + keyboard comes back. `Lockout.kt` gained the composable `lockoutMessage` alongside the Context one. +- **By design, worth remembering.** + - Waiting doesn't forgive wrong tries; only a correct entry or fingerprint does, and each miss after + the 5th lengthens the wait. A reboot brings a full wait back if no correct entry came since, even + one that had already run out. + - Auto Unlock waits for → until the first PIN unlock on a chunk 2 build (fingerprint doesn't record + the length). A wrong last digit now clears the PIN and counts. + - The anti-uninstall-off screen has no fingerprint button, so end a wait on a lock screen first. + Phones without strong biometrics can only wait. + +## Chunk 3 — Credential storage (done) + +Merged in PR #7 after CI; signed off as LGTM. The same PR added this file and the audit to the repo. + +- **F2** (`a5d4dee`). `setPattern` stores a salted SHA-256, like the PIN and password. A pattern still + stored as plain text is hashed when the app starts; if that couldn't run, a plain-text match still + unlocks and re-stores it hashed. +- **F3** (`1c17e43`). New `CredentialHasher`. The PIN, password and pattern are stored as + `v2:salt:mac`: an HMAC-SHA256 of the salted SHA-256, under a non-exportable Android Keystore key + (TEE). A copied hash can only be checked on the phone that holds the key. + - Chosen over the audit's suggested PBKDF2, which can't save a 4–6 digit PIN or a pattern: a GPU + exhausts those in seconds to minutes at any practical iteration count. + - The MAC covers the old hash, so existing values upgrade when the app starts, without an unlock. + If the Keystore fails, new values fall back to `salt:hash` and older ones are still checked. + - Credentials restored from a backup, or copied by a phone-transfer app, arrive without the key. A + marker in `noBackupFilesDir` holding the phone's Android ID detects that, and the app erases them + and asks for a new PIN, keeping locked apps and settings. + - On the phone that made the key, a key that fails to load is an error and is never replaced, so no + stored hash is orphaned and nothing is erased on a Keystore hiccup. + - `limitAttempts` still wraps both checks, and `setPassword` still stores `pin_length`. +- **Differed from the plan.** `forgetKey()` was dropped. A key may be generated whenever the marker + doesn't hold this phone's ID, so a marker copied from another phone can't block it. +- **By design, worth remembering.** + - No going back: older builds can't read upgraded credentials. To roll back, unlock with + fingerprint and use Forgot PIN on the change-PIN screen, or clear storage. + - Keystore errors fail closed: the entry is refused and counts toward the wait. Fingerprint still + works and clears it. + - A key lost on the same phone erases nothing, so the PIN stops working. Keep fingerprint on; + without it, removing device admin takes safe mode. + - A few cheap or uncertified phones keep Keystore keys in software, which works but is weaker. + - The missing-key path was not tested on a phone, since that needs a restore without the key. +- **Follow-ups noted, not done.** The main screen reads the protection state once, so the shield can + still show ON after automation has turned protection off. And the log doesn't say whether a change + came from the shield or from automation. + +## Testing chunks 2 and 3 + +Install over the current build with your real PIN and pattern, with anti-uninstall **off and device +admin removed**. An active admin greys out Clear storage and the App info guard bounces you, so a +lockout bug would leave no easy way to reset the app. Turning anti-uninstall off in the app does not +remove the admin; do that in Settings. From 16b89d2a43794138182139f9959ca549398e5c2c Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:38:26 -0300 Subject: [PATCH 22/31] feat: open locked apps without authentication on trusted Wi-Fi Builds option A of the trusted-Wi-Fi feature request into the app: while the phone is on a trusted Wi-Fi network, the lock backends let locked apps open, so protection can follow home Wi-Fi without an automation app. TrustedNetworkMonitor follows the connected Wi-Fi through a network callback and fails closed throughout, since it switches locking off: - trust is kept in memory only, so a reboot or restart stays protected until Android reports a trusted network; - a name that can't be read (Location off, no location access, or access only while in use) counts as untrusted, and turning Location off drops trust at once; - when the screen turns on or the phone unlocks, the name is read again and anything but a trusted name drops trust. That check never grants trust, and unlike a timer it costs no wake-ups. The three backends now check isProtectionActive(): the shield is on and no trusted network is relaxing it. The shield and the automation intents still read and write only the shield flag, so the two features stay independent. Anti-uninstall runs before that check and is unaffected. Leaving a trusted network re-locks through relockAll, moved out of AutomationReceiver.setProtection, so both paths clear unlocks and restart the services the same way. Off by default; the Settings screen that turns it on comes next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- app/src/main/AndroidManifest.xml | 7 + .../dev/pranav/applock/AppLockApplication.kt | 10 + .../core/broadcast/AppLockServiceStarter.kt | 19 ++ .../core/broadcast/AutomationReceiver.kt | 10 +- .../core/network/TrustedNetworkMonitor.kt | 314 ++++++++++++++++++ .../data/repository/AppLockRepository.kt | 16 + .../data/repository/PreferencesRepository.kt | 26 ++ .../services/AppLockAccessibilityService.kt | 2 +- .../applock/services/UsageLockService.kt | 2 +- .../applock/shizuku/ShizukuActivityManager.kt | 2 +- 10 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d2b3bc9c..f7ca1bc7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,13 @@ + + + + + + + diff --git a/app/src/main/java/dev/pranav/applock/AppLockApplication.kt b/app/src/main/java/dev/pranav/applock/AppLockApplication.kt index 4780ac94..b387e6c6 100644 --- a/app/src/main/java/dev/pranav/applock/AppLockApplication.kt +++ b/app/src/main/java/dev/pranav/applock/AppLockApplication.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Build import android.util.Log import dev.pranav.applock.core.broadcast.AutomationReceiver +import dev.pranav.applock.core.network.TrustedNetworkMonitor import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.data.repository.AppLockRepository import org.lsposed.hiddenapibypass.HiddenApiBypass @@ -39,6 +40,15 @@ class AppLockApplication : Application() { // two drifted apart across a restore or an update. AutomationReceiver.setComponentEnabled(this, appLockRepository.isAutomationEnabled()) + // Trust in a Wi-Fi network is never stored, so every start is protected until Android + // reports the network the phone is on. A failure here must not crash the app on every start, + // which would stop locking altogether. + try { + TrustedNetworkMonitor.refresh(this) + } catch (e: Exception) { + Log.e(TAG, "Failed to start trusted Wi-Fi monitoring", e) + } + // Purge logs older than 3 days on every app start (run in background to avoid ANR) thread(start = true, name = "LogPurge") { LogUtils.purgeOldLogs() diff --git a/app/src/main/java/dev/pranav/applock/core/broadcast/AppLockServiceStarter.kt b/app/src/main/java/dev/pranav/applock/core/broadcast/AppLockServiceStarter.kt index 84e3c62b..59c135f2 100644 --- a/app/src/main/java/dev/pranav/applock/core/broadcast/AppLockServiceStarter.kt +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/AppLockServiceStarter.kt @@ -5,8 +5,10 @@ import android.content.Intent import android.os.Build import android.util.Log import androidx.core.content.ContextCompat +import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.data.repository.BackendImplementation import dev.pranav.applock.services.AppLockAccessibilityService +import dev.pranav.applock.services.AppLockManager import dev.pranav.applock.services.ShizukuAppLockService import dev.pranav.applock.services.UsageLockService import dev.pranav.applock.services.isServiceRunning @@ -37,6 +39,23 @@ object AppLockServiceStarter { } } + /** + * Puts locking back in force after a stretch without it: the shield turned back on, or the + * phone leaving a trusted Wi-Fi network. Fails closed - an app unlocked meanwhile must not stay + * unlocked, and the backend service may have been stopped. + */ + fun relockAll( + context: Context, + repository: dev.pranav.applock.data.repository.AppLockRepository + ) { + AppLockManager.clearAllUnlockStates() + try { + startAppropriateServices(context, repository) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to start services while re-locking", e) + } + } + private fun startService(context: Context, serviceClass: Class<*>) { try { if (context.isServiceRunning(serviceClass)) { diff --git a/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt b/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt index b3fd6d79..a595b250 100644 --- a/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageManager import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.SecurityUtils import dev.pranav.applock.core.utils.appLockRepository -import dev.pranav.applock.services.AppLockManager /** * Lets automation apps (MacroDroid, Tasker, adb) set the global protection flag - the same flag @@ -101,14 +100,7 @@ class AutomationReceiver : BroadcastReceiver() { repository.setProtectEnabled(enabled) if (enabled) { - // Fail closed: an app unlocked while protection was off must not stay unlocked once - // protection comes back, and the backend service may have been stopped meanwhile. - AppLockManager.clearAllUnlockStates() - try { - AppLockServiceStarter.startAppropriateServices(context, repository) - } catch (e: Exception) { - LogUtils.e(TAG, "Failed to start services after enabling protection", e) - } + AppLockServiceStarter.relockAll(context, repository) } LogUtils.d(TAG, "Protection set to $enabled") diff --git a/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt new file mode 100644 index 00000000..64089eaf --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt @@ -0,0 +1,314 @@ +package dev.pranav.applock.core.network + +import android.Manifest +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.location.LocationManagerCompat +import dev.pranav.applock.core.broadcast.AppLockServiceStarter +import dev.pranav.applock.core.utils.LogUtils +import dev.pranav.applock.core.utils.appLockRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Tracks whether the phone is on a trusted Wi-Fi network, so the lock backends can let locked apps + * open there without authentication (see `AppLockRepository.isProtectionActive`). + * + * Fails closed throughout, since this switches locking off: + * - trust lives only in memory, so after a reboot or restart apps stay locked until Android + * reports a trusted network; + * - a network whose name can't be read - Location off, location access missing or limited to + * while the app is in use - counts as untrusted; + * - when the screen turns on or the phone unlocks the name is read again, and anything but a + * trusted name drops trust at once. That check only ever takes trust away; only Android's + * network updates grant it. + * + * Networks are matched by name alone, so mesh and dual-band networks need no extra setup. + */ +@SuppressLint("StaticFieldLeak") // Holds the application context only. +object TrustedNetworkMonitor { + private const val TAG = "TrustedNetworkMonitor" + + /** What Android reports in place of a Wi-Fi name it can't read or won't share. */ + private const val UNKNOWN_SSID = "" + + data class State( + /** The connected Wi-Fi's name as Android reports it (quoted), or null if none is readable. */ + val currentSsid: String? = null, + val trusted: Boolean = false + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + // Everything below is only touched while holding this object's lock. + private var appContext: Context? = null + private var networkCallback: WifiCallback? = null + private var receiver: BroadcastReceiver? = null + private val ssidsByNetwork = HashMap() + + fun isOnTrustedNetwork(): Boolean = _state.value.trusted + + /** + * Starts or stops monitoring to match the setting, then re-evaluates trust. Call it whenever the + * setting, the trusted networks or location access may have changed. + */ + @Synchronized + fun refresh(context: Context) { + val app = context.applicationContext + appContext = app + + if (!app.appLockRepository().isTrustedWifiEnabled()) { + stop(app) + return + } + + if (receiver == null) { + registerReceiver(app) + LogUtils.d(TAG, "Started monitoring Wi-Fi") + } + // Once trusted there is nothing to gain. Otherwise ask again: location access may have been + // granted since Android last reported the network, and it only reports again on a change. + if (networkCallback == null || !_state.value.trusted) { + registerNetworkCallback(app) + } + recompute(app) + } + + /** The name to show for [ssid]. Android quotes names that are valid UTF-8. */ + fun displayName(ssid: String): String = + if (ssid.length >= 2 && ssid.startsWith('"') && ssid.endsWith('"')) { + ssid.substring(1, ssid.length - 1) + } else { + ssid + } + + fun hasLocationPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + /** Before Android 10 there is no separate background permission; location access covers it. */ + fun hasBackgroundLocationPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + fun isLocationEnabled(context: Context): Boolean { + val locationManager = context.getSystemService(LocationManager::class.java) ?: return false + return LocationManagerCompat.isLocationEnabled(locationManager) + } + + private fun stop(context: Context) { + val wasRunning = receiver != null || networkCallback != null + unregisterNetworkCallback(context) + receiver?.let { + try { + context.unregisterReceiver(it) + } catch (_: IllegalArgumentException) { + LogUtils.d(TAG, "Receiver was not registered") + } + } + receiver = null + updateState(context, State()) + if (wasRunning) LogUtils.d(TAG, "Stopped monitoring Wi-Fi") + } + + /** + * Registers a fresh callback in place of any earlier one. Android reports every current Wi-Fi + * network to a new callback straight away. What the old one saw is dropped rather than carried + * over: a network that has already gone is never reported to the new callback, so a leftover + * entry for it would stay trusted. + */ + private fun registerNetworkCallback(context: Context) { + unregisterNetworkCallback(context) + + val connectivityManager = context.getSystemService(ConnectivityManager::class.java) ?: return + val callback = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + WifiCallback(ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO) + } else { + WifiCallback() + } + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build() + + try { + connectivityManager.registerNetworkCallback(request, callback) + networkCallback = callback + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to register the Wi-Fi callback", e) + } + } + + private fun unregisterNetworkCallback(context: Context) { + ssidsByNetwork.clear() + val callback = networkCallback ?: return + networkCallback = null + try { + context.getSystemService(ConnectivityManager::class.java) + ?.unregisterNetworkCallback(callback) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to unregister the Wi-Fi callback", e) + } + } + + private fun registerReceiver(context: Context) { + val newReceiver = object : BroadcastReceiver() { + override fun onReceive(receiverContext: Context, intent: Intent) { + val app = receiverContext.applicationContext + when (intent.action) { + LocationManager.MODE_CHANGED_ACTION -> onLocationModeChanged(app) + Intent.ACTION_SCREEN_ON, + Intent.ACTION_USER_PRESENT -> checkOnScreenOn(app) + } + } + } + val filter = IntentFilter().apply { + addAction(LocationManager.MODE_CHANGED_ACTION) + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_USER_PRESENT) + } + + try { + ContextCompat.registerReceiver( + context, + newReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) + receiver = newReceiver + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to register the screen and Location receiver", e) + } + } + + @Synchronized + private fun onLocationModeChanged(context: Context) { + if (networkCallback == null) return + + val enabled = isLocationEnabled(context) + LogUtils.d(TAG, "Location turned ${if (enabled) "on" else "off"}") + + // Off: no name can be read any more, so drop trust now instead of at the next network change. + // On: a trusted network the phone is already on is only reported to a fresh callback. + if (!enabled || !_state.value.trusted) { + registerNetworkCallback(context) + recompute(context) + } + } + + /** Takes trust away if the network name read now isn't a trusted one. Never grants it. */ + @Synchronized + private fun checkOnScreenOn(context: Context) { + if (networkCallback == null || !_state.value.trusted) return + + val ssid = readConnectionSsid(context) + if (ssid != null && ssid in context.appLockRepository().getTrustedWifiSsids()) return + + LogUtils.d(TAG, "Screen-on check found no trusted network") + // A trusted network that really is there comes straight back through the fresh callback. + registerNetworkCallback(context) + recompute(context) + } + + @Synchronized + private fun onNetworkSsid(callback: WifiCallback, network: Network, ssid: String?) { + if (callback !== networkCallback) return + + val readableSsid = readable(ssid) + if (readableSsid == null) { + ssidsByNetwork.remove(network) + } else { + ssidsByNetwork[network] = readableSsid + } + appContext?.let { recompute(it) } + } + + @Synchronized + private fun onNetworkLost(callback: WifiCallback, network: Network) { + if (callback !== networkCallback) return + + ssidsByNetwork.remove(network) + appContext?.let { recompute(it) } + } + + private fun recompute(context: Context) { + val trustedSsids = context.appLockRepository().getTrustedWifiSsids() + val ssids = ssidsByNetwork.values + val trustedSsid = ssids.firstOrNull { it in trustedSsids } + updateState( + context, + State( + currentSsid = trustedSsid ?: ssids.firstOrNull(), + trusted = networkCallback != null && trustedSsid != null + ) + ) + } + + private fun updateState(context: Context, newState: State) { + val wasTrusted = _state.value.trusted + _state.value = newState + if (newState.trusted == wasTrusted) return + + if (newState.trusted) { + LogUtils.d(TAG, "On a trusted network, locked apps open without authentication") + } else { + LogUtils.d(TAG, "Not on a trusted network, re-locking") + AppLockServiceStarter.relockAll(context, context.appLockRepository()) + } + } + + private fun readable(ssid: String?): String? = + ssid?.takeIf { it.isNotEmpty() && it != UNKNOWN_SSID } + + /** The connected Wi-Fi's name straight from [WifiManager], or null if none is readable. */ + @Suppress("DEPRECATION") // Still answers on Android 12+; the callback API has no synchronous read. + private fun readConnectionSsid(context: Context): String? { + val wifiManager = context.applicationContext.getSystemService(WifiManager::class.java) + ?: return null + return try { + readable(wifiManager.connectionInfo?.ssid) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to read the Wi-Fi name", e) + null + } + } + + private class WifiCallback : ConnectivityManager.NetworkCallback { + constructor() : super() + + @RequiresApi(Build.VERSION_CODES.S) + constructor(flags: Int) : super(flags) + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + val ssid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Carries the name only because the callback asked for location info. + (capabilities.transportInfo as? WifiInfo)?.ssid + } else { + TrustedNetworkMonitor.appContext?.let { TrustedNetworkMonitor.readConnectionSsid(it) } + } + TrustedNetworkMonitor.onNetworkSsid(this, network, ssid) + } + + override fun onLost(network: Network) { + TrustedNetworkMonitor.onNetworkLost(this, network) + } + } +} 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 50b47321..3a3082c2 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 @@ -1,6 +1,7 @@ package dev.pranav.applock.data.repository import android.content.Context +import dev.pranav.applock.core.network.TrustedNetworkMonitor import dev.pranav.applock.data.manager.BackendServiceManager import dev.pranav.applock.services.AppLockManager @@ -85,6 +86,13 @@ class AppLockRepository(private val context: Context) { fun setProtectEnabled(enabled: Boolean) = preferencesRepository.setProtectEnabled(enabled) fun isProtectEnabled(): Boolean = preferencesRepository.isProtectEnabled() + /** + * Whether locked apps should be locked right now: the shield is on, and no trusted Wi-Fi network + * is relaxing it. The lock backends check this; the shield and automation read [isProtectEnabled]. + */ + fun isProtectionActive(): Boolean = + isProtectEnabled() && !(isTrustedWifiEnabled() && TrustedNetworkMonitor.isOnTrustedNetwork()) + fun setAutomationEnabled(enabled: Boolean) = preferencesRepository.setAutomationEnabled(enabled) @@ -92,6 +100,14 @@ class AppLockRepository(private val context: Context) { fun getAutomationToken(): String? = preferencesRepository.getAutomationToken() fun regenerateAutomationToken(): String = preferencesRepository.regenerateAutomationToken() + fun setTrustedWifiEnabled(enabled: Boolean) = + preferencesRepository.setTrustedWifiEnabled(enabled) + + fun isTrustedWifiEnabled(): Boolean = preferencesRepository.isTrustedWifiEnabled() + fun getTrustedWifiSsids(): Set = preferencesRepository.getTrustedWifiSsids() + fun addTrustedWifiSsid(ssid: String) = preferencesRepository.addTrustedWifiSsid(ssid) + fun removeTrustedWifiSsid(ssid: String) = preferencesRepository.removeTrustedWifiSsid(ssid) + fun setUnlockTimeDuration(minutes: Int) = preferencesRepository.setUnlockTimeDuration(minutes) fun getUnlockTimeDuration(): Int = preferencesRepository.getUnlockTimeDuration() fun setAutoUnlockEnabled(enabled: Boolean) = preferencesRepository.setAutoUnlockEnabled(enabled) 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 a5da4846..2322cf32 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 @@ -220,6 +220,29 @@ class PreferencesRepository(context: Context) { return token } + fun setTrustedWifiEnabled(enabled: Boolean) { + settingsPrefs.edit(commit = true) { putBoolean(KEY_TRUSTED_WIFI_ENABLED, enabled) } + } + + fun isTrustedWifiEnabled(): Boolean { + return settingsPrefs.getBoolean(KEY_TRUSTED_WIFI_ENABLED, DEFAULT_TRUSTED_WIFI_ENABLED) + } + + /** Trusted Wi-Fi names, each exactly as Android reports it: quoted when valid UTF-8. */ + fun getTrustedWifiSsids(): Set { + return settingsPrefs.getStringSet(KEY_TRUSTED_WIFI_SSIDS, emptySet())?.toSet() ?: emptySet() + } + + fun addTrustedWifiSsid(ssid: String) { + val updated = getTrustedWifiSsids() + ssid + settingsPrefs.edit(commit = true) { putStringSet(KEY_TRUSTED_WIFI_SSIDS, updated) } + } + + fun removeTrustedWifiSsid(ssid: String) { + val updated = getTrustedWifiSsids() - ssid + settingsPrefs.edit(commit = true) { putStringSet(KEY_TRUSTED_WIFI_SSIDS, updated) } + } + fun setUnlockTimeDuration(minutes: Int) { settingsPrefs.edit { putInt(KEY_UNLOCK_TIME_DURATION, minutes) } } @@ -299,9 +322,12 @@ class PreferencesRepository(context: Context) { private const val KEY_AUTOMATION_ENABLED = "automation_enabled" private const val KEY_AUTOMATION_TOKEN = "automation_token" private const val KEY_PIN_LENGTH = "pin_length" + private const val KEY_TRUSTED_WIFI_ENABLED = "trusted_wifi_enabled" + private const val KEY_TRUSTED_WIFI_SSIDS = "trusted_wifi_ssids" private const val DEFAULT_PROTECT_ENABLED = true private const val DEFAULT_AUTOMATION_ENABLED = false + private const val DEFAULT_TRUSTED_WIFI_ENABLED = false private const val DEFAULT_UNLOCK_DURATION = 0 /** The shortest PIN, pattern or password the set screens accept. */ 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 4cd4467a..78ee647f 100644 --- a/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt +++ b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt @@ -141,7 +141,7 @@ class AppLockAccessibilityService : AccessibilityService() { checkForDeviceAdminDeactivation(event) } - if (!appLockRepository.isProtectEnabled()) { + if (!appLockRepository.isProtectionActive()) { return } diff --git a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt index 4aac8ed6..1367dd4c 100644 --- a/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt +++ b/app/src/main/java/dev/pranav/applock/services/UsageLockService.kt @@ -131,7 +131,7 @@ class UsageLockService: Service() { private fun safeMonitorForegroundApp() { try { - if (!appLockRepository.isProtectEnabled() || applicationContext.isDeviceLocked()) { + if (!appLockRepository.isProtectionActive() || applicationContext.isDeviceLocked()) { if (applicationContext.isDeviceLocked()) { AppLockManager.clearAllUnlockStates() previousForegroundPackage = "" diff --git a/app/src/main/java/dev/pranav/applock/shizuku/ShizukuActivityManager.kt b/app/src/main/java/dev/pranav/applock/shizuku/ShizukuActivityManager.kt index e89dfe57..3c4be833 100644 --- a/app/src/main/java/dev/pranav/applock/shizuku/ShizukuActivityManager.kt +++ b/app/src/main/java/dev/pranav/applock/shizuku/ShizukuActivityManager.kt @@ -118,7 +118,7 @@ class ShizukuActivityManager( } private fun checkForegroundApp() { - if (!appLockRepository.isProtectEnabled()) return + if (!appLockRepository.isProtectionActive()) return if (appLockRepository.getBackendImplementation() != BackendImplementation.SHIZUKU) { handler.removeCallbacks(checkForegroundRunnable) return From b6911d61d19e2b014f4caad2b5b706c071e1ae8f Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:38:26 -0300 Subject: [PATCH 23/31] feat: add trusted Wi-Fi settings A Trusted Wi-Fi section in Settings turns the feature on and manages the networks. Turning it on asks for precise location, then for "Allow all the time" (from Android 11 on its own settings page, after a short explanation), because Android only shares the Wi-Fi name with apps that have location access, and only in the background with that grant. The switch stays off until both are granted. A network can only be added while connected to it, never typed, so the stored name always matches what Android reports: quoted, or hex for names that aren't valid UTF-8. The dialog says plainly that networks are matched by name. The status line under the switch follows the monitor, and location access and the Location switch are re-read whenever the screen resumes. With Location off, the dialog offers a button to Android's Location settings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- .../features/settings/ui/SettingsScreen.kt | 306 ++++++++++++++++++ app/src/main/res/values/strings.xml | 30 ++ 2 files changed, 336 insertions(+) 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 f15d18ce..637fc739 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,5 +1,6 @@ package dev.pranav.applock.features.settings.ui +import android.Manifest import android.app.admin.DevicePolicyManager import android.content.ClipData import android.content.ClipDescription @@ -9,6 +10,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.PersistableBundle +import android.provider.Settings import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -42,11 +44,15 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.navigation.NavController import dev.pranav.applock.R import dev.pranav.applock.core.broadcast.AutomationReceiver import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen +import dev.pranav.applock.core.network.TrustedNetworkMonitor import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.canAuthenticateBiometrics import dev.pranav.applock.core.utils.hasUsagePermission @@ -102,6 +108,18 @@ fun SettingsScreen( var automationEnabled by remember { mutableStateOf(appLockRepository.isAutomationEnabled()) } var automationToken by remember { mutableStateOf(appLockRepository.getAutomationToken()) } var showAutomationDialog by remember { mutableStateOf(false) } + var trustedWifiEnabled by remember { mutableStateOf(appLockRepository.isTrustedWifiEnabled()) } + var trustedWifiSsids by remember { mutableStateOf(appLockRepository.getTrustedWifiSsids()) } + var showTrustedNetworksDialog by remember { mutableStateOf(false) } + var showBackgroundLocationDialog by remember { mutableStateOf(false) } + val trustedNetworkState by TrustedNetworkMonitor.state.collectAsState() + var hasLocationAccess by remember { + mutableStateOf( + TrustedNetworkMonitor.hasLocationPermission(context) && + TrustedNetworkMonitor.hasBackgroundLocationPermission(context) + ) + } + var locationEnabled by remember { mutableStateOf(TrustedNetworkMonitor.isLocationEnabled(context)) } var showPermissionDialog by remember { mutableStateOf(false) } var showDeviceAdminDialog by remember { mutableStateOf(false) } @@ -122,6 +140,66 @@ fun SettingsScreen( onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } } + // Location access and the Location switch change in Android's settings, away from this screen. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + hasLocationAccess = TrustedNetworkMonitor.hasLocationPermission(context) && + TrustedNetworkMonitor.hasBackgroundLocationPermission(context) + locationEnabled = TrustedNetworkMonitor.isLocationEnabled(context) + if (trustedWifiEnabled) TrustedNetworkMonitor.refresh(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + fun enableTrustedWifi() { + appLockRepository.setTrustedWifiEnabled(true) + trustedWifiEnabled = true + hasLocationAccess = true + TrustedNetworkMonitor.refresh(context) + if (trustedWifiSsids.isEmpty()) showTrustedNetworksDialog = true + } + + val backgroundLocationLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + enableTrustedWifi() + } else { + Toast.makeText( + context, + context.getString(R.string.settings_screen_trusted_wifi_background_denied), + Toast.LENGTH_LONG + ).show() + } + } + + val locationPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + when { + !TrustedNetworkMonitor.hasLocationPermission(context) -> { + Toast.makeText( + context, + context.getString(R.string.settings_screen_trusted_wifi_location_denied), + Toast.LENGTH_LONG + ).show() + } + + TrustedNetworkMonitor.hasBackgroundLocationPermission(context) -> enableTrustedWifi() + + // From Android 11, "Allow all the time" is only offered on its own settings page. + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { + showBackgroundLocationDialog = true + } + + else -> backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + } + if (showDialog) { AlertDialog( onDismissRequest = { showDialog = false }, @@ -186,6 +264,52 @@ fun SettingsScreen( ) } + if (showBackgroundLocationDialog) { + AlertDialog( + onDismissRequest = { showBackgroundLocationDialog = false }, + title = { Text(stringResource(R.string.settings_screen_trusted_wifi_background_dialog_title)) }, + text = { Text(stringResource(R.string.settings_screen_trusted_wifi_background_dialog_text)) }, + confirmButton = { + FilledTonalButton( + onClick = { + showBackgroundLocationDialog = false + backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + ) { + Text(stringResource(R.string.settings_screen_trusted_wifi_background_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showBackgroundLocationDialog = false }) { + Text(stringResource(R.string.cancel_button)) + } + }, + containerColor = MaterialTheme.colorScheme.surface + ) + } + + if (showTrustedNetworksDialog) { + TrustedNetworksDialog( + trustedSsids = trustedWifiSsids, + currentSsid = trustedNetworkState.currentSsid, + locationEnabled = locationEnabled, + onAdd = { ssid -> + appLockRepository.addTrustedWifiSsid(ssid) + trustedWifiSsids = appLockRepository.getTrustedWifiSsids() + TrustedNetworkMonitor.refresh(context) + }, + onRemove = { ssid -> + appLockRepository.removeTrustedWifiSsid(ssid) + trustedWifiSsids = appLockRepository.getTrustedWifiSsids() + TrustedNetworkMonitor.refresh(context) + }, + onOpenLocationSettings = { + context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + }, + onDismiss = { showTrustedNetworksDialog = false } + ) + } + if (showPermissionDialog) { PermissionRequiredDialog( onDismiss = { showPermissionDialog = false }, @@ -410,6 +534,86 @@ fun SettingsScreen( ) } + item { + SectionTitle(text = stringResource(R.string.settings_screen_trusted_wifi_title)) + } + + item { + SettingsGroup( + items = listOf( + ToggleSettingItem( + icon = Icons.Default.Wifi, + title = stringResource(R.string.settings_screen_trusted_wifi_control_title), + subtitle = stringResource( + when { + !trustedWifiEnabled -> R.string.settings_screen_trusted_wifi_desc_off + !hasLocationAccess -> R.string.settings_screen_trusted_wifi_desc_needs_permission + !locationEnabled -> R.string.settings_screen_trusted_wifi_desc_location_off + trustedWifiSsids.isEmpty() -> R.string.settings_screen_trusted_wifi_desc_no_networks + trustedNetworkState.trusted -> R.string.settings_screen_trusted_wifi_desc_trusted + else -> R.string.settings_screen_trusted_wifi_desc_not_trusted + } + ), + checked = trustedWifiEnabled, + enabled = true, + onCheckedChange = { isChecked -> + when { + !isChecked -> { + appLockRepository.setTrustedWifiEnabled(false) + trustedWifiEnabled = false + TrustedNetworkMonitor.refresh(context) + } + + !TrustedNetworkMonitor.hasLocationPermission(context) -> { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + } + + !TrustedNetworkMonitor.hasBackgroundLocationPermission(context) -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + showBackgroundLocationDialog = true + } else { + backgroundLocationLauncher.launch( + Manifest.permission.ACCESS_BACKGROUND_LOCATION + ) + } + } + + else -> enableTrustedWifi() + } + } + ), + ActionSettingItem( + icon = Icons.Default.Router, + title = stringResource(R.string.settings_screen_trusted_networks_title), + subtitle = if (trustedWifiEnabled) + context.resources.getQuantityString( + R.plurals.settings_screen_trusted_networks_count, + trustedWifiSsids.size, + trustedWifiSsids.size + ) + else + stringResource(R.string.settings_screen_trusted_networks_desc_disabled), + onClick = { + if (!trustedWifiEnabled) { + Toast.makeText( + context, + context.getString(R.string.settings_screen_trusted_networks_desc_disabled), + Toast.LENGTH_SHORT + ).show() + } else { + showTrustedNetworksDialog = true + } + } + ) + ) + ) + } + item { SectionTitle(text = stringResource(R.string.settings_screen_automation_title)) } @@ -1153,6 +1357,108 @@ fun AutomationTokenDialog( ) } +@Composable +fun TrustedNetworksDialog( + trustedSsids: Set, + currentSsid: String?, + locationEnabled: Boolean, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit, + onOpenLocationSettings: () -> Unit, + onDismiss: () -> Unit +) { + val addableSsid = currentSsid?.takeIf { it !in trustedSsids } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings_screen_trusted_networks_dialog_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.settings_screen_trusted_networks_dialog_intro), + style = MaterialTheme.typography.bodyMedium + ) + + if (trustedSsids.isEmpty()) { + Text( + text = stringResource(R.string.settings_screen_trusted_networks_dialog_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + trustedSsids.sortedBy { TrustedNetworkMonitor.displayName(it).lowercase() } + .forEach { ssid -> + val name = TrustedNetworkMonitor.displayName(ssid) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 12.dp) + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { onRemove(ssid) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource( + R.string.settings_screen_trusted_networks_remove_cd, + name + ) + ) + } + } + } + } + + val hint = when { + !locationEnabled -> R.string.settings_screen_trusted_networks_dialog_location_off + currentSsid == null -> R.string.settings_screen_trusted_networks_dialog_not_connected + else -> null + } + if (hint != null) { + Text( + text = stringResource(hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + if (!locationEnabled) { + FilledTonalButton(onClick = onOpenLocationSettings) { + Text(stringResource(R.string.settings_screen_trusted_networks_turn_on_location)) + } + } else if (addableSsid != null) { + FilledTonalButton(onClick = { onAdd(addableSsid) }) { + Text( + stringResource( + R.string.settings_screen_trusted_networks_add, + TrustedNetworkMonitor.displayName(addableSsid) + ) + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_screen_trusted_networks_close)) + } + }, + containerColor = MaterialTheme.colorScheme.surface + ) +} + @Composable private fun MonospaceBlock(text: String) { Surface( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 567f8a1c..3a7c5656 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,36 @@ Make sure you have Shizuku installed and enabled via ADB. Tap \'Next\' to grant Thank you for believing in my work! + + Trusted Wi-Fi + Open locked apps on trusted Wi-Fi + Skip authentication while connected to a Wi-Fi network you trust. Apps stay locked everywhere else. + Needs location set to \"Allow all the time\". Until then, apps stay locked. + Location is off, so no network can be recognised and apps stay locked + No trusted networks yet + On a trusted network: locked apps open without authentication + Not on a trusted network: apps stay locked + Trusted Wi-Fi needs precise location to read the network name + To use trusted Wi-Fi, set location for this app to \"Allow all the time\" in Android settings + Allow location all the time + Android only shares the name of the connected Wi-Fi network with apps that have location access, and only in the background with \"Allow all the time\". Choose it on the next screen.\n\nOnly the network name is read, never your position. Without this access, apps stay locked everywhere. + Continue + Trusted networks + Turn on trusted Wi-Fi to add networks + + %d network + %d networks + + Trusted networks + Locked apps open without authentication while connected to one of these. Networks are matched by name, so a hotspot given the same name would count too. + No trusted networks yet. + Connect to the Wi-Fi network you want to trust, then add it here. + Turn on Location so the network name can be read. + Add %1$s + Remove %1$s + Turn on Location + Done + Automation Allow automation apps From 3adb41d5014a20664b7ef2bd1e06aace2aa3d359 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:38:26 -0300 Subject: [PATCH 24/31] feat: show HOME on the shield while trusted Wi-Fi keeps apps open Otherwise the shield says ON at home while locked apps open freely. Tapping it still toggles the same flag, so at home it cycles HOME, OFF, HOME. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- .../applock/features/applist/ui/MainScreen.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) 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 369572ac..6d2faa34 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 @@ -48,6 +48,7 @@ import dev.pranav.applock.R import dev.pranav.applock.core.broadcast.AutomationReceiver import dev.pranav.applock.core.broadcast.DeviceAdmin import dev.pranav.applock.core.navigation.Screen +import dev.pranav.applock.core.network.TrustedNetworkMonitor import dev.pranav.applock.core.utils.appLockRepository import dev.pranav.applock.core.utils.hasUsagePermission import dev.pranav.applock.core.utils.isAccessibilityServiceEnabled @@ -87,6 +88,10 @@ fun MainScreen( applockEnabled = appLockRepository.isProtectEnabled() } + // On a trusted Wi-Fi network the shield is still on, but locked apps open freely: say so. + val trustedNetworkState by TrustedNetworkMonitor.state.collectAsState() + val relaxedByTrustedWifi = applockEnabled && trustedNetworkState.trusted + DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { @@ -170,9 +175,18 @@ fun MainScreen( AutomationReceiver.notifyStateChanged(context, applockEnabled) }, shape = RoundedCornerShape(16.dp), - color = if (applockEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + color = when { + relaxedByTrustedWifi -> MaterialTheme.colorScheme.tertiaryContainer + applockEnabled -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + }, modifier = Modifier.padding(end = 8.dp) ) { + val shieldContentColor = when { + relaxedByTrustedWifi -> MaterialTheme.colorScheme.onTertiaryContainer + applockEnabled -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) @@ -181,13 +195,17 @@ fun MainScreen( imageVector = if (applockEnabled) Icons.Default.Shield else Icons.Outlined.Shield, contentDescription = stringResource(R.string.main_screen_app_protection_cd), modifier = Modifier.size(18.dp), - tint = if (applockEnabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + tint = shieldContentColor ) Spacer(Modifier.width(6.dp)) Text( - text = if (applockEnabled) "ON" else "OFF", + text = when { + relaxedByTrustedWifi -> "HOME" + applockEnabled -> "ON" + else -> "OFF" + }, style = MaterialTheme.typography.labelLarge, - color = if (applockEnabled) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + color = shieldContentColor ) } } From a2819009182680a073b52b10e4074f0ac7342cb5 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:38:26 -0300 Subject: [PATCH 25/31] docs: describe built-in trusted Wi-Fi Covers setup, why location access is needed, the fail-closed rules including the screen-on check, how it combines with the automation intents, and the limits of matching networks by name. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- AUTOMATION.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/AUTOMATION.md b/AUTOMATION.md index 0a6f576c..879ea76f 100644 --- a/AUTOMATION.md +++ b/AUTOMATION.md @@ -4,8 +4,10 @@ This fork exposes AppLock's global protection flag to automation apps, so that p follow a condition you choose — a trusted Wi-Fi network, a time of day, a location — instead of being switched by hand. -It adds no conditions of its own. AppLock only accepts "protection on" and "protection off"; -MacroDroid, Tasker or `adb` decides when to send them. +The intents add no conditions of their own. AppLock only accepts "protection on" and "protection +off"; MacroDroid, Tasker or `adb` decides when to send them. For the most common condition, a +trusted Wi-Fi network, there is also a [built-in option](#built-in-trusted-wi-fi) that needs no +automation app. ## Setup @@ -100,3 +102,48 @@ Anything that can switch protection off is worth being careful about. Three thin Anyone who can already run `adb` against an unlocked device could read the token out of the app's private storage — the token protects against other *apps* on the device, not against someone holding an unlocked phone with USB debugging on. + +## Built-in trusted Wi-Fi + +For the most common condition no automation app is needed. **Settings → Trusted Wi-Fi** lets locked +apps open without authentication while the phone is connected to a network you trust, and keeps +them locked everywhere else. + +### Setup + +1. Turn on **Open locked apps on trusted Wi-Fi**. Grant precise location, then choose **Allow all + the time**, and keep Location switched on. +2. Connect to the network, open **Trusted networks**, and tap **Add**. + +Android only shares the name of the connected Wi-Fi network with apps that have location access, +and only in the background if that access is "Allow all the time". The app reads the network name +and nothing else; it never asks for your position. + +The shield on the main screen shows **HOME** while a trusted network is keeping apps open. + +### Fails closed + +Anything the app can't confirm counts as untrusted, so apps stay locked: + +- Trust is kept in memory, never stored. After a reboot or restart, apps stay locked until Android + reports a trusted network. +- Wi-Fi off, no connection, an untrusted network, Location off, or location access missing or set + to "While using the app": locked. +- Leaving a trusted network re-locks everything, the same as turning the shield back on. +- Each time the screen turns on or the phone unlocks, the app reads the network name again. If it + can't, or the network isn't trusted, apps lock at once. This check never unlocks anything. + +Anti-uninstall is not affected by any of this. + +### With the intents + +Both can be used together. The intents turn the shield on and off. Trusted Wi-Fi never changes the +shield; it only keeps apps open while the shield is on. With the shield off, apps open everywhere, +trusted network or not. `QUERY_PROTECTION_STATE` and `PROTECTION_STATE` report the shield only. + +### Limits + +- Networks are matched by name. Someone holding your unlocked phone could name a hotspot the same, + connect the phone to it, and open locked apps. +- As you walk away, Wi-Fi stays connected until the signal drops, and locked apps keep opening + until then. From 3701413305b58340318f75d8e56b98be6ddba8f1 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:17:23 -0300 Subject: [PATCH 26/31] docs: list future improvement ideas Twelve ideas that aren't built yet, with what to check before building each, plus three upstream bypass reports to check against this fork. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E9neD85JvxxMdGWrHQP8n9 --- FUTURE_IMPROVEMENTS.md | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 FUTURE_IMPROVEMENTS.md diff --git a/FUTURE_IMPROVEMENTS.md b/FUTURE_IMPROVEMENTS.md new file mode 100644 index 00000000..dbb8cfcf --- /dev/null +++ b/FUTURE_IMPROVEMENTS.md @@ -0,0 +1,159 @@ +# Future improvements + +Ideas for this fork that are not built yet, collected on 2026-09-11. None of them is planned; each +entry says what it would do, why, and what to check before building it. Effort is a rough guess. +When one ships, set its Status to the PR that built it. + +Anything a stranger could see — a Quick Settings tile, a notification, a dialog — has to keep the +"System Services" disguise described in [ANTI_UNINSTALL.md](ANTI_UNINSTALL.md). + +| # | Idea | Effort | Status | +| --- | --- | --- | --- | +| 1 | [Wrong tries lock the whole phone](#1-wrong-tries-lock-the-whole-phone) | Small | Not started | +| 2 | [Tripwires: airplane mode or SIM removal](#2-tripwires-airplane-mode-or-sim-removal) | Small to medium | Not started | +| 3 | [Shuffled PIN pad](#3-shuffled-pin-pad) | Small | Not started | +| 4 | [A believable front for "System Services"](#4-a-believable-front-for-system-services) | Medium | Not started | +| 5 | [Fake crash on locked apps](#5-fake-crash-on-locked-apps) | Medium | Not started | +| 6 | [Duress PIN](#6-duress-pin) | Medium to large | Not started | +| 7 | [Hide notification content for locked apps](#7-hide-notification-content-for-locked-apps) | Medium to large | Not started | +| 8 | [Intruder photo](#8-intruder-photo) | Medium to large | Not started | +| 9 | [Wrong-try report and log viewer](#9-wrong-try-report-and-log-viewer) | Small to medium | Not started | +| 10 | [Timeout per app](#10-timeout-per-app) | Medium | Not started | +| 11 | [Lock new apps automatically](#11-lock-new-apps-automatically) | Small to medium | Not started | +| 12 | [Re-lock everything from Quick Settings](#12-re-lock-everything-from-quick-settings) | Small | Not started | + +## Against someone holding the unlocked phone + +The disguise, anti-uninstall and the wrong-try limit are aimed at someone who takes the phone while +it is unlocked. These ideas go further in that direction. + +### 1. Wrong tries lock the whole phone + +After a number of wrong PINs, patterns or passwords on any locked app, lock the phone and re-lock +every app. Someone guessing at one app lands on the phone's own lock screen instead of moving on to +the next app. + +- **Builds on:** `UnlockAttemptLimiter` already counts wrong tries, `AppLockAccessibilityService` + already locks the phone with `GLOBAL_ACTION_LOCK_SCREEN`, and + `AppLockManager.clearAllUnlockStates` re-locks every app. +- **Check first:** locking the phone that way needs the accessibility service switched on, which + only the Accessibility backend and anti-uninstall require. Device admin could lock the phone too, + but it declares no policies today, so that would mean adding `force-lock` and probably granting + device admin again. + +### 2. Tripwires: airplane mode or SIM removal + +While the phone is unlocked, airplane mode switching on or the SIM being removed locks the phone and +re-locks every app. Both are a thief's first moves to stop the phone being tracked. + +- **Builds on:** the lock action from item 1. A running service can listen for airplane mode + changes. +- **Check first:** SIM removal is harder to detect reliably; test it on One UI. One UI's own Theft + protection (Offline device lock) may already cover part of this, so compare first; this version + would act at once. Turning airplane mode on for a flight would lock the phone too. + +### 3. Shuffled PIN pad + +Put the digits in a new order every time the lock screen shows, so someone watching you type in +public doesn't learn the PIN. It should be optional, since it slows entry. Hiding the pattern trail +([upstream #175](https://github.com/aload0/AppLock/issues/175)) does the same for patterns. + +- **Builds on:** the keypad in `PasswordOverlayScreen.kt` and the pattern grid in + `PatternLockScreen.kt`. + +### 4. A believable front for "System Services" + +Opening the disguised app from the launcher goes straight to the PIN pad (the start route in +`NavigationManager`), which gives the disguise away. Show a dull, system-style screen instead, with +a secret gesture that opens the PIN. + +- **Check first:** hiding the launcher icon outright looks simpler but is riskier. Android may show + a stand-in icon that opens App info, where the anti-uninstall guard would bounce whoever tapped + it. + +### 5. Fake crash on locked apps + +A locked app shows an "App keeps stopping" style message instead of the PIN pad. A thief assumes the +app is broken and doesn't learn that it is protected. A long-press or secret tap opens the real +lock. + +- **Builds on:** `LockScreenOverlayManager`, which shows the lock screen over locked apps. +- **Check first:** you have to remember the gesture. The fingerprint prompt reveals that a lock + exists, so an automatic fingerprint prompt would have to wait for the gesture. + +### 6. Duress PIN + +A second PIN that seems to work but opens a fake "Couldn't connect" screen, while every locked app +stays locked. It is meant for being forced to unlock an app. It should do nothing visible, such as +locking the phone, that could provoke the person forcing it. + +- **Builds on:** `CredentialHasher` for storing the second PIN. +- **Check first:** how it interacts with the wrong-try limit, and that it can never be set to the + real PIN. + +## Privacy from people nearby + +### 7. Hide notification content for locked apps + +[Upstream #230](https://github.com/aload0/AppLock/issues/230). A locked messaging app still shows the +sender and message text in the notification shade, including SMS codes someone could use to take +over accounts. + +- **Check first:** it needs notification access. Android can't hide another app's notification and + restore it later, so the realistic version removes the original and posts a generic "New + notification" that opens the app through the lock. Quick reply is lost, and the generic + notification has to follow the disguise. + +### 8. Intruder photo + +[Upstream #229](https://github.com/aload0/AppLock/issues/229). Take a front-camera photo after a +number of wrong tries and show it with the security log, behind the PIN. It helps against people +nearby but little against a thief, since the photo stays on the stolen phone. + +- **Check first:** it needs the camera permission, and Android limits camera use by apps in the + background, so check that the lock screen is allowed to use it. The green camera indicator shows + while the photo is taken. + +### 9. Wrong-try report and log viewer + +After a correct unlock, show what happened since the last one, for example "3 wrong tries on [app] +at 14:02". A log viewer behind the PIN would save exporting `audit_log.txt` to read it. + +- **Builds on:** `UnlockAttemptLimiter`, which stores only a count today; the app and time of each + try would need recording. +- **Effort:** small for the report, medium for the viewer. + +## Convenience + +### 10. Timeout per app + +Banking apps lock immediately while chat apps stay open for a few minutes. + +- **Builds on:** `AppLockManager.shouldShowLockScreen` already tracks the unlock time per app; only + the duration is a single setting for all apps. + +### 11. Lock new apps automatically + +When an app is installed, lock it by default, or ask behind the PIN, so a new bank or wallet app +isn't left open. + +- **Check first:** a notification asking about it would be visible outside the PIN, so it has to + follow the disguise, or the question waits until the app is next opened. + +### 12. Re-lock everything from Quick Settings + +A Quick Settings tile that re-locks every app at once, for handing the phone to someone. + +- **Builds on:** `AppLockManager.clearAllUnlockStates`. +- **Check first:** the tile's label and icon show outside the PIN and have to follow the disguise. + +## To check before new features + +Open upstream reports of ways around the lock, two of them on Samsung. None has been checked against +this fork yet: + +- [#228](https://github.com/aload0/AppLock/issues/228): cancelling the biometric prompt with "Use + PIN" can leave the lock screen state stuck. +- [#234](https://github.com/aload0/AppLock/issues/234): on One UI, the lock overlay crashes and fails + to re-lock after the system biometric prompt. +- [#240](https://github.com/aload0/AppLock/issues/240): a One UI popup gets around the lock. From 3bf2c9443f509ba18a9a90a7226952fe53693cbf Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:27:36 -0300 Subject: [PATCH 27/31] docs: add intruder video, alerts, log sending and remote lock ideas Extend the intruder photo item with a chosen wrong-try count and video, and the wrong-try report with an optional log of successful unlocks. Add three items: sending the log off the phone, locking remotely by a keyword message, and a spoken warning or alarm after wrong tries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E9neD85JvxxMdGWrHQP8n9 --- FUTURE_IMPROVEMENTS.md | 92 +++++++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/FUTURE_IMPROVEMENTS.md b/FUTURE_IMPROVEMENTS.md index dbb8cfcf..184bed9e 100644 --- a/FUTURE_IMPROVEMENTS.md +++ b/FUTURE_IMPROVEMENTS.md @@ -16,11 +16,14 @@ Anything a stranger could see — a Quick Settings tile, a notification, a dialo | 5 | [Fake crash on locked apps](#5-fake-crash-on-locked-apps) | Medium | Not started | | 6 | [Duress PIN](#6-duress-pin) | Medium to large | Not started | | 7 | [Hide notification content for locked apps](#7-hide-notification-content-for-locked-apps) | Medium to large | Not started | -| 8 | [Intruder photo](#8-intruder-photo) | Medium to large | Not started | -| 9 | [Wrong-try report and log viewer](#9-wrong-try-report-and-log-viewer) | Small to medium | Not started | +| 8 | [Intruder photo or video](#8-intruder-photo-or-video) | Medium to large | Not started | +| 9 | [Unlock report and log viewer](#9-unlock-report-and-log-viewer) | Small to medium | Not started | | 10 | [Timeout per app](#10-timeout-per-app) | Medium | Not started | | 11 | [Lock new apps automatically](#11-lock-new-apps-automatically) | Small to medium | Not started | | 12 | [Re-lock everything from Quick Settings](#12-re-lock-everything-from-quick-settings) | Small | Not started | +| 13 | [Send the log off the phone](#13-send-the-log-off-the-phone) | Medium | Not started | +| 14 | [Lock remotely by message](#14-lock-remotely-by-message) | Medium | Not started | +| 15 | [Spoken warning or alarm](#15-spoken-warning-or-alarm) | Small | Not started | ## Against someone holding the unlocked phone @@ -29,9 +32,9 @@ it is unlocked. These ideas go further in that direction. ### 1. Wrong tries lock the whole phone -After a number of wrong PINs, patterns or passwords on any locked app, lock the phone and re-lock -every app. Someone guessing at one app lands on the phone's own lock screen instead of moving on to -the next app. +After a chosen number of wrong PINs, patterns or passwords on any locked app, lock the phone and +re-lock every app. Someone guessing at one app lands on the phone's own lock screen instead of +moving on to the next app. - **Builds on:** `UnlockAttemptLimiter` already counts wrong tries, `AppLockAccessibilityService` already locks the phone with `GLOBAL_ACTION_LOCK_SCREEN`, and @@ -91,38 +94,81 @@ locking the phone, that could provoke the person forcing it. - **Check first:** how it interacts with the wrong-try limit, and that it can never be set to the real PIN. +### 13. Send the log off the phone + +After wrong tries, send the log entry, and the photo or video from item 8, somewhere off the phone. +Anything kept only on the phone leaves with the thief, so this is what makes item 8 useful against +theft. + +- **Check first:** the app has no internet permission today. Sending by email or a messaging bot + would add one, which ends the README's promise that all data stays on the device. An SMS to a + trusted number needs the SMS permission but no internet; it carries text only, and stops once the + SIM is removed. Handing the event to an automation app that does the sending keeps this app + offline, but the event must go to that one app, not to every app on the phone (see F6 in + [SECURITY_AUDIT.md](SECURITY_AUDIT.md)). + +### 14. Lock remotely by message + +Send a secret keyword from another phone, by SMS or a messaging app, to switch protection on, +re-lock every app and optionally lock the phone. Useful when the phone is lost, or lent to someone. + +- **Builds on:** the lock action from item 1, and the protection switch behind the automation + intents in [AUTOMATION.md](AUTOMATION.md). An automation app that can react to incoming messages + can already send `ENABLE_PROTECTION` today; this would build it in. +- **Check first:** reading SMS needs the SMS permission, and reading other messaging apps needs + notification access, shared with item 7. The keyword shows in the notification and stays in the + chat, where anyone holding the phone can read it, so pick one that looks like an ordinary message. + That is harmless for locking but rules out a fixed unlock keyword, which anyone who saw it could + reuse; a remote unlock would need a code that works only once. A phone in airplane mode receives + nothing, which item 2 covers. + ## Privacy from people nearby ### 7. Hide notification content for locked apps -[Upstream #230](https://github.com/aload0/AppLock/issues/230). A locked messaging app still shows the -sender and message text in the notification shade, including SMS codes someone could use to take -over accounts. +[Upstream #230](https://github.com/aload0/AppLock/issues/230). A locked messaging app still shows +the sender and message text in the notification shade, including SMS codes someone could use to +take over accounts. - **Check first:** it needs notification access. Android can't hide another app's notification and restore it later, so the realistic version removes the original and posts a generic "New notification" that opens the app through the lock. Quick reply is lost, and the generic notification has to follow the disguise. -### 8. Intruder photo +### 8. Intruder photo or video -[Upstream #229](https://github.com/aload0/AppLock/issues/229). Take a front-camera photo after a -number of wrong tries and show it with the security log, behind the PIN. It helps against people -nearby but little against a thief, since the photo stays on the stolen phone. +[Upstream #229](https://github.com/aload0/AppLock/issues/229). After a chosen number of wrong +tries, for example 3, take a front-camera photo or record a short video, and show it with the +security log, behind the PIN. It helps against people nearby; against a thief it only helps if +item 13 sends it off the phone. -- **Check first:** it needs the camera permission, and Android limits camera use by apps in the - background, so check that the lock screen is allowed to use it. The green camera indicator shows - while the photo is taken. +- **Check first:** it needs the camera permission, and the microphone for video with sound. Android + limits camera use by apps in the background, so check that the lock screen is allowed to use it. + The green camera indicator shows while it records, and some phones play a sound when a video + starts, so the person may notice. -### 9. Wrong-try report and log viewer +### 9. Unlock report and log viewer After a correct unlock, show what happened since the last one, for example "3 wrong tries on [app] -at 14:02". A log viewer behind the PIN would save exporting `audit_log.txt` to read it. +at 14:02". Optionally record successful unlocks too: which app, when, and whether by PIN or +fingerprint. A log viewer behind the PIN would save exporting `audit_log.txt` to read it. -- **Builds on:** `UnlockAttemptLimiter`, which stores only a count today; the app and time of each - try would need recording. +- **Builds on:** `UnlockAttemptLimiter`, which stores only a count today. `audit_log.txt` is a debug + log, written only while **Settings → Advanced → Logging** is on, so neither keeps a record of each + try. - **Effort:** small for the report, medium for the viewer. +### 15. Spoken warning or alarm + +After the chosen number of wrong tries, speak a message you write, such as "Don't touch my phone", +or sound an alarm, to scare off someone nearby who is guessing. + +- **Builds on:** Android's text-to-speech, which needs no permission, and the same wrong-try count + as items 1 and 8. +- **Check first:** it gives the disguise away and works against items 5 and 6, which rely on showing + nothing. A thief who hears it may just switch the phone off, and during a robbery it could + provoke. Playing at alarm volume keeps silent mode from muting it. + ## Convenience ### 10. Timeout per app @@ -149,11 +195,11 @@ A Quick Settings tile that re-locks every app at once, for handing the phone to ## To check before new features -Open upstream reports of ways around the lock, two of them on Samsung. None has been checked against -this fork yet: +Open upstream reports of ways around the lock, two of them on Samsung. None has been checked +against this fork yet: - [#228](https://github.com/aload0/AppLock/issues/228): cancelling the biometric prompt with "Use PIN" can leave the lock screen state stuck. -- [#234](https://github.com/aload0/AppLock/issues/234): on One UI, the lock overlay crashes and fails - to re-lock after the system biometric prompt. +- [#234](https://github.com/aload0/AppLock/issues/234): on One UI, the lock overlay crashes and + fails to re-lock after the system biometric prompt. - [#240](https://github.com/aload0/AppLock/issues/240): a One UI popup gets around the lock. From 6f2ed32bcdb80d077eebeab9c6c9e1ed021940df Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:38:37 -0300 Subject: [PATCH 28/31] docs: add screen timeout and lock-screen notifications by network Two phone settings that could follow the trusted Wi-Fi networks, relaxed on a trusted network and strict everywhere else, with the design they share: their own switches, fail-closed writes because Android stores the values, and no timer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E9neD85JvxxMdGWrHQP8n9 --- FUTURE_IMPROVEMENTS.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/FUTURE_IMPROVEMENTS.md b/FUTURE_IMPROVEMENTS.md index 184bed9e..243d244e 100644 --- a/FUTURE_IMPROVEMENTS.md +++ b/FUTURE_IMPROVEMENTS.md @@ -24,6 +24,8 @@ Anything a stranger could see — a Quick Settings tile, a notification, a dialo | 13 | [Send the log off the phone](#13-send-the-log-off-the-phone) | Medium | Not started | | 14 | [Lock remotely by message](#14-lock-remotely-by-message) | Medium | Not started | | 15 | [Spoken warning or alarm](#15-spoken-warning-or-alarm) | Small | Not started | +| 16 | [Screen timeout by network](#16-screen-timeout-by-network) | Small to medium | Not started | +| 17 | [Lock-screen notification content by network](#17-lock-screen-notification-content-by-network) | Small to medium | Not started | ## Against someone holding the unlocked phone @@ -193,6 +195,46 @@ A Quick Settings tile that re-locks every app at once, for handing the phone to - **Builds on:** `AppLockManager.clearAllUnlockStates`. - **Check first:** the tile's label and icon show outside the PIN and have to follow the disguise. +## Following trusted Wi-Fi + +Two phone settings that could switch with the trusted networks from +[AUTOMATION.md](AUTOMATION.md#built-in-trusted-wi-fi): relaxed on a trusted network, strict +everywhere else. Both share this design: + +- **Own switches, shared networks.** Each setting gets its own switch and uses the same trusted + network list, so neither requires letting locked apps open on trusted Wi-Fi. + `TrustedNetworkMonitor` only runs while "Open locked apps on trusted Wi-Fi" is on today, so it + would have to run while any of the three is on. +- **Fail closed, although Android stores the value.** Trust lives in memory, but these settings are + saved by Android and outlive the app. Write the strict value whenever the app starts, whenever + trust drops and when the switch is turned off, and the relaxed value only while trust holds. + `TrustedNetworkMonitor.updateState` acts only on a change and trust starts out false, so the write + at start has to be added. +- **No timer.** Writes follow the trust changes the app already tracks. If Android misses the phone + leaving a network, the screen-on check drops trust, so a wrong value lasts only a moment after + the screen turns on. + +### 16. Screen timeout by network + +A longer screen timeout on a trusted network and a shorter one everywhere else, which shrinks the +time a snatched phone stays unlocked away from home. Both durations are settings. + +- **Builds on:** `Settings.System.SCREEN_OFF_TIMEOUT`. It needs "Modify system settings", granted + from a switch in the phone's Settings. +- **Check first:** a timeout changed by hand is overwritten at the next trust change. + +### 17. Lock-screen notification content by network + +Show notification content on the lock screen on a trusted network and hide it everywhere else. +Unlike item 7, this covers the lock screen only, for every app. + +- **Builds on:** the secure setting `lock_screen_allow_private_notifications` (1 shows content, 0 + hides it). It needs `WRITE_SECURE_SETTINGS`, which only ADB or Shizuku can grant: + `adb shell pm grant dev.pranav.applock android.permission.WRITE_SECURE_SETTINGS`, or through + Shizuku, which the app already uses. The grant survives app updates but not an uninstall. +- **Check first:** that Android accepts this write from an app targeting SDK 37. Without the + permission the switch should stay off. + ## To check before new features Open upstream reports of ways around the lock, two of them on Samsung. None has been checked From 28d8a8937dcb3ac4a02601079031aa74f39c59e7 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:21:59 -0300 Subject: [PATCH 29/31] feat: log what trusted Wi-Fi sees, without network names Trust could stay on after leaving a trusted network with the screen on, and the log only recorded trust changes, so it couldn't show why. The monitor now also logs these, each with the screen, lock, app and location state: - each Wi-Fi report when it changes: trusted, other or no readable name, whether Android verified the internet, and signal bars; - each lost Wi-Fi network; - which network carries the phone's internet, when that changes; - every screen-on and unlock check, including those that keep trust or have nothing to check; - each time it asks Android for the current Wi-Fi networks. Network names are never logged. The lines only reach logcat and the security log while logging is on. Locking behaviour is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- .../core/network/TrustedNetworkMonitor.kt | 249 ++++++++++++++++-- 1 file changed, 227 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt index 64089eaf..263a8103 100644 --- a/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt +++ b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt @@ -2,6 +2,7 @@ package dev.pranav.applock.core.network import android.Manifest import android.annotation.SuppressLint +import android.app.ActivityManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -15,12 +16,14 @@ import android.net.NetworkRequest import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build +import android.os.PowerManager import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.location.LocationManagerCompat import dev.pranav.applock.core.broadcast.AppLockServiceStarter import dev.pranav.applock.core.utils.LogUtils import dev.pranav.applock.core.utils.appLockRepository +import dev.pranav.applock.services.isDeviceLocked import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -39,6 +42,10 @@ import kotlinx.coroutines.flow.asStateFlow * network updates grant it. * * Networks are matched by name alone, so mesh and dual-band networks need no extra setup. + * + * What Android reports is logged as it changes, never with network names, alongside the screen, app + * and location state that decide what Android shares. The exported log then shows why trust was + * kept or dropped. */ @SuppressLint("StaticFieldLeak") // Holds the application context only. object TrustedNetworkMonitor { @@ -47,6 +54,9 @@ object TrustedNetworkMonitor { /** What Android reports in place of a Wi-Fi name it can't read or won't share. */ private const val UNKNOWN_SSID = "" + /** What [WifiInfo.getRssi] reports when there is no signal reading. */ + private const val INVALID_RSSI = -127 + data class State( /** The connected Wi-Fi's name as Android reports it (quoted), or null if none is readable. */ val currentSsid: String? = null, @@ -59,9 +69,16 @@ object TrustedNetworkMonitor { // Everything below is only touched while holding this object's lock. private var appContext: Context? = null private var networkCallback: WifiCallback? = null + private var defaultNetworkCallback: DefaultNetworkCallback? = null private var receiver: BroadcastReceiver? = null private val ssidsByNetwork = HashMap() + // For the log only: the last report logged for each Wi-Fi network, and the network carrying + // the phone's internet. + private val reportsByNetwork = HashMap() + private var defaultNetwork: Network? = null + private var defaultNetworkReport: String? = null + fun isOnTrustedNetwork(): Boolean = _state.value.trusted /** @@ -80,7 +97,10 @@ object TrustedNetworkMonitor { if (receiver == null) { registerReceiver(app) - LogUtils.d(TAG, "Started monitoring Wi-Fi") + LogUtils.d(TAG, "Started monitoring Wi-Fi; ${describeConditions(app)}") + } + if (defaultNetworkCallback == null) { + registerDefaultNetworkCallback(app) } // Once trusted there is nothing to gain. Otherwise ask again: location access may have been // granted since Android last reported the network, and it only reports again on a change. @@ -116,8 +136,10 @@ object TrustedNetworkMonitor { } private fun stop(context: Context) { - val wasRunning = receiver != null || networkCallback != null + val wasRunning = + receiver != null || networkCallback != null || defaultNetworkCallback != null unregisterNetworkCallback(context) + unregisterDefaultNetworkCallback(context) receiver?.let { try { context.unregisterReceiver(it) @@ -152,6 +174,7 @@ object TrustedNetworkMonitor { try { connectivityManager.registerNetworkCallback(request, callback) networkCallback = callback + LogUtils.d(TAG, "Asked Android for the current Wi-Fi networks") } catch (e: Exception) { LogUtils.e(TAG, "Failed to register the Wi-Fi callback", e) } @@ -159,6 +182,7 @@ object TrustedNetworkMonitor { private fun unregisterNetworkCallback(context: Context) { ssidsByNetwork.clear() + reportsByNetwork.clear() val callback = networkCallback ?: return networkCallback = null try { @@ -169,14 +193,39 @@ object TrustedNetworkMonitor { } } + /** For the log only: follows which network carries the phone's internet. */ + private fun registerDefaultNetworkCallback(context: Context) { + val connectivityManager = context.getSystemService(ConnectivityManager::class.java) ?: return + val callback = DefaultNetworkCallback() + try { + connectivityManager.registerDefaultNetworkCallback(callback) + defaultNetworkCallback = callback + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to register the default network callback", e) + } + } + + private fun unregisterDefaultNetworkCallback(context: Context) { + defaultNetwork = null + defaultNetworkReport = null + val callback = defaultNetworkCallback ?: return + defaultNetworkCallback = null + try { + context.getSystemService(ConnectivityManager::class.java) + ?.unregisterNetworkCallback(callback) + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to unregister the default network callback", e) + } + } + private fun registerReceiver(context: Context) { val newReceiver = object : BroadcastReceiver() { override fun onReceive(receiverContext: Context, intent: Intent) { val app = receiverContext.applicationContext when (intent.action) { LocationManager.MODE_CHANGED_ACTION -> onLocationModeChanged(app) - Intent.ACTION_SCREEN_ON, - Intent.ACTION_USER_PRESENT -> checkOnScreenOn(app) + Intent.ACTION_SCREEN_ON -> checkOnScreenOn(app, "Screen on") + Intent.ACTION_USER_PRESENT -> checkOnScreenOn(app, "Unlock") } } } @@ -204,7 +253,10 @@ object TrustedNetworkMonitor { if (networkCallback == null) return val enabled = isLocationEnabled(context) - LogUtils.d(TAG, "Location turned ${if (enabled) "on" else "off"}") + LogUtils.d( + TAG, + "Location turned ${if (enabled) "on" else "off"}; ${describeConditions(context)}" + ) // Off: no name can be read any more, so drop trust now instead of at the next network change. // On: a trusted network the phone is already on is only reported to a fresh callback. @@ -214,22 +266,46 @@ object TrustedNetworkMonitor { } } - /** Takes trust away if the network name read now isn't a trusted one. Never grants it. */ + /** Takes trust away if the network read now isn't a trusted one. Never grants it. */ @Synchronized - private fun checkOnScreenOn(context: Context) { - if (networkCallback == null || !_state.value.trusted) return + private fun checkOnScreenOn(context: Context, event: String) { + if (networkCallback == null || !_state.value.trusted) { + LogUtils.d( + TAG, + "$event: not on a trusted network, nothing to check; ${describeConditions(context)}" + ) + return + } - val ssid = readConnectionSsid(context) - if (ssid != null && ssid in context.appLockRepository().getTrustedWifiSsids()) return + val trustedSsids = context.appLockRepository().getTrustedWifiSsids() + val wifiInfo = readConnectionInfo(context) + val ssid = readable(wifiInfo?.ssid) + val reading = "Wi-Fi ${wifiInfo?.supplicantState?.name?.lowercase() ?: "unknown"}, " + + "${describeName(ssid, trustedSsids)}, " + + "${describeSignal(context, wifiInfo?.rssi)}${describeDbm(wifiInfo?.rssi)}" + if (ssid != null && ssid in trustedSsids) { + LogUtils.d(TAG, "$event check kept trust ($reading); ${describeConditions(context)}") + return + } - LogUtils.d(TAG, "Screen-on check found no trusted network") + LogUtils.d( + TAG, + "Screen-on check found no trusted network ($event: $reading); " + + describeConditions(context) + ) // A trusted network that really is there comes straight back through the fresh callback. registerNetworkCallback(context) recompute(context) } @Synchronized - private fun onNetworkSsid(callback: WifiCallback, network: Network, ssid: String?) { + private fun onNetworkReport( + callback: WifiCallback, + network: Network, + ssid: String?, + validated: Boolean, + rssi: Int? + ) { if (callback !== networkCallback) return val readableSsid = readable(ssid) @@ -238,7 +314,10 @@ object TrustedNetworkMonitor { } else { ssidsByNetwork[network] = readableSsid } - appContext?.let { recompute(it) } + + val context = appContext ?: return + logNetworkReport(context, network, readableSsid, validated, rssi) + recompute(context) } @Synchronized @@ -246,7 +325,40 @@ object TrustedNetworkMonitor { if (callback !== networkCallback) return ssidsByNetwork.remove(network) - appContext?.let { recompute(it) } + reportsByNetwork.remove(network) + appContext?.let { + LogUtils.d(TAG, "Wi-Fi net $network gone; ${describeConditions(it)}") + recompute(it) + } + } + + /** For the log only: notes when the phone's internet moves to another network. */ + @Synchronized + private fun onDefaultNetwork( + callback: DefaultNetworkCallback, + network: Network, + capabilities: NetworkCapabilities? + ) { + if (callback !== defaultNetworkCallback) return + // A loss only matters for the network carrying internet. When internet moves to another + // network, Android reports the new one instead. + if (capabilities == null && network != defaultNetwork) return + + defaultNetwork = if (capabilities == null) null else network + val report = when { + capabilities == null -> "no network" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "VPN net $network" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi net $network" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> + "mobile data net $network" + + else -> "other network net $network" + } + if (report == defaultNetworkReport) return + defaultNetworkReport = report + appContext?.let { + LogUtils.d(TAG, "Internet now goes over $report; ${describeConditions(it)}") + } } private fun recompute(context: Context) { @@ -275,18 +387,94 @@ object TrustedNetworkMonitor { } } + private fun logNetworkReport( + context: Context, + network: Network, + ssid: String?, + validated: Boolean, + rssi: Int? + ) { + val trustedSsids = context.appLockRepository().getTrustedWifiSsids() + val report = "${describeName(ssid, trustedSsids)}, " + + "internet ${if (validated) "verified" else "not verified"}, " + + describeSignal(context, rssi) + // Only changes are logged: Android reports a network again whenever its signal moves. + if (reportsByNetwork.put(network, report) == report) return + + LogUtils.d( + TAG, + "Wi-Fi net $network: $report${describeDbm(rssi)}; ${describeConditions(context)}" + ) + } + + /** Whether a readable name is a trusted one, without the name itself. */ + private fun describeName(ssid: String?, trustedSsids: Set): String = when { + ssid == null -> "no readable name" + ssid in trustedSsids -> "trusted name" + else -> "other name" + } + + /** Signal in Android's bars, which move less often than the dBm reading. */ + @Suppress("DEPRECATION") // Per-device bar thresholds only exist from Android 11. + private fun describeSignal(context: Context, rssi: Int?): String { + if (rssi == null || rssi <= INVALID_RSSI) return "signal unknown" + val wifiManager = context.getSystemService(WifiManager::class.java) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && wifiManager != null) { + "signal ${wifiManager.calculateSignalLevel(rssi)}/${wifiManager.maxSignalLevel}" + } else { + "signal ${WifiManager.calculateSignalLevel(rssi, 5)}/4" + } + } + + private fun describeDbm(rssi: Int?): String = + if (rssi == null || rssi <= INVALID_RSSI) "" else " at $rssi dBm" + + /** Screen, app and location state, which decide whether Android shares a network's name. */ + private fun describeConditions(context: Context): String { + val screenOn = context.getSystemService(PowerManager::class.java)?.isInteractive == true + val process = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(process) + val app = when { + process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND -> + "app in foreground" + + process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE -> + "app as foreground service" + + process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE -> + "app visible" + + process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_PERCEPTIBLE -> + "app perceptible" + + else -> "app in background" + } + val location = when { + !hasLocationPermission(context) -> "no location access" + !hasBackgroundLocationPermission(context) -> "location while in use" + else -> "location all the time" + } + return listOf( + if (screenOn) "screen on" else "screen off", + if (context.isDeviceLocked()) "locked" else "unlocked", + "$app (${process.importance})", + location, + if (isLocationEnabled(context)) "Location on" else "Location off" + ).joinToString(", ") + } + private fun readable(ssid: String?): String? = ssid?.takeIf { it.isNotEmpty() && it != UNKNOWN_SSID } - /** The connected Wi-Fi's name straight from [WifiManager], or null if none is readable. */ + /** The current Wi-Fi connection straight from [WifiManager]; the name needs location access. */ @Suppress("DEPRECATION") // Still answers on Android 12+; the callback API has no synchronous read. - private fun readConnectionSsid(context: Context): String? { + private fun readConnectionInfo(context: Context): WifiInfo? { val wifiManager = context.applicationContext.getSystemService(WifiManager::class.java) ?: return null return try { - readable(wifiManager.connectionInfo?.ssid) + wifiManager.connectionInfo } catch (e: Exception) { - LogUtils.e(TAG, "Failed to read the Wi-Fi name", e) + LogUtils.e(TAG, "Failed to read the Wi-Fi connection", e) null } } @@ -298,17 +486,34 @@ object TrustedNetworkMonitor { constructor(flags: Int) : super(flags) override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { - val ssid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val wifiInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Carries the name only because the callback asked for location info. - (capabilities.transportInfo as? WifiInfo)?.ssid + capabilities.transportInfo as? WifiInfo } else { - TrustedNetworkMonitor.appContext?.let { TrustedNetworkMonitor.readConnectionSsid(it) } + TrustedNetworkMonitor.appContext?.let { TrustedNetworkMonitor.readConnectionInfo(it) } } - TrustedNetworkMonitor.onNetworkSsid(this, network, ssid) + TrustedNetworkMonitor.onNetworkReport( + this, + network, + ssid = wifiInfo?.ssid, + validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + rssi = wifiInfo?.rssi + ) } override fun onLost(network: Network) { TrustedNetworkMonitor.onNetworkLost(this, network) } } + + /** For the log only: follows the network carrying the phone's internet. */ + private class DefaultNetworkCallback : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + TrustedNetworkMonitor.onDefaultNetwork(this, network, capabilities) + } + + override fun onLost(network: Network) { + TrustedNetworkMonitor.onDefaultNetwork(this, network, null) + } + } } From 4492a8f4d5db7d40675c7a62910aaa3a9493d343 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:45:15 -0300 Subject: [PATCH 30/31] docs: say trusted Wi-Fi follows the connection, even at one bar Near the edge of range the phone can stay connected to a trusted network at one bar while it uses mobile data. Locked apps keep opening until the Wi-Fi disconnects, and the screen-on check doesn't catch it, since the phone is still connected. The old wording only said Wi-Fi lingers until the signal drops. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- AUTOMATION.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AUTOMATION.md b/AUTOMATION.md index 879ea76f..d85173e4 100644 --- a/AUTOMATION.md +++ b/AUTOMATION.md @@ -145,5 +145,9 @@ trusted network or not. `QUERY_PROTECTION_STATE` and `PROTECTION_STATE` report t - Networks are matched by name. Someone holding your unlocked phone could name a hotspot the same, connect the phone to it, and open locked apps. -- As you walk away, Wi-Fi stays connected until the signal drops, and locked apps keep opening - until then. +- Trust follows the Wi-Fi connection, not which network carries your internet. Near the edge of + range the phone can stay connected to a trusted network at one bar while it uses mobile data. + Until the Wi-Fi disconnects, locked apps keep opening and the shield keeps showing **HOME**. The + screen-on check doesn't catch this, because the phone really is still connected. Walking on ends + it once the Wi-Fi drops; staying within range, say just outside or in a building's shared areas, + keeps it going. From 504791d4ee2458003c871e79a5bc04cb73f9e609 Mon Sep 17 00:00:00 2001 From: thiagosoeiro <81694338+thiagosoeiro@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:03:32 -0300 Subject: [PATCH 31/31] ui: remove original-owner links from Settings and Main screens Drops the Donate card, Discord/Source Code/Report Issue links, and the auto-popup Discord invite and Donate bottom sheet, all of which pointed back to the upstream project's author. Underlying composables and preference plumbing are left in place, just no longer rendered. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HVGZVKJhw7t1rVQitX1sFz --- .../applock/features/applist/ui/MainScreen.kt | 29 ------------------- .../features/settings/ui/SettingsScreen.kt | 10 ------- 2 files changed, 39 deletions(-) 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 6d2faa34..24d3cc3e 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 @@ -54,7 +54,6 @@ 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.BackendImplementation -import dev.pranav.applock.ui.components.DonateModalBottomSheet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import rikka.shizuku.Shizuku @@ -124,34 +123,6 @@ fun MainScreen( val appLockRepository = context.appLockRepository() - var showCommunityLink by remember { mutableStateOf(appLockRepository.isShowCommunityLink()) } - - if (showCommunityLink) { - CommunityDialog( - onDismiss = { - appLockRepository.setCommunityLinkShown(true) - showCommunityLink = false - }, - onJoin = { - appLockRepository.setCommunityLinkShown(true) - showCommunityLink = false - context.startActivity( - Intent( - Intent.ACTION_VIEW, - "https://discord.gg/46wCMRVAre".toUri() - ) - ) - } - ) - } - - var showDonateDialog by remember { mutableStateOf(appLockRepository.isShowDonateLink()) } - if (showDonateDialog && !showCommunityLink) { - DonateModalBottomSheet { - appLockRepository.setShowDonateLink(false); showDonateDialog = false - } - } - Scaffold( modifier = Modifier .fillMaxSize() 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 637fc739..0614fe28 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 @@ -29,7 +29,6 @@ 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.* @@ -63,7 +62,6 @@ import dev.pranav.applock.data.repository.BackendImplementation import dev.pranav.applock.features.admin.AdminDisableActivity import dev.pranav.applock.services.ShizukuAppLockService import dev.pranav.applock.services.UsageLockService -import dev.pranav.applock.ui.components.DonateButton import dev.pranav.applock.ui.icons.* import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuProvider @@ -407,10 +405,6 @@ fun SettingsScreen( ) } - item { - DonateButton() - } - item { SectionTitle(text = stringResource(R.string.settings_screen_lock_screen_customization_title)) } @@ -741,10 +735,6 @@ fun SettingsScreen( shizukuPermissionLauncher = shizukuPermissionLauncher ) } - - item { - LinksSection() - } } } }