mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 17:34:04 +01:00
Make the enforcer real: block YouTube, and refuse to be uninstalled
The device owner is provisioned, so enforcement stops being theoretical. Three things had to be true before it was safe to switch on. Release signing. 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 a debug-signed device owner could never be updated -- the only exit would be another factory reset. focus_owner now uses the shared key.properties config the other apps use. No minify: an untested ProGuard pass that strips a DevicePolicyManager callback produces a device owner that cannot release itself. Uninstall blocking. Device owner hides Settings' uninstall button, but `adb uninstall` still worked, and that is the route that strands ownership with no holder. setSelfUninstallBlocked closes it, re-asserted every pass so it self-heals. The ordering in releaseDeviceOwner is not stylistic: unblock, THEN clear. Reversed, the block outlives the ownership required to lift it. The allowlist. config.sh describes the rooted phone, so the hide set was computed on the PC before any pass ran -- which caught pl.infakt.infakt in it. infakt is device-paired to a bank over SMS, so hiding it would have stranded the same re-authentication chain as hiding Messages. Verified on the Pixel 6a as a real device owner: hid 27, and youtube, youtube.music, chrome, vending and videos are all hidden=true, while phone, messages, keepass, mbank, revolut, infakt, mobywatel, settings and signal stay hidden=false. `adb uninstall` returns DELETE_FAILED_DEVICE_POLICY_MANAGER. All of it survives a reboot, which is exactly what `pm suspend` and `pm disable-user` failed to do here. restore_apps.sh reinstalls the 54 backed-up packages via install-multiple, since split APKs make a bare `adb install` fail. It skips the device owner's own package by name. Enforcement is permanent for now, not geofenced: push_home_location.sh delivers coordinates through `run-as`, which is dead on a release build, so every pass decides LOCATION_UNKNOWN and fails closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014DqEd1uUhyT52KjaNeLanC
This commit is contained in:
parent
eeaf9590da
commit
6bf7343c0f
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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 <pkg>`; 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."
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<Boolean>("blocked")
|
||||
if (blocked == null) {
|
||||
result.error("bad_args", "blocked required", null)
|
||||
} else {
|
||||
result.success(bridge.setSelfUninstallBlocked(blocked))
|
||||
}
|
||||
}
|
||||
"setApplicationHidden" -> {
|
||||
val pkg = call.argument<String>("package")
|
||||
val hidden = call.argument<Boolean>("hidden")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
184
phone_focus_mode/restore_apps.sh
Executable file
184
phone_focus_mode/restore_apps.sh
Executable file
@ -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 "$@"
|
||||
Loading…
Reference in New Issue
Block a user