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 diff --git a/ANTI_UNINSTALL.md b/ANTI_UNINSTALL.md new file mode 100644 index 00000000..13fc51d5 --- /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 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). +- **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/AUTOMATION.md b/AUTOMATION.md new file mode 100644 index 00000000..d85173e4 --- /dev/null +++ b/AUTOMATION.md @@ -0,0 +1,153 @@ +# 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. + +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 + +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. + +## 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. +- 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. diff --git a/FUTURE_IMPROVEMENTS.md b/FUTURE_IMPROVEMENTS.md new file mode 100644 index 00000000..243d244e --- /dev/null +++ b/FUTURE_IMPROVEMENTS.md @@ -0,0 +1,247 @@ +# 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 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 | +| 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 + +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 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 + `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. + +### 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. + +- **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 or video + +[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 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. 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". 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. `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 + +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. + +## 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 +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. 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 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12c9f0f8..e0b2f23d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,6 +20,23 @@ 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) + storeType = "PKCS12" + storePassword = System.getenv("APPLOCK_KEYSTORE_PASSWORD") + keyAlias = System.getenv("APPLOCK_KEY_ALIAS") + keyPassword = System.getenv("APPLOCK_KEY_PASSWORD") + } + } + } + buildTypes { release { isMinifyEnabled = true @@ -31,7 +48,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..f7ca1bc7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,13 @@ + + + + + + + @@ -140,6 +147,21 @@ + + + + + + + + + = Build.VERSION_CODES.TIRAMISU) { + setRecentsScreenshotEnabled(false) + } + navigationManager = NavigationManager(this) setContent { 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 new file mode 100644 index 00000000..a595b250 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/broadcast/AutomationReceiver.kt @@ -0,0 +1,145 @@ +package dev.pranav.applock.core.broadcast + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +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 + +/** + * Lets automation apps (MacroDroid, Tasker, adb) set the global protection flag - the same flag + * the shield toggle on the main screen writes, and the flag every backend checks before locking. + * + * Deliberately 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 silently drifts out of sync and leaves + * protection off when you believe it is on. ENABLE and DISABLE are idempotent and cannot desync. + * + * Guarded three ways, since this component can switch a security feature off: + * - the component itself stays disabled until the user opts in from Settings, + * - every action requires the token generated there, compared in constant time, + * - anything unrecognised, unauthenticated, or sent before a token exists is ignored. + */ +class AutomationReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val repository = context.appLockRepository() + val action = intent.action + + if (!repository.isAutomationEnabled()) { + reject(context, "automation control is off", action) + return + } + + val expected = repository.getAutomationToken() + if (expected.isNullOrEmpty()) { + reject(context, "no token has been generated", action) + return + } + + if (!SecurityUtils.constantTimeEquals(intent.getStringExtra(EXTRA_TOKEN), expected)) { + reject(context, "invalid or missing token", action) + return + } + + when (action) { + 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) + return + } + } + + val enabled = repository.isProtectEnabled() + reportResult(if (enabled) RESULT_PROTECTION_ON else RESULT_PROTECTION_OFF, enabled.toString()) + notifyStateChanged(context, enabled) + } + + 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 + + /** + * 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) { + AppLockServiceStarter.relockAll(context, repository) + } + + 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. + */ + 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/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) { 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/core/navigation/AppNavigator.kt b/app/src/main/java/dev/pranav/applock/core/navigation/AppNavigator.kt index 2b73fb30..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 @@ -16,7 +16,9 @@ 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.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 @@ -179,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) } @@ -189,22 +192,18 @@ 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) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_WEAK or - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setTitle(context.getString(R.string.biometric_verify_title)) + .setNegativeButtonText(context.getString(R.string.use_pin_button)) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() } @@ -226,6 +225,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/core/network/TrustedNetworkMonitor.kt b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt new file mode 100644 index 00000000..263a8103 --- /dev/null +++ b/app/src/main/java/dev/pranav/applock/core/network/TrustedNetworkMonitor.kt @@ -0,0 +1,519 @@ +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 +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 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 + +/** + * 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. + * + * 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 { + 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 = "" + + /** 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, + 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 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 + + /** + * 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; ${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. + 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 || defaultNetworkCallback != null + unregisterNetworkCallback(context) + unregisterDefaultNetworkCallback(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 + 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) + } + } + + private fun unregisterNetworkCallback(context: Context) { + ssidsByNetwork.clear() + reportsByNetwork.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) + } + } + + /** 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 -> checkOnScreenOn(app, "Screen on") + Intent.ACTION_USER_PRESENT -> checkOnScreenOn(app, "Unlock") + } + } + } + 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"}; ${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. + if (!enabled || !_state.value.trusted) { + registerNetworkCallback(context) + recompute(context) + } + } + + /** Takes trust away if the network read now isn't a trusted one. Never grants it. */ + @Synchronized + 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 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 ($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 onNetworkReport( + callback: WifiCallback, + network: Network, + ssid: String?, + validated: Boolean, + rssi: Int? + ) { + if (callback !== networkCallback) return + + val readableSsid = readable(ssid) + if (readableSsid == null) { + ssidsByNetwork.remove(network) + } else { + ssidsByNetwork[network] = readableSsid + } + + val context = appContext ?: return + logNetworkReport(context, network, readableSsid, validated, rssi) + recompute(context) + } + + @Synchronized + private fun onNetworkLost(callback: WifiCallback, network: Network) { + if (callback !== networkCallback) return + + ssidsByNetwork.remove(network) + 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) { + 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 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 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 readConnectionInfo(context: Context): WifiInfo? { + val wifiManager = context.applicationContext.getSystemService(WifiManager::class.java) + ?: return null + return try { + wifiManager.connectionInfo + } catch (e: Exception) { + LogUtils.e(TAG, "Failed to read the Wi-Fi connection", 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 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 + } else { + TrustedNetworkMonitor.appContext?.let { TrustedNetworkMonitor.readConnectionInfo(it) } + } + 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) + } + } +} 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..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 @@ -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,14 @@ 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 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_STRONG + ) == BiometricManager.BIOMETRIC_SUCCESS 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..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 @@ -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()) } @@ -40,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) @@ -52,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 @@ -76,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/AppLockRepository.kt b/app/src/main/java/dev/pranav/applock/data/repository/AppLockRepository.kt index 3e928706..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 @@ -57,6 +58,12 @@ 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() + fun setLockType(lockType: String) = preferencesRepository.setLockType(lockType) fun getLockType(): String = preferencesRepository.getLockType() @@ -79,6 +86,28 @@ 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) + + fun isAutomationEnabled(): Boolean = preferencesRepository.isAutomationEnabled() + 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/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 522d7128..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 @@ -17,45 +17,130 @@ class PreferencesRepository(context: Context) { private val settingsPrefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME_SETTINGS, Context.MODE_PRIVATE) + 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) } + appLockPrefs.edit(commit = true) { + putString(KEY_PASSWORD, credentialHasher.hash(password)) + 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 (!credentialHasher.verify(sanitizedInput, stored)) return false - if (SecurityUtils.isSaltedHash(stored)) { - return SecurityUtils.verifyPassword(sanitizedInput, stored) - } - - 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 true + } - 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) } + appLockPrefs.edit(commit = true) { putString(KEY_PATTERN, credentialHasher.hash(pattern)) } } fun getPattern(): String? { 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 + if (storedPattern.isNullOrBlank()) return false + if (!credentialHasher.verify(inputPattern, storedPattern)) 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 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 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) } + } + } + + /** 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) { @@ -114,6 +199,50 @@ 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 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) } } @@ -190,10 +319,20 @@ 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 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. */ + 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/admin/AdminDisableActivity.kt b/app/src/main/java/dev/pranav/applock/features/admin/AdminDisableActivity.kt index 16e9cc78..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 @@ -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,9 +274,14 @@ fun AdminDisablePasswordScreen( var showError by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() + 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() } Column( @@ -293,6 +306,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 +326,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 +361,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/applist/ui/MainScreen.kt b/app/src/main/java/dev/pranav/applock/features/applist/ui/MainScreen.kt index 9e9cf812..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 @@ -45,14 +45,15 @@ 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.network.TrustedNetworkMonitor import dev.pranav.applock.core.utils.appLockRepository 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 @@ -86,6 +87,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) { @@ -118,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() @@ -164,13 +141,23 @@ fun MainScreen( actions = { Surface( onClick = { - appLockRepository.setProtectEnabled(!applockEnabled) + AutomationReceiver.setProtection(context, !applockEnabled) applockEnabled = !applockEnabled + 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) @@ -179,13 +166,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 ) } } 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..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,11 +73,16 @@ 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(), @@ -129,6 +134,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 +171,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 +225,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/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/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 c485bd3c..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 @@ -248,15 +248,10 @@ 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 - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() } @@ -273,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. @@ -397,6 +393,31 @@ 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 + // 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( @@ -438,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, ) @@ -455,18 +480,13 @@ fun PinPasswordOverlayScreen( KeypadSection( passwordState = passwordState, minLength = minLength, + enabled = lockoutSeconds == 0L, showBiometricButton = showBiometricButton, fromMainActivity = fromMainActivity, onBiometricAuth = onBiometricAuth, onAuthSuccess = onAuthSuccess, onPinAttempt = onPinAttempt, - onPasswordChange = { - showError = false - - if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) - } - }, + onPasswordChange = onPasswordChange, onPinIncorrect = { showError = true } ) } @@ -510,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) @@ -524,18 +548,13 @@ fun PinPasswordOverlayScreen( KeypadSection( passwordState = passwordState, minLength = minLength, + enabled = lockoutSeconds == 0L, showBiometricButton = showBiometricButton, fromMainActivity = fromMainActivity, onBiometricAuth = onBiometricAuth, onAuthSuccess = onAuthSuccess, onPinAttempt = onPinAttempt, - onPasswordChange = { - showError = false - - if (appLockRepository.isAutoUnlockEnabled()) { - onPinAttempt?.invoke(passwordState.value) - } - }, + onPasswordChange = onPasswordChange, onPinIncorrect = { showError = true } ) } @@ -690,6 +709,7 @@ fun PasswordIndicators( fun KeypadSection( passwordState: MutableState, minLength: Int, + enabled: Boolean = true, showBiometricButton: Boolean, fromMainActivity: Boolean = false, onBiometricAuth: () -> Unit, @@ -837,6 +857,7 @@ fun KeypadSection( } KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("1", "2", "3"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -844,6 +865,7 @@ fun KeypadSection( ) KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("4", "5", "6"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -851,6 +873,7 @@ fun KeypadSection( ) KeypadRow( disableHaptics = disableHaptics, + enabled = enabled, keys = listOf("7", "8", "9"), onKeyClick = onDigitKeyClick, buttonSize = buttonSize, @@ -858,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, @@ -943,6 +967,7 @@ private fun handleKeypadSpecialButtonLogic( @Composable fun KeypadRow( disableHaptics: Boolean = false, + enabled: Boolean = true, keys: List, icons: List = emptyList(), onKeyClick: (String) -> Unit, @@ -989,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..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 @@ -52,6 +52,12 @@ fun PatternLockScreen( ) { Box(modifier = Modifier.fillMaxSize()) { 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) } @@ -79,6 +85,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 +130,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 +167,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 +206,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 +247,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/lockscreen/ui/TransparentBiometricActivity.kt b/app/src/main/java/dev/pranav/applock/features/lockscreen/ui/TransparentBiometricActivity.kt index 92313840..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 @@ -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 @@ -12,16 +14,41 @@ 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 +/** + * 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,57 +59,124 @@ 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 appNameForPrompt = getString(R.string.this_app) + val biometricPrompt = BiometricPrompt(this, executor, authenticationCallback) + 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 - BiometricManager.Authenticators.BIOMETRIC_STRONG - ) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) .setConfirmationRequired(false) .build() 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 + appLockRepository().clearFailedAttempts() + 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/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/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt b/app/src/main/java/dev/pranav/applock/features/settings/ui/SettingsScreen.kt index 7438a6a1..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 @@ -1,27 +1,34 @@ package dev.pranav.applock.features.settings.ui +import android.Manifest 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.provider.Settings 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 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 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.* @@ -32,14 +39,21 @@ 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.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 import dev.pranav.applock.core.utils.isAccessibilityServiceEnabled import dev.pranav.applock.core.utils.openAccessibilitySettings @@ -48,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 @@ -90,17 +103,99 @@ 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 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) } 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() } + + // 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) } + } + + // 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) { @@ -144,6 +239,75 @@ 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 (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 }, @@ -241,10 +405,6 @@ fun SettingsScreen( ) } - item { - DonateButton() - } - item { SectionTitle(text = stringResource(R.string.settings_screen_lock_screen_customization_title)) } @@ -368,6 +528,135 @@ 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)) + } + + 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)) } @@ -446,10 +735,6 @@ fun SettingsScreen( shizukuPermissionLauncher = shizukuPermissionLauncher ) } - - item { - LinksSection() - } } } } @@ -1010,6 +1295,193 @@ 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 +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( + 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/AppLockAccessibilityService.kt b/app/src/main/java/dev/pranav/applock/services/AppLockAccessibilityService.kt index dad4b8a1..78ee647f 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 @@ -19,10 +17,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 @@ -32,8 +32,25 @@ 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. + // 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. + 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 isRecentsOpen: Boolean = false private var overlayManager: LockScreenOverlayManager? = null private lateinit var mainHandler: Handler @@ -57,8 +74,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) @@ -77,6 +93,7 @@ class AppLockAccessibilityService : AccessibilityService() { mainHandler = Handler(mainLooper) overlayManager = LockScreenOverlayManager(this) + AppLockManager.lockScreenHost = lockScreenHost val filter = android.content.IntentFilter().apply { addAction(Intent.ACTION_SCREEN_OFF) @@ -124,7 +141,7 @@ class AppLockAccessibilityService : AccessibilityService() { checkForDeviceAdminDeactivation(event) } - if (!appLockRepository.isProtectEnabled()) { + if (!appLockRepository.isProtectionActive()) { return } @@ -142,8 +159,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) @@ -171,13 +187,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()) } } } @@ -201,22 +216,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 } @@ -247,6 +280,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() && @@ -257,10 +292,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()) } @@ -279,42 +322,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() || @@ -327,8 +343,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 +378,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) @@ -382,14 +433,19 @@ 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 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 + } - LogUtils.d(TAG, "User is on device admin page: $isDeviceAdminPage, $event") + // Check if on the confirmation page for our own device admin + val isOwnDeviceAdminPage = isOwnDeviceAdminPage(event) - if (!isDeviceAdminPage) { + LogUtils.d(TAG, "User is on our device admin page: $isOwnDeviceAdminPage, $event") + + if (!isOwnDeviceAdminPage) { return } @@ -397,17 +453,46 @@ 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("App Lock") } + event.text.any { it.contains(label) } val isSubSettings = event.className == "com.android.settings.SubSettings" && - event.text.any { it.contains("App Lock") } + event.text.any { it.contains(label) } val isAlertDialog = event.packageName == "com.google.android.packageinstaller" && event.className == "android.app.AlertDialog" && event.text.toString() - .lowercase().contains("App Lock") + .contains(label) 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. + * + * 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 && + findNodeWithTextContaining(root, ownVersionName) != null + } + @SuppressLint("InlinedApi") private fun blockDeactivationAttempt() { try { @@ -419,15 +504,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") @@ -444,7 +537,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.") @@ -485,36 +578,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 { @@ -570,6 +635,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 f5296444..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 @@ -57,34 +89,137 @@ object AppLockManager { val isLockScreenShown = AtomicBoolean(false) var currentBiometricState: AppLockAccessibilityService.BiometricState? = null - // Grace period tracking - private var recentlyLeftApp: String = "" - private var recentlyLeftTime: Long = 0L - private const val GRACE_PERIOD_MS = 300L + /** + * 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 + + /** + * 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( @@ -121,14 +256,26 @@ 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() + pendingReturnApp = "" + pendingReturnSince = 0L + LogUtils.d(TAG, "Cleared all unlock states") + } + fun clearAppUnlockState(packageName: String) { if (temporarilyUnlockedApp == packageName) { 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..87c36d11 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) @@ -152,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) @@ -160,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() @@ -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..1367dd4c 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) { @@ -131,9 +131,9 @@ class UsageLockService: Service() { private fun safeMonitorForegroundApp() { try { - if (!appLockRepository.isProtectEnabled() || applicationContext.isDeviceLocked()) { + if (!appLockRepository.isProtectionActive() || 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) { @@ -314,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) @@ -322,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/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 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 ea86d36a..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index 27253744..00000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp deleted file mode 100644 index dd1c2f2a..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 8e420dbc..00000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp deleted file mode 100644 index 8f229aa2..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index a0581839..00000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp deleted file mode 100644 index f4ab3caf..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 0771c88f..00000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp deleted file mode 100644 index 5eeec77a..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index ad5db814..00000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 98e18200..48573608 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,6 +1,8 @@ - قفل التطبيقات - يمنع الأشخاص غير المصرح لهم من إلغاء تثبيت التطبيق. - يسمح لقفل التطبيقات باكتشاف فتح التطبيقات المحمية وعرض شاشة التحقق بكلمة المرور. + خدمات النظام + مطلوب لكي تعمل خدمات النظام بشكل صحيح. + مطلوب لكي تعمل خدمات النظام بشكل صحيح. + خدمة في الخلفية + قيد التشغيل diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1873486b..3a7c5656 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,11 +1,15 @@ - 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. + 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 @@ -37,7 +41,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 +49,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. @@ -61,9 +65,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 @@ -316,4 +319,50 @@ 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! + + + 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 + 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