resInfoList = getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
+ for (android.content.pm.ResolveInfo resolveInfo : resInfoList) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ grantUriPermission(packageName, apkUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ }
+
+ try {
+ startActivity(intent);
+ } catch (Exception e) {
+ Log.e(TAG, "OTA: Error launching installer", e);
+ Toast.makeText(this, R.string.ota_error_launching_installer, Toast.LENGTH_LONG).show();
}
}
diff --git a/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java b/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java
new file mode 100644
index 0000000..1af0895
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/update/data/ApkVerifier.java
@@ -0,0 +1,125 @@
+/*
+ * ============================================================================
+ * Name : ApkVerifier.java
+ * Author : IIAB Project
+ * Copyright : Copyright (c) 2026 IIAB Project
+ * Description : Verifies a downloaded OTA APK is signed by the SAME certificate
+ * as the running app, before it is installed. Closes part of
+ * tech-debt F15 (no integrity verification of the update APK).
+ * ============================================================================
+ */
+package org.iiab.controller.update.data;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.os.Build;
+import android.util.Log;
+
+import org.iiab.controller.update.domain.CertDigests;
+
+import java.io.File;
+import java.security.MessageDigest;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Reads APK signing certificates via {@link PackageManager} (no private key,
+ * no secrets — only public certs) and checks the downloaded update APK was
+ * signed by the same certificate as the installed app.
+ *
+ * This rejects a tampered/MITM'd APK (different signer) and a download that
+ * is not even a validly-signed APK (e.g. an HTML/text error page → no signer →
+ * empty set), so it must be called before launching the
+ * installer.
+ */
+public final class ApkVerifier {
+
+ private static final String TAG = "IIAB-ApkVerifier";
+
+ private ApkVerifier() {
+ // Static utility; not instantiable.
+ }
+
+ /** True only if {@code apkFile} is signed by the same certificate(s) as the running app. */
+ public static boolean isSignedBySameCertAsApp(Context context, File apkFile) {
+ if (apkFile == null || !apkFile.exists()) {
+ return false;
+ }
+ PackageManager pm = context.getPackageManager();
+ Set appDigests = digestsForInstalledApp(pm, context.getPackageName());
+ Set apkDigests = digestsForArchive(pm, apkFile.getAbsolutePath());
+ boolean ok = CertDigests.sameSigner(appDigests, apkDigests);
+ if (!ok) {
+ Log.w(TAG, "Update APK signature does not match the running app (or it is not a signed APK).");
+ }
+ return ok;
+ }
+
+ private static Set digestsForInstalledApp(PackageManager pm, String pkg) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES);
+ return digestsFromSigningInfo(pi);
+ }
+ @SuppressWarnings("deprecation")
+ PackageInfo pi = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES);
+ return digestsFromSignatures(pi == null ? null : pi.signatures);
+ } catch (Exception e) {
+ Log.e(TAG, "Could not read installed app signatures", e);
+ return new HashSet<>();
+ }
+ }
+
+ private static Set digestsForArchive(PackageManager pm, String path) {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNING_CERTIFICATES);
+ return digestsFromSigningInfo(pi);
+ }
+ @SuppressWarnings("deprecation")
+ PackageInfo pi = pm.getPackageArchiveInfo(path, PackageManager.GET_SIGNATURES);
+ return digestsFromSignatures(pi == null ? null : pi.signatures);
+ } catch (Exception e) {
+ Log.e(TAG, "Could not read APK archive signatures", e);
+ return new HashSet<>();
+ }
+ }
+
+ private static Set digestsFromSigningInfo(PackageInfo pi) {
+ if (pi == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P || pi.signingInfo == null) {
+ return new HashSet<>();
+ }
+ // Use the current APK content signers for both app and archive so the
+ // sets are directly comparable.
+ return digestsFromSignatures(pi.signingInfo.getApkContentsSigners());
+ }
+
+ private static Set digestsFromSignatures(Signature[] sigs) {
+ Set out = new HashSet<>();
+ if (sigs == null) {
+ return out;
+ }
+ for (Signature s : sigs) {
+ String d = sha256Hex(s.toByteArray());
+ if (d != null) {
+ out.add(d);
+ }
+ }
+ return out;
+ }
+
+ private static String sha256Hex(byte[] data) {
+ try {
+ byte[] h = MessageDigest.getInstance("SHA-256").digest(data);
+ StringBuilder sb = new StringBuilder(h.length * 2);
+ for (byte b : h) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java b/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java
new file mode 100644
index 0000000..adf132a
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/update/domain/CertDigests.java
@@ -0,0 +1,46 @@
+/*
+ * ============================================================================
+ * Name : CertDigests.java
+ * Author : IIAB Project
+ * Copyright : Copyright (c) 2026 IIAB Project
+ * Description : Domain rule: does a downloaded APK's set of signing-certificate
+ * digests match the running app's? Pure JVM, unit-testable.
+ * ============================================================================
+ */
+package org.iiab.controller.update.domain;
+
+import java.util.Set;
+
+/**
+ * Pure comparison of APK signing-certificate digests.
+ *
+ * The OTA updater must only install an APK signed by the same
+ * certificate as the running app (Android enforces this for updates anyway;
+ * checking it ourselves before launching the installer rejects MITM/tampered
+ * or non-APK downloads early with a clear message — see tech-debt F15).
+ *
+ *
Certificate extraction (Android {@code PackageManager}) lives in the data
+ * layer; this only compares the resulting digest sets, so it is unit-testable
+ * on a plain JVM.
+ */
+public final class CertDigests {
+
+ private CertDigests() {
+ // Static utility; not instantiable.
+ }
+
+ /**
+ * True only if both sets are non-empty and identical. An empty candidate set
+ * (e.g. the download was not a validly-signed APK — a text/HTML error page)
+ * therefore fails, as does any set signed by a different certificate.
+ */
+ public static boolean sameSigner(Set appDigests, Set apkDigests) {
+ if (appDigests == null || apkDigests == null) {
+ return false;
+ }
+ if (appDigests.isEmpty() || apkDigests.isEmpty()) {
+ return false;
+ }
+ return appDigests.equals(apkDigests);
+ }
+}
diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java b/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java
new file mode 100644
index 0000000..998fb4e
--- /dev/null
+++ b/controller/app/src/main/java/org/iiab/controller/update/domain/UpdateCheck.java
@@ -0,0 +1,38 @@
+/*
+ * ============================================================================
+ * Name : UpdateCheck.java
+ * Author : IIAB Project
+ * Copyright : Copyright (c) 2026 IIAB Project
+ * Description : Domain rule for the OTA self-updater: is the server build newer
+ * than the installed one? Pure JVM, unit-testable.
+ * ============================================================================
+ */
+package org.iiab.controller.update.domain;
+
+/**
+ * Pure (framework-free) rules for the in-app OTA updater.
+ *
+ * The server publishes a {@code versionCodeBase}; the installed app's raw
+ * {@code versionCode} maps to the same base by integer division by 10 (the
+ * project's per-ABI versioning scheme). An update is offered only when the
+ * server base is strictly greater.
+ */
+public final class UpdateCheck {
+
+ /** The project encodes the ABI in the last digit of the versionCode. */
+ public static final int VERSION_CODE_ABI_FACTOR = 10;
+
+ private UpdateCheck() {
+ // Static utility; not instantiable.
+ }
+
+ /** The shared base of a raw per-ABI {@code versionCode}. */
+ public static int baseOf(int rawVersionCode) {
+ return rawVersionCode / VERSION_CODE_ABI_FACTOR;
+ }
+
+ /** True if the server build (already a base) is newer than the installed raw code. */
+ public static boolean isUpdateAvailable(int serverVersionCodeBase, int installedRawVersionCode) {
+ return serverVersionCodeBase > baseOf(installedRawVersionCode);
+ }
+}
diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml
index ee11369..be407f5 100644
--- a/controller/app/src/main/res/values-es/strings.xml
+++ b/controller/app/src/main/res/values-es/strings.xml
@@ -500,4 +500,7 @@
Hardware compatible. Iniciando conexión...
Credenciales de sincronización inválidas o no seguras. Escanea un código QR de sincronización IIAB válido.
+ No se pudo verificar la actualización y no se instaló. Inténtalo de nuevo.
+ La descarga de la actualización falló. Inténtalo de nuevo.
+ Permite instalar actualizaciones desde esta app y vuelve a tocar actualizar.
\ No newline at end of file
diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml
index bef9101..79cdcd7 100644
--- a/controller/app/src/main/res/values/strings.xml
+++ b/controller/app/src/main/res/values/strings.xml
@@ -515,4 +515,7 @@
Hardware compatible. Starting connection...
Invalid or unsafe sync credentials. Please scan a valid IIAB sync QR code.
+ The update could not be verified and was not installed. Please try again.
+ The update download failed. Please try again.
+ Allow installing updates from this app, then tap update again.
\ No newline at end of file
diff --git a/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java b/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java
new file mode 100644
index 0000000..a568d30
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/update/domain/CertDigestsTest.java
@@ -0,0 +1,40 @@
+package org.iiab.controller.update.domain;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.Test;
+
+public class CertDigestsTest {
+
+ private static Set of(String... v) {
+ return new HashSet<>(java.util.Arrays.asList(v));
+ }
+
+ @Test
+ public void acceptsIdenticalNonEmptySets() {
+ assertTrue(CertDigests.sameSigner(of("AA:BB"), of("AA:BB")));
+ }
+
+ @Test
+ public void rejectsDifferentSigner() {
+ assertFalse(CertDigests.sameSigner(of("AA:BB"), of("CC:DD")));
+ }
+
+ @Test
+ public void rejectsEmptyCandidate() {
+ // e.g. the download was a text/HTML page, not a signed APK
+ assertFalse(CertDigests.sameSigner(of("AA:BB"), Collections.emptySet()));
+ }
+
+ @Test
+ public void rejectsNullsAndEmptyApp() {
+ assertFalse(CertDigests.sameSigner(null, of("AA")));
+ assertFalse(CertDigests.sameSigner(of("AA"), null));
+ assertFalse(CertDigests.sameSigner(Collections.emptySet(), of("AA")));
+ }
+}
diff --git a/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java b/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java
new file mode 100644
index 0000000..bb593e0
--- /dev/null
+++ b/controller/app/src/test/java/org/iiab/controller/update/domain/UpdateCheckTest.java
@@ -0,0 +1,25 @@
+package org.iiab.controller.update.domain;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class UpdateCheckTest {
+
+ @Test
+ public void baseStripsTheAbiDigit() {
+ assertEquals(50, UpdateCheck.baseOf(500));
+ assertEquals(50, UpdateCheck.baseOf(501));
+ assertEquals(50, UpdateCheck.baseOf(509));
+ }
+
+ @Test
+ public void offersUpdateOnlyWhenServerBaseIsStrictlyNewer() {
+ assertTrue(UpdateCheck.isUpdateAvailable(51, 500)); // server 51 > local base 50
+ assertFalse(UpdateCheck.isUpdateAvailable(50, 500)); // equal
+ assertFalse(UpdateCheck.isUpdateAvailable(49, 500)); // older
+ assertFalse(UpdateCheck.isUpdateAvailable(50, 509)); // same base, different ABI digit
+ }
+}
diff --git a/controller/docs/TECH_DEBT_PLAN.md b/controller/docs/TECH_DEBT_PLAN.md
index 07df08b..eabc6a5 100644
--- a/controller/docs/TECH_DEBT_PLAN.md
+++ b/controller/docs/TECH_DEBT_PLAN.md
@@ -76,8 +76,15 @@ _Last updated: 2026-06-17. Tracks remediation work against the findings below. I
- New pure domain rule `org.iiab.controller.adb.domain.AdbShellCommand.isSafe(command)` (rejects `; | & $ \` ( ) < > ' " \\`, CR/LF and control chars). Unit-tested (`AdbShellCommandTest`).
- `executeCommand` now fails closed (logs + does not open the stream) on an unsafe command; the two legitimate `settings put` / `device_config put` calls are unaffected.
-**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12** (PR #16), **D11** (PR #15). **S3 reverted** (see above), **S4**. Remaining: **F15**.
-
+**F15 — OTA self-updater security + correctness, PR A** (PR `feat/ota-updater-security-redesign`)
+- The OTA updater downloaded the new APK to **public Downloads** and installed it with **no integrity check**; `DownloadManager` reports "complete" even for an HTML/text error page, so a wrong/MITM'd response was installed as garbage (the "downloaded a text file" bug), and the completion receiver was registered **EXPORTED**.
+- PR A (security + functional correctness, layered `org.iiab.controller.update` slice):
+ - `domain/` — `UpdateCheck` (version rule) + `CertDigests.sameSigner` (pure, unit-tested).
+ - `data/ApkVerifier` — the downloaded APK must be signed by the **same certificate as the running app** (public certs via `PackageManager`, no secrets); rejects MITM/tampered APKs and non-APK downloads (kills the text-file bug).
+ - `MainActivity` seam: stage the APK in the app's **private** external dir (not public Downloads); only install when `DownloadManager` status is SUCCESSFUL; **verify signature before install**; handle the API 26+ "install unknown apps" permission; register the receiver **NOT_EXPORTED**.
+- Follow-ups: **PR B** (presentation: `UpdateViewModel` + in-app download-progress UX) and a separate `network-security-config` to scope cleartext to the local box hosts (**S18**; deferred to avoid risking box connectivity).
+
+**Phase 1 — Security hardening: IN PROGRESS.** Done so far: **S1** (PR #9), **M4** (PR #10), **D6** (PR #12), **D2** (PR #13), **D12** (PR #16), **D11** (PR #15), **S4** (PR #28). **S3 reverted** (see above). Remaining: **F15** (PR A in review).
## 1. Executive summary
The Controller is functional and shows real security intent (it SHA256-audits native binaries at build time, scrubs the keystore in CI, and scopes most broadcasts). But it carries debt on four fronts that scale badly toward the README's "millions of users" goal: