diff --git a/docs/superpowers/evidence/device-owner-enforcement-live-2026-08-10.json b/docs/superpowers/evidence/device-owner-enforcement-live-2026-08-10.json new file mode 100644 index 00000000..fd84cea8 --- /dev/null +++ b/docs/superpowers/evidence/device-owner-enforcement-live-2026-08-10.json @@ -0,0 +1,66 @@ +{ + "intent": "Turn the provisioned device owner into an actually-enforcing install: block YouTube (a FLAG_SYSTEM package the sweep could not previously touch), and make the enforcer impossible to uninstall while it is enforcing. Done inside the 0-account window, where a mistake costs a re-provision rather than a factory reset.", + "scope": [ + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt", + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt", + "focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt", + "phone_focus_mode/config.sh", + "focus_owner/assets/policy.json", + "Non-goal: home coordinate delivery, which needs an in-app writer (run-as is dead on a release build)" + ], + "changes": [ + "DevicePolicyBridge gained setSelfUninstallBlocked/isSelfUninstallBlocked. Device owner already hides Settings' uninstall button, but `adb uninstall` worked without this -- and that path strands ownership with no holder, making a factory reset the only exit.", + "releaseDeviceOwner now unblocks uninstall BEFORE clearDeviceOwnerApp. Reversed, the block would outlive the ownership that is required to lift it: an app that owns nothing and cannot be removed.", + "EnforcementRunner.apply re-asserts the block every pass, tracking decision.isEnforcing. Self-healing rather than set-once, and going away from home lifts it -- the app stays removable exactly when enforcement is already off.", + "pl.infakt.infakt added to config.sh (both the day and night allowlists) and the asset regenerated. It was absent because config.sh describes the rooted phone; the first pass would have hidden it, stranding the SMS-paired banking re-authentication chain." + ], + "verification": [ + { + "command": "jq allowed_packages vs `pm list packages -3`, comm -23, before applying anything", + "result": "pass", + "evidence": "Computed the hide set on the PC first. Caught pl.infakt.infakt in it. Confirmed org.fossify.messages, org.fossify.phone, keepass, mbank, revolut, mobywatel and Signal were all allowed -- hiding the SMS app would have stranded every bank re-pairing ahead." + }, + { + "command": "adb install -r app-release.apk over the provisioned device owner", + "result": "pass", + "evidence": "Success, and dumpsys still reported Device Owner Type: 0 with the same admin. This is what the release signing config bought: an in-place update that does not disturb ownership." + }, + { + "command": "tap 'Run enforcement now'", + "result": "pass", + "evidence": "'applied LOCATION_UNKNOWN: hid 27, restored 0'. dumpsys: youtube, youtube.music, chrome, vending, videos all hidden=true. Before the FLAG_SYSTEM fix these were invisible to the sweep." + }, + { + "command": "dumpsys on the apps that must keep working", + "result": "pass", + "evidence": "phone, messages, keepass, mbank, revolut, infakt, mobywatel, focus_owner, settings, signal all hidden=false. The phone remains usable and the restore chain is intact." + }, + { + "command": "adb uninstall com.kuhy.focus_owner", + "result": "pass (refused, as intended)", + "evidence": "Failure [DELETE_FAILED_DEVICE_POLICY_MANAGER]; logcat 'setUninstallBlocked(true) -> true'; package still installed. dumpsys device_policy lists PackagePolicyKey{packageUninstallBlocked, com.kuhy.focus_owner}." + }, + { + "command": "adb reboot, then re-check", + "result": "pass", + "evidence": "After reboot: youtube/chrome/vending still hidden=true, Device Owner Type: 0, uninstall still refused. This is the exact failure that defeated `pm suspend` and `pm disable-user` on this device -- both were cleared by a reboot. Device owner holds." + }, + { + "command": "gradlew :app:testDebugUnitTest ; pre-commit run --files <9 paths>", + "result": "pass", + "evidence": "Kotlin unit tests BUILD SUCCESSFUL. All pre-commit hooks pass, including shfmt/shellcheck on the new restore_apps.sh." + } + ], + "risks": [ + "Enforcement is currently PERMANENT, not geofenced. No home_location.json exists, so every pass decides LOCATION_UNKNOWN and fails closed. That is the safe direction, but it means YouTube is blocked everywhere, not only at home.", + "The geofence cannot be provisioned by the existing push_home_location.sh: it relies on `run-as`, which returns 'package not debuggable' on the release build device owner requires. The fix is an in-app 'set home to current location' action, deliberately NOT an exported intent -- an exported set-home is a bypass, since spoofing home to elsewhere would leave the device permanently 'away' with enforcement off. Deferred: it is an APK update, and updates install in place on the same signature, so it does not need the 0-account window.", + "com.celzero.bravedns (RethinkDNS) is in the hide set, so enforcement hides the independent DNS-level blocking layer. Not dangerous, but the layers are not additive while enforcing.", + "com.android.vending is hidden, so no app can be installed or updated from Play while enforcing. Intended -- it is what stops a one-tap YouTube reinstall -- but it is a large blast radius.", + "The uninstall block is tied to decision.isEnforcing. If a future change makes a pass decide AWAY incorrectly, the block lifts. That is the fail-safe direction (removable rather than stranded), but it is not a hard guarantee." + ], + "rollback": [ + "In-app 'Release device owner' unblocks uninstall and clears ownership, verified working on this device before any account existed.", + "IMPORTANT: once a Google account is added, `dpm set-device-owner` can never run again without another factory reset, so releasing becomes one-way. Verify the release path is REACHABLE (device owner: yes, button rendered) rather than pressing it.", + "To lift enforcement without releasing ownership: add the package to the allowlist in config.sh, regenerate the asset, rebuild, install in place, run a pass." + ] +} diff --git a/docs/superpowers/evidence/focus-owner-release-signing-2026-08-10.json b/docs/superpowers/evidence/focus-owner-release-signing-2026-08-10.json new file mode 100644 index 00000000..8a5ad04d --- /dev/null +++ b/docs/superpowers/evidence/focus-owner-release-signing-2026-08-10.json @@ -0,0 +1,51 @@ +{ + "intent": "Give focus_owner a real release signing config before provisioning it as device owner. A device owner can only be replaced by an APK with the same signature, and `dpm remove-active-admin` does not work on one -- so provisioning a debug-signed build would mean an app that can never be updated, whose only exit is another factory reset.", + "scope": [ + "focus_owner/android/app/build.gradle.kts", + "focus_owner/android/key.properties (gitignored, not committed)", + "Non-goal: minify/shrink, deliberately left off" + ], + "changes": [ + "Added the shared key.properties signing pattern already used by diet-guard, untools and home_inventory: storeFile/storePassword/keyAlias/keyPassword read from a gitignored file, falling back to the debug key when absent so a fresh clone still builds.", + "Replaced the scaffold `signingConfig = signingConfigs.getByName(\"debug\")` TODO in the release build type.", + "Deliberately did NOT enable isMinifyEnabled/isShrinkResources. An untested ProGuard pass that strips a DevicePolicyManager callback would produce a device owner that cannot release itself, and that costs a factory reset to undo." + ], + "verification": [ + { + "command": "apksigner verify --print-certs app-release.apk", + "result": "pass", + "evidence": "Signer #1 certificate DN: CN=kuhy, OU=kuhy, O=kuhy, C=PL (SHA-256 9dcc2e6c...). Not the Android debug key, so the APK is upgradable in place after provisioning." + }, + { + "command": "adb install -r app-release.apk over the existing debug build", + "result": "fail (expected, and the reason this was tested pre-wipe)", + "evidence": "INSTALL_FAILED_UPDATE_INCOMPATIBLE: signatures do not match. Caught before the factory reset, while a debug build was still available to fall back on. Resolved by uninstall + clean install." + }, + { + "command": "launch release build, check logcat for crashes", + "result": "pass", + "evidence": "ActivityTaskManager: Fully drawn com.kuhy.focus_owner/.MainActivity: +185ms, no AndroidRuntime/FATAL. A release build can fail where debug succeeds, so this was checked before it mattered." + }, + { + "command": "post-wipe: dpm set-device-owner com.kuhy.focus_owner/.FocusDeviceAdminReceiver", + "result": "pass", + "evidence": "Success. dumpsys device_policy: Device Owner admin=ComponentInfo{...FocusDeviceAdminReceiver}, provisioningState: 3, testOnlyAdmin=false, Device Owner Type: 0. Pre-wipe the same command failed with IllegalStateException: there are already some accounts on the device (6 accounts)." + }, + { + "command": "escape hatch: Release device owner -> confirm dialog -> Release", + "result": "pass", + "evidence": "logcat: 'FocusOwner: clearDeviceOwnerApp -> released=true' then 'Device admin disabled'. dumpsys then reported Device Owner Type: -1, provisioningState: 0. Tested at the one moment when failure was free -- before any account was added. Re-provisioned successfully afterwards, so both directions are proven." + } + ], + "risks": [ + "The keystore password sits in plaintext in focus_owner/android/key.properties. It is gitignored (focus_owner/android/.gitignore line 12), matching the three other repos that share this key, so it does not reach the repo -- but it is on disk unencrypted.", + "No minify means a larger APK (44.4MB) than a shrunk release would be. Accepted deliberately for the device-owner reliability reason above.", + "The phone currently has a provisioned device owner with `Restrictions: none (inert build)`. No enforcement is applied yet; that is the next stage and the checklist says one restriction at a time, re-testing the release path after each.", + "Signal message history was permanently destroyed by the wipe. The user was told the backup was missing, chose to proceed anyway, and confirmed explicitly. Signal itself re-registers with the number and PIN." + ], + "rollback": [ + "git revert restores the debug-key fallback. Any already-signed release APK keeps working; only future builds change signature, which would then fail to install over a release-signed install.", + "The device owner itself is removed from inside the app: Release device owner (clearDeviceOwnerApp), verified working. `dpm remove-active-admin` does NOT work on a device owner.", + "If the app is uninstalled without releasing first, the only exit is another factory reset." + ] +} diff --git a/docs/superpowers/evidence/restore-apps-after-wipe-2026-08-10.json b/docs/superpowers/evidence/restore-apps-after-wipe-2026-08-10.json new file mode 100644 index 00000000..ec07a0a7 --- /dev/null +++ b/docs/superpowers/evidence/restore-apps-after-wipe-2026-08-10.json @@ -0,0 +1,56 @@ +{ + "intent": "Reinstall the 54 packages captured by phone_backup.sh onto the freshly wiped Pixel 6a without disturbing the device owner, which cannot be re-provisioned once any account exists.", + "scope": [ + "phone_focus_mode/restore_apps.sh (new)", + "Non-goal: app-private data, logins, Signal history -- unreachable from adb on an unrooted phone", + "Non-goal: Google 'Copy apps & data', which is setup-wizard-only and mutually exclusive with device owner provisioning" + ], + "changes": [ + "New restore_apps.sh: iterates the backup's per-package directories and installs each with `adb install-multiple`, since 130 APKs across 54 packages means splits and `adb install base.apk` fails on those with INSTALL_FAILED_INVALID_APK.", + "Failures are collected and summarised rather than aborting the run: preinstalled and Play-licensed packages are expected to refuse a sideload, and stop-on-first-error would leave the restore half-done with no report.", + "com.kuhy.focus_owner is skipped by name. The captured APK is the old debug-signed build; it could not install over the provisioned release build anyway, but a successful downgrade would be far worse than a failed one.", + "Device owner state is printed before and after the run, so the invariant the whole factory reset was spent on is checked rather than assumed." + ], + "verification": [ + { + "command": "shellcheck phone_focus_mode/restore_apps.sh", + "result": "pass", + "evidence": "Clean after splitting the SC2155 declare-and-assign on SCRIPT_NAME." + }, + { + "command": "./restore_apps.sh --backup ... --serial 23181JEGR08034 --dry-run", + "result": "pass", + "evidence": "54 packages enumerated, split counts correct (airbnb 9, claude 6, discord 6), and 'SKIP com.kuhy.focus_owner (device owner; keeping the provisioned build)'." + }, + { + "command": "./restore_apps.sh --backup ... --serial 23181JEGR08034", + "result": "pass", + "evidence": "Installed: 54 Failed: 0. Better than predicted -- no Play-licensed refusals materialised. `pm list packages -3 | wc -l` on device confirms 54." + }, + { + "command": "adb push of /sdcard subtrees", + "result": "pass", + "evidence": "DCIM 1.2G (6 files), Download 40 files, Pictures 43 files, Movies 4, Music 2. On-device du: 1.1G DCIM, 109M Download, 4.3M Pictures." + }, + { + "command": "content call --uri content://media/external --method scan_volume", + "result": "pass", + "evidence": "27 images indexed in MediaStore. The documented MEDIA_MOUNTED broadcast is rejected by modern Android (SecurityException); scan_volume is the working path." + }, + { + "command": "dumpsys device_policy + dumpsys account after the full restore", + "result": "pass", + "evidence": "Device Owner Type: 0, admin=ComponentInfo{...FocusDeviceAdminReceiver}, accounts: 0. The restore cost nothing." + } + ], + "risks": [ + "Once a Google account is added, `dpm set-device-owner` can never run again without another factory reset. The escape hatch still releases without a wipe, but it becomes ONE-WAY: releasing means losing device owner permanently. The plan to 're-test the release path after each restriction' is therefore no longer safe to execute literally; verify the path is reachable (isDeviceOwner true, button rendered) instead of pressing it.", + "No .kdbx was found on /sdcard, before or after. KeePassDX stores its database in private storage, so phone_backup.sh never captured it. The canonical copy is on the PC, which is the user's stated intent.", + "App-private data did not come back: every app is at first-run state, and all logins must be redone. Signal history was destroyed by the wipe with the user's explicit go-ahead.", + "restore_apps.sh has no automated tests. It is an adb-driven operational script in phone_focus_mode/, matching the existing untested scripts there; the dry-run mode is the safety mechanism." + ], + "rollback": [ + "Uninstall any unwanted package with `adb uninstall `; nothing here is destructive to the device owner.", + "NEVER `adb uninstall com.kuhy.focus_owner` while it holds device ownership -- releasing first from inside the app is the only safe order, and after an account exists the release cannot be undone." + ] +} diff --git a/focus_owner/android/app/build.gradle.kts b/focus_owner/android/app/build.gradle.kts index fd92f928..851e3009 100644 --- a/focus_owner/android/app/build.gradle.kts +++ b/focus_owner/android/app/build.gradle.kts @@ -1,9 +1,23 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing key, kept out of the repo, shared with the other apps. +// +// This matters more here than elsewhere: a device owner can only be replaced +// by an APK with the same signature, and `dpm remove-active-admin` does not +// work on one. Provisioning a debug-signed build would mean the app could +// never be updated, and the only exit from a stale device owner that cannot +// release itself is another factory reset. +val keystoreProperties = Properties().apply { + val f = rootProject.file("key.properties") + if (f.exists()) f.inputStream().use { load(it) } +} + android { namespace = "com.kuhy.focus_owner" compileSdk = flutter.compileSdkVersion @@ -25,11 +39,27 @@ android { versionName = flutter.versionName } + signingConfigs { + if (keystoreProperties.getProperty("storeFile") != null) { + create("release") { + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // Falls back to the debug key only when key.properties is absent + // (a fresh clone), so `flutter run --release` still works. + signingConfig = signingConfigs.findByName("release") + ?: signingConfigs.getByName("debug") + // Deliberately no minify/shrink. An untested ProGuard pass that + // strips a DevicePolicyManager callback would produce a device + // owner that cannot release itself, and that costs a factory + // reset to undo. } } } diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt index 7072c342..8d6d454c 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/DevicePolicyBridge.kt @@ -64,6 +64,40 @@ class DevicePolicyBridge(private val context: Context) { /** Whether the admin component is active (a weaker state than ownership). */ fun isAdminActive(): Boolean = dpm.isAdminActive(admin) + /** + * Blocks or unblocks uninstalling this app. + * + * Device owner already hides Settings' uninstall button, but `adb uninstall` + * still works without this, and that path is the soft-brick: removing the + * app without releasing first strands ownership with no holder, and a + * factory reset becomes the only exit. + * + * Self-targeted deliberately. Blocking uninstall of *other* packages is a + * separate concern and is not what this protects. + */ + fun setSelfUninstallBlocked(blocked: Boolean): Boolean { + if (!isDeviceOwner()) { + Log.w(FocusDeviceAdminReceiver.TAG, "not DO; cannot change uninstall block") + return false + } + return runCatching { + dpm.setUninstallBlocked(admin, context.packageName, blocked) + val applied = dpm.isUninstallBlocked(admin, context.packageName) + Log.i(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked($blocked) -> $applied") + applied == blocked + }.getOrElse { error -> + Log.e(FocusDeviceAdminReceiver.TAG, "setUninstallBlocked failed", error) + false + } + } + + /** Whether uninstalling this app is currently blocked. */ + fun isSelfUninstallBlocked(): Boolean { + if (!isDeviceOwner()) return false + return runCatching { dpm.isUninstallBlocked(admin, context.packageName) } + .getOrDefault(false) + } + /** * Describes provisioning state for the UI, so the escape hatch can be found * without a PC attached. @@ -74,6 +108,7 @@ class DevicePolicyBridge(private val context: Context) { "isAdminActive" to isAdminActive(), "sdkInt" to Build.VERSION.SDK_INT, "restrictionsApplied" to false, + "uninstallBlocked" to isSelfUninstallBlocked(), ) /** @@ -96,6 +131,11 @@ class DevicePolicyBridge(private val context: Context) { return true } return runCatching { + // Order matters and is not interchangeable: uninstall blocking is a + // device-owner power, so clearing ownership first would leave the + // block in place with nothing privileged left to lift it — an app + // that owns nothing and cannot be removed. Unblock, then release. + setSelfUninstallBlocked(false) dpm.clearDeviceOwnerApp(context.packageName) val released = !isDeviceOwner() Log.i(FocusDeviceAdminReceiver.TAG, "clearDeviceOwnerApp -> released=$released") diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt index 05b3e659..96ec319c 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/EnforcementRunner.kt @@ -67,6 +67,14 @@ class EnforcementRunner(private val context: Context) { * that restores access rather than the half that removes it. */ fun apply(decision: EnforcementDecision, bridge: DevicePolicyBridge) { + // Re-asserted every pass rather than set once at provisioning time, so + // the protection self-heals: `adb uninstall` while enforcing is the one + // route that strands device ownership with no holder, and a factory + // reset is then the only exit. Tracking the decision means going away + // from home lifts it, which keeps the app removable in exactly the + // state where enforcement is already off. + bridge.setSelfUninstallBlocked(decision.isEnforcing) + var shown = 0 var hidden = 0 for (pkg in decision.packagesToShow) { diff --git a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt index 8f2a335a..00b1029c 100644 --- a/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt +++ b/focus_owner/android/app/src/main/kotlin/com/kuhy/focus_owner/MainActivity.kt @@ -14,6 +14,14 @@ class MainActivity : FlutterActivity() { when (call.method) { "status" -> result.success(bridge.status()) "releaseDeviceOwner" -> result.success(bridge.releaseDeviceOwner()) + "setSelfUninstallBlocked" -> { + val blocked = call.argument("blocked") + if (blocked == null) { + result.error("bad_args", "blocked required", null) + } else { + result.success(bridge.setSelfUninstallBlocked(blocked)) + } + } "setApplicationHidden" -> { val pkg = call.argument("package") val hidden = call.argument("hidden") diff --git a/focus_owner/assets/policy.json b/focus_owner/assets/policy.json index a85aece4..49929bf3 100644 --- a/focus_owner/assets/policy.json +++ b/focus_owner/assets/policy.json @@ -80,6 +80,7 @@ "org.runnerup", "org.thoughtcrime.securesms", "pkp.ic.eicmobile", + "pl.infakt.infakt", "pl.mbank", "pl.nask.mobywatel", "pl.orange.mojeorange", @@ -192,6 +193,7 @@ "org.fossify.messages", "org.fossify.phone", "org.runnerup", + "pl.infakt.infakt", "pl.mbank", "pl.nask.mobywatel", "pl.pkobp.iko", diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index f74ed503..2169d4db 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -421,6 +421,9 @@ com.kolejeslaskie.mss pl.mbank pl.pkobp.iko com.revolut.revolut +# Accounting, but device-paired to a bank and re-paired over SMS, so losing +# access to it strands the same re-authentication chain the banks do. +pl.infakt.infakt # --- Government / digital ID (Polish mObywatel — must always work) --- pl.nask.mobywatel @@ -537,6 +540,7 @@ com.kuhy.vaultkitbypass pl.mbank pl.pkobp.iko com.revolut.revolut +pl.infakt.infakt # Maps / navigation home com.google.android.apps.maps # Calendar diff --git a/phone_focus_mode/restore_apps.sh b/phone_focus_mode/restore_apps.sh new file mode 100755 index 00000000..751eb37f --- /dev/null +++ b/phone_focus_mode/restore_apps.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +# ============================================================================ +# Reinstall the APKs captured by phone_backup.sh onto a wiped device. +# +# Split APKs are the norm on a modern Pixel (130 files for 54 packages here), +# and `adb install base.apk` on a split package fails with +# INSTALL_FAILED_INVALID_APK -- every package therefore goes through +# `install-multiple` with its splits. +# +# Failures are expected and are NOT fatal: preinstalled system apps come back +# with the factory image, and some Play-licensed apps refuse a sideload. The +# script collects them and prints a summary instead of aborting, because a +# stop-on-first-error run would leave the restore half-done with no report. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +# Never reinstall the device owner from a backup. The captured APK predates the +# release signing config and is debug-signed, so it cannot install over the +# provisioned build anyway -- but a successful downgrade would be far worse +# than a failed one, so it is skipped by name rather than left to chance. +readonly DEVICE_OWNER_PKG="com.kuhy.focus_owner" + +BACKUP_DIR="" +SERIAL="" +DRY_RUN=0 + +usage() { + echo "Usage: $SCRIPT_NAME --backup DIR [--serial SERIAL] [--dry-run]" + echo "Options:" + echo " -b, --backup Backup directory from phone_backup.sh (required)" + echo " -s, --serial ADB serial (required when several devices attached)" + echo " -n, --dry-run List what would be installed, install nothing" + echo " -h, --help Show this help" + exit 0 +} + +# adb with the serial applied only when one was given, so the script also +# works with a single attached device. +adb_dev() { + if [[ -n "$SERIAL" ]]; then + adb -s "$SERIAL" "$@" + else + adb "$@" + fi +} + +validate_requirements() { + if [[ -z "$BACKUP_DIR" ]]; then + echo "Error: --backup is required" >&2 + exit 1 + fi + if [[ ! -d "$BACKUP_DIR/apks" ]]; then + echo "Error: no apks/ directory under $BACKUP_DIR" >&2 + exit 1 + fi + if ! command -v adb >/dev/null 2>&1; then + echo "Error: adb not found. Install with: sudo pacman -S android-tools" >&2 + exit 1 + fi + local state + state="$(adb_dev get-state 2>/dev/null || true)" + if [[ "$state" != "device" ]]; then + echo "Error: device not ready (adb state: ${state:-unreachable})" >&2 + exit 1 + fi +} + +# Guard the whole point of the exercise: a restore must never cost device +# ownership, and re-provisioning is impossible once an account exists. +check_device_owner() { + local dump + dump="$(adb_dev shell dumpsys device_policy 2>/dev/null || true)" + if [[ "$dump" == *"Device Owner Type: 0"* ]]; then + echo "Device owner: present (preserved by this script)" + else + echo "Device owner: NOT set -- continuing, but note it cannot be" + echo " provisioned again once any account is added." + fi +} + +install_one() { + local pkg_dir="$1" + local pkg + pkg="$(basename "$pkg_dir")" + + if [[ "$pkg" == "$DEVICE_OWNER_PKG" ]]; then + echo " SKIP $pkg (device owner; keeping the provisioned build)" + return 0 + fi + + local apks=() + mapfile -t apks < <(find "$pkg_dir" -maxdepth 1 -name '*.apk' | sort) + if [[ ${#apks[@]} -eq 0 ]]; then + echo " SKIP $pkg (no apk files)" + return 1 + fi + + if [[ "$DRY_RUN" -eq 1 ]]; then + echo " WOULD INSTALL $pkg (${#apks[@]} apk(s))" + return 0 + fi + + # install-multiple handles the single-APK case too, so there is no need to + # branch on the split/non-split distinction. + local output + if output="$(adb_dev install-multiple -r "${apks[@]}" 2>&1)"; then + echo " OK $pkg" + return 0 + fi + + # Trim to the failure reason; the streamed-install chatter is noise. + local reason + reason="$(printf '%s' "$output" | grep -oE 'INSTALL_[A-Z_]+' | head -1)" + echo " FAIL $pkg (${reason:-unknown})" + return 1 +} + +main() { + validate_requirements + + echo "============================================================================" + echo "Restoring APKs from $BACKUP_DIR" + echo "============================================================================" + check_device_owner + + local pkg_dirs=() + mapfile -t pkg_dirs < <(find "$BACKUP_DIR/apks" -mindepth 1 -maxdepth 1 -type d | sort) + echo "Packages in backup: ${#pkg_dirs[@]}" + echo + + local ok=0 + local failed=0 + local failed_pkgs=() + local dir + for dir in "${pkg_dirs[@]}"; do + if install_one "$dir"; then + ok=$((ok + 1)) + else + failed=$((failed + 1)) + failed_pkgs+=("$(basename "$dir")") + fi + done + + echo + echo "============================================================================" + echo "Installed: $ok Failed: $failed" + if [[ ${#failed_pkgs[@]} -gt 0 ]]; then + echo "Failed packages (expected for preinstalled/Play-licensed apps):" + printf ' %s\n' "${failed_pkgs[@]}" + fi + check_device_owner + echo "============================================================================" +} + +while [[ $# -gt 0 ]]; do + case $1 in + -b | --backup) + BACKUP_DIR="$2" + shift 2 + ;; + -s | --serial) + SERIAL="$2" + shift 2 + ;; + -n | --dry-run) + DRY_RUN=1 + shift + ;; + -h | --help) + usage + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +main "$@"