diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml
index 87689763..11252ce3 100644
--- a/.github/workflows/release-apk.yml
+++ b/.github/workflows/release-apk.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
- description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.1)
+ description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.2)
required: true
type: string
prerelease:
@@ -118,14 +118,37 @@ jobs:
EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }}
EXPECTED_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }}
run: |
+ set -o pipefail
APK=app/build/outputs/apk/foss/release/mumla-foss-release.apk
- "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK"
+ "$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK" | tee "$RUNNER_TEMP/apk-signature.txt"
+ mapfile -t signer_shas < <(sed -n 's/^Signer #[0-9][0-9]* certificate SHA-256 digest: //p' "$RUNNER_TEMP/apk-signature.txt")
+ if [[ ${#signer_shas[@]} -ne 1 || ! "${signer_shas[0]}" =~ ^[0-9a-fA-F]{64}$ ]]; then
+ echo "Release APK must have exactly one reviewed signing certificate; missing or extra signers are refused." >&2
+ exit 1
+ fi
+ signer_sha=${signer_shas[0]}
+ echo "MINIMUM_APK_SIGNER_SHA256=${signer_sha^^}" >> "$GITHUB_ENV"
"$ANDROID_HOME/build-tools/36.0.0/aapt" dump badging "$APK" | tee "$RUNNER_TEMP/apk-badging.txt"
grep -F "package: name='$EXPECTED_APPLICATION_ID'" "$RUNNER_TEMP/apk-badging.txt"
grep -F "versionCode='$EXPECTED_VERSION_CODE'" "$RUNNER_TEMP/apk-badging.txt"
grep -F "versionName='$RELEASE_TAG'" "$RUNNER_TEMP/apk-badging.txt"
cp "$APK" "minimum-${RELEASE_TAG}-foss.apk"
sha256sum "minimum-${RELEASE_TAG}-foss.apk" > "minimum-${RELEASE_TAG}-foss.apk.sha256"
+ - name: Test Windows updater logic
+ shell: pwsh
+ run: |
+ $errors = $null
+ $tokens = $null
+ [System.Management.Automation.Language.Parser]::ParseFile(
+ (Resolve-Path 'scripts/update-minimum-device.ps1'),
+ [ref]$tokens,
+ [ref]$errors) | Out-Null
+ if ($errors.Count -gt 0) {
+ $errors | Format-List *
+ throw "Updater failed PowerShell AST parsing."
+ }
+ ./tools/verify-cellular-policy.ps1
+ ./tests/update-minimum-device.Tests.ps1
- name: Build temporary Wi-Fi provisioner
run: |
./gradlew -p tools/t99-wifi-provisioner :app:assembleDebug --no-daemon --stacktrace
@@ -144,26 +167,54 @@ jobs:
rm -f -- "$OUTPUT_ZIP" "$OUTPUT_ZIP.sha256"
mkdir -p "$BUNDLE_DIR/scripts" "$BUNDLE_DIR/assets"
cp "Provision Minimum Device.cmd" "$BUNDLE_DIR/"
+ cp "Update Minimum Device.cmd" "$BUNDLE_DIR/"
cp "minimum-${RELEASE_TAG}-foss.apk" "$BUNDLE_DIR/minimum-foss.apk"
+ cp "minimum-${RELEASE_TAG}-foss.apk.sha256" "$BUNDLE_DIR/minimum-foss.apk.sha256"
+ sed -i "s#minimum-${RELEASE_TAG}-foss.apk#minimum-foss.apk#" "$BUNDLE_DIR/minimum-foss.apk.sha256"
cp scripts/provision-minimum-device.ps1 "$BUNDLE_DIR/scripts/"
+ cp scripts/update-minimum-device.ps1 "$BUNDLE_DIR/scripts/"
+ cp scripts/manage-cellular.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-t99.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-t56.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-ryks.ps1 "$BUNDLE_DIR/scripts/"
cp tools/t99-wifi-provisioner/app/build/outputs/apk/debug/app-debug.apk \
"$BUNDLE_DIR/assets/t99-wifi-provisioner.apk"
cp docs/PROVISIONING_BUNDLE_README.txt "$BUNDLE_DIR/README.txt"
+ cp docs/UPDATER_RUNBOOK.md "$BUNDLE_DIR/UPDATER-README.md"
+ cp docs/CELLULAR_PROVISIONING.md "$BUNDLE_DIR/CELLULAR-README.md"
printf '%s\n' "$RELEASE_TAG" > "$BUNDLE_DIR/VERSION.txt"
+ apk_sha=$(sha256sum "$BUNDLE_DIR/minimum-foss.apk" | cut -d' ' -f1)
+ file_entries="$RUNNER_TEMP/release-manifest-files.json"
+ find "$BUNDLE_DIR" -type f -printf '%P\n' | LC_ALL=C sort | while IFS= read -r relative; do
+ jq -cn --arg path "$relative" --arg sha256 "$(sha256sum "$BUNDLE_DIR/$relative" | cut -d' ' -f1 | tr '[:lower:]' '[:upper:]')" \
+ '{path:$path,sha256:$sha256}'
+ done | jq -s . > "$file_entries"
+ jq -n \
+ --arg releaseTag "$RELEASE_TAG" \
+ --argjson versionCode '${{ inputs.expected_version_code }}' \
+ --arg apkSha256 "${apk_sha^^}" \
+ --arg signerSha256 "$MINIMUM_APK_SIGNER_SHA256" \
+ --slurpfile files "$file_entries" \
+ '{schemaVersion:1,releaseTag:$releaseTag,applicationId:"se.lublin.mumla",versionCode:$versionCode,versionName:$releaseTag,apkFile:"minimum-foss.apk",apkSha256:$apkSha256,signerSha256:$signerSha256,rebootRequired:false,migrations:[{id:"CELLULAR_POLICY_V1_T56",fromVersionCodeMax:3070300,toVersionCode:3070301,profiles:["T56"],rebootRequired:true,irreversible:false}],files:$files[0]}' \
+ > "$BUNDLE_DIR/RELEASE-MANIFEST.json"
expected_files="$RUNNER_TEMP/provisioning-bundle-expected-files.txt"
expected_dirs="$RUNNER_TEMP/provisioning-bundle-expected-dirs.txt"
printf '%s\n' \
"$BUNDLE_NAME/Provision Minimum Device.cmd" \
+ "$BUNDLE_NAME/Update Minimum Device.cmd" \
"$BUNDLE_NAME/README.txt" \
+ "$BUNDLE_NAME/CELLULAR-README.md" \
+ "$BUNDLE_NAME/RELEASE-MANIFEST.json" \
+ "$BUNDLE_NAME/UPDATER-README.md" \
"$BUNDLE_NAME/VERSION.txt" \
"$BUNDLE_NAME/minimum-foss.apk" \
+ "$BUNDLE_NAME/minimum-foss.apk.sha256" \
"$BUNDLE_NAME/scripts/prepare-ryks.ps1" \
+ "$BUNDLE_NAME/scripts/manage-cellular.ps1" \
"$BUNDLE_NAME/scripts/prepare-t56.ps1" \
"$BUNDLE_NAME/scripts/prepare-t99.ps1" \
"$BUNDLE_NAME/scripts/provision-minimum-device.ps1" \
+ "$BUNDLE_NAME/scripts/update-minimum-device.ps1" \
"$BUNDLE_NAME/assets/t99-wifi-provisioner.apk" > "$expected_files"
sort -o "$expected_files" "$expected_files"
printf '%s\n' \
@@ -194,7 +245,7 @@ jobs:
echo "Credential-like content pattern found in staged text file (filename only): ${text_file#$BUNDLE_DIR/}"
exit 1
fi
- done < <(find "$text_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' \) -print)
+ done < <(find "$text_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print)
done
(
cd "$RUNNER_TEMP"
@@ -243,7 +294,9 @@ jobs:
echo "Credential-like content pattern found in extracted text file (filename only): ${text_file#$extracted_root/}"
exit 1
fi
- done < <(find "$extracted_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' \) -print)
+ done < <(find "$extracted_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print)
+ pwsh -NoLogo -NoProfile -Command \
+ ". '$extracted_root/scripts/update-minimum-device.ps1' -LibraryOnly; Read-ReleaseBundle -Root '$extracted_root' | Out-Null; \$identity = Get-ApkManifestIdentity -ApkPath '$extracted_root/minimum-foss.apk'; if (\$identity.ApplicationId -cne 'se.lublin.mumla' -or \$identity.VersionName -cne '$RELEASE_TAG') { throw 'Extracted updater APK identity verification failed.' }; \$signers = @(Get-ApkSignerDigests -ApkPath '$extracted_root/minimum-foss.apk'); if (\$signers.Count -ne 1 -or \$signers[0] -cne '$MINIMUM_APK_SIGNER_SHA256') { throw 'Extracted updater APK signer-set verification failed.' }"
echo "Provisioning bundle verification passed: exact allowlist, regular files, no symlinks, safe paths, staged and extracted content checks."
sha256sum "$OUTPUT_ZIP" > "$OUTPUT_ZIP.sha256"
- name: Prepare reviewed release notes
@@ -289,6 +342,8 @@ jobs:
echo "## Physical-test status and known limitations"
echo "- Physical acceptance is operator-gated; this workflow does not claim PTT, audio, room-switching or Location success without reviewed device evidence."
echo "- Provisioning PASS requires same-ID Ready after reboot; \`-SkipReboot\` is explicitly INCOMPLETE."
+ echo "- The existing-device updater verifies the exact bundle/APK/signer, preserves app data, and refuses unknown migrations or debug-to-release signer changes."
+ echo "- Updater acceptance must be recorded separately for every model/signing channel claimed; no release workflow run itself proves physical acceptance."
echo "- T56 network Location requires on-device consent within 120 seconds and remains subject to the documented manual safety boundary."
echo
echo "## Generated changes"
diff --git a/Update Minimum Device.cmd b/Update Minimum Device.cmd
new file mode 100644
index 00000000..3ae4d518
--- /dev/null
+++ b/Update Minimum Device.cmd
@@ -0,0 +1,17 @@
+@echo off
+setlocal
+title Minimum One-Shot Updater
+echo Starting Minimum device update...
+echo.
+powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\update-minimum-device.ps1"
+set "MINIMUM_UPDATE_EXIT=%ERRORLEVEL%"
+echo.
+if "%MINIMUM_UPDATE_EXIT%"=="0" (
+ echo Update window finished.
+) else (
+ echo UPDATE FAILED with exit code %MINIMUM_UPDATE_EXIT%.
+ echo Read the sanitized error above, correct it, then run this updater again.
+)
+echo.
+pause
+exit /b %MINIMUM_UPDATE_EXIT%
diff --git a/app/build.gradle b/app/build.gradle
index 19783605..3815e551 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -74,7 +74,7 @@ android {
// Remember: app_news_items_vX_Y_Z in src/main/res/values/strings.xml
// and NEWS_ITEMS in src/main/res/java/se/lublin/mumla/app/DialogUtils.java
// code:XYYZZbb (bb for build)
- versionCode 3070300
+ versionCode 3070301
versionName gitDescribe
buildConfigField "long", "TIMESTAMP", System.currentTimeMillis() + "L"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d8784c1f..50a3b52c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -163,6 +163,7 @@
+
diff --git a/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java b/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java
index e7b85d46..7e4e2165 100644
--- a/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java
+++ b/app/src/main/java/se/lublin/mumla/radio/DeviceIdentityManager.java
@@ -57,6 +57,12 @@ public String getOrCreateDeviceId() {
return generated;
}
+ /** Returns the existing identity without creating or modifying app state. */
+ public String getExistingDeviceId() {
+ String existing = preferences.getString(DEVICE_ID_PREFERENCE, null);
+ return isValidDeviceId(existing) ? existing : null;
+ }
+
/**
* Generates a new identity for an explicitly authorized administrative action.
* Callers must protect the UI/action that invokes this method.
diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java b/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java
index 40079437..3051b7e9 100644
--- a/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java
+++ b/app/src/main/java/se/lublin/mumla/radio/RadioConfigRepository.java
@@ -95,6 +95,19 @@ public JSONObject loadActiveOrDefault() throws IOException, JSONException {
return fallback;
}
+ /** Reads the active Last Known Good cache without fallback, rollback, creation, or mutation. */
+ public JSONObject loadActiveForReport() throws IOException, JSONException {
+ synchronized (CACHE_LOCK) {
+ File active = new File(cacheDirectoryForRead(), ACTIVE_FILE);
+ if (!active.isFile()) {
+ throw new IOException("active radio config is missing");
+ }
+ JSONObject cached = readJson(active);
+ validateCompleteConfig(cached, null);
+ return cached;
+ }
+ }
+
/**
* Fetches and merges default, model and optional device configuration. The result is staged as
* pending and cannot replace the Last Known Good active config until the radio proves it works.
@@ -513,6 +526,14 @@ private File cacheDirectory() {
return directory;
}
+ private File cacheDirectoryForRead() throws IOException {
+ File directory = new File(context.getFilesDir(), "radio-config");
+ if (!directory.isDirectory()) {
+ throw new IOException("radio config cache is missing");
+ }
+ return directory;
+ }
+
private void writePendingLocked(JSONObject config) throws IOException {
File directory = cacheDirectory();
File pending = new File(directory, PENDING_FILE);
diff --git a/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java b/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java
index 22a59ff5..3a783fa6 100644
--- a/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java
+++ b/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java
@@ -12,14 +12,19 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.Locale;
+import se.lublin.mumla.Settings;
import se.lublin.mumla.service.MumlaService;
/** Narrow, shell-permission-protected ADB entry point for managed radio provisioning. */
@@ -28,6 +33,8 @@ public final class RadioProvisionReceiver extends BroadcastReceiver {
"se.lublin.mumla.action.PROVISION_DEVICE_PROFILE";
public static final String ACTION_REPORT_IDENTITY =
"se.lublin.mumla.action.PROVISION_REPORT_IDENTITY";
+ public static final String ACTION_REPORT_EXISTING_IDENTITY =
+ "se.lublin.mumla.action.PROVISION_REPORT_EXISTING_IDENTITY";
public static final String ACTION_REPORT_STATUS =
"se.lublin.mumla.action.PROVISION_REPORT_STATUS";
public static final String ACTION_INSTALL_RADIO_CONFIG =
@@ -59,6 +66,11 @@ public void onReceive(Context context, Intent intent) {
PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId();
setResultCode(-1);
setResultData(deviceId);
+ } else if (ACTION_REPORT_EXISTING_IDENTITY.equals(intent.getAction())) {
+ String deviceId = new DeviceIdentityManager(
+ PreferenceManager.getDefaultSharedPreferences(context)).getExistingDeviceId();
+ setResultCode(deviceId == null ? 0 : -1);
+ setResultData(deviceId == null ? "unavailable" : deviceId);
} else if (ACTION_REPORT_STATUS.equals(intent.getAction())) {
reportProvisioningStatus(context);
} else if (ACTION_INSTALL_RADIO_CONFIG.equals(intent.getAction())) {
@@ -73,25 +85,55 @@ private void reportProvisioningStatus(Context context) {
setResultCode(0);
setResultData("unavailable");
try {
- String deviceId = new DeviceIdentityManager(
- PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId();
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+ String deviceId = new DeviceIdentityManager(preferences).getExistingDeviceId();
+ if (deviceId == null) {
+ return;
+ }
RadioConfigRepository repository = new RadioConfigRepository(context);
- org.json.JSONObject active = repository.loadActiveOrDefault();
+ org.json.JSONObject active = repository.loadActiveForReport();
String activeDeviceId = active.optString("deviceId", "");
int configVersion = active.optInt("configVersion", -1);
+ String selectedChannel = preferences.getString("radio_selected_channel_id", "");
+ String activeConfigDigest = sha256(active.toString());
+ String safeSettingsDigest = sha256(String.format(Locale.US,
+ "%s|%s|%s|%s|%s|%s|%s|%s",
+ preferences.getString(Settings.PREF_INPUT_METHOD, ""),
+ preferences.getBoolean(Settings.PREF_PTT_TOGGLE, false),
+ preferences.getBoolean(Settings.PREF_AUTO_RECONNECT, false),
+ preferences.getBoolean(Settings.PREF_PREPROCESSOR_ENABLED, false),
+ preferences.getBoolean(Settings.PREF_HALF_DUPLEX, false),
+ preferences.getBoolean(Settings.PREF_USE_TTS, false),
+ preferences.getBoolean(Settings.PREF_PTT_SOUND, false),
+ preferences.getInt(Settings.PREF_PUSH_KEY, -1)));
setResultCode(-1);
setResultData(String.format(Locale.US,
- "deviceId=%s;activeDeviceId=%s;configVersion=%d;pending=%s;lastSuccessMs=%d",
+ "deviceId=%s;activeDeviceId=%s;configVersion=%d;pending=%s;lastSuccessMs=%d;"
+ + "selectedChannel=%s;activeConfigSha256=%s;safeSettingsSha256=%s",
deviceId,
activeDeviceId,
configVersion,
repository.hasPending() ? "true" : "false",
- RadioConfigUpdater.getLastSuccess(context)));
- } catch (IOException | RuntimeException | org.json.JSONException ignored) {
+ RadioConfigUpdater.getLastSuccess(context),
+ selectedChannel,
+ activeConfigDigest,
+ safeSettingsDigest));
+ } catch (IOException | RuntimeException | org.json.JSONException
+ | NoSuchAlgorithmException ignored) {
// Status intentionally contains no config fields, endpoints or room data.
}
}
+ static String sha256(String value) throws NoSuchAlgorithmException {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder output = new StringBuilder(64);
+ for (byte item : digest) {
+ output.append(String.format(Locale.US, "%02X", item & 0xff));
+ }
+ return output.toString();
+ }
+
private void updateAprsObjectName(Context context, String objectName) {
setResultCode(0);
setResultData("rejected");
diff --git a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java
index 2d956f92..3ec27ac6 100644
--- a/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java
+++ b/app/src/main/java/se/lublin/mumla/radio/tracking/AprsTrackingManager.java
@@ -80,8 +80,11 @@ public void onSignalStrengthsChanged(SignalStrength signalStrength) {
: signalStrengthDbm(signalStrength);
mobileRssiDbm = dbm == 0 || dbm == Integer.MAX_VALUE
? AprsHealthSnapshot.UNKNOWN : dbm;
+ mobileRssiElapsedRealtime = mobileRssiDbm == AprsHealthSnapshot.UNKNOWN
+ ? 0L : SystemClock.elapsedRealtime();
} catch (RuntimeException ignored) {
mobileRssiDbm = AprsHealthSnapshot.UNKNOWN;
+ mobileRssiElapsedRealtime = 0L;
}
}
};
@@ -112,6 +115,7 @@ public void onStatusChanged(String provider, int status, android.os.Bundle extra
private volatile AprsTrackingConfig config = AprsTrackingConfig.disabled();
private volatile boolean stopped;
private volatile int mobileRssiDbm = AprsHealthSnapshot.UNKNOWN;
+ private volatile long mobileRssiElapsedRealtime;
private TelephonyManager telephonyManager;
private AprsBeaconCoordinator.MovementState requestedState;
@@ -278,7 +282,7 @@ private void sendReady() {
final String packetObjectName = objectName;
final String packet;
try {
- String healthComment = AprsHealthSnapshot.capture(context, mobileRssiDbm)
+ String healthComment = AprsHealthSnapshot.capture(context, freshMobileRssiDbm())
.toAprsComment(beacon.getMovementState(), beacon.getFix().getAccuracyMeters());
packet = AprsPacketEncoder.encodeObject(packetConfig.getSourceCallsign(), packetObjectName,
beacon.getFix(), APRS_SYMBOL_TABLE,
@@ -421,6 +425,8 @@ private void startMobileSignalListener() {
if (telephonyManager == null) {
return;
}
+ mobileRssiDbm = AprsHealthSnapshot.UNKNOWN;
+ mobileRssiElapsedRealtime = 0L;
try {
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_SIGNAL_STRENGTHS);
} catch (SecurityException ignored) {
@@ -429,23 +435,38 @@ private void startMobileSignalListener() {
}
private static int signalStrengthDbm(SignalStrength signalStrength) {
+ Integer reflectedDbm = null;
try {
java.lang.reflect.Method method = SignalStrength.class.getMethod("getDbm");
Object value = method.invoke(signalStrength);
if (value instanceof Integer) {
- return (Integer) value;
+ reflectedDbm = (Integer) value;
}
} catch (Exception ignored) {
// API-22 has no public getDbm method on all vendor builds.
}
try {
int asu = signalStrength.getGsmSignalStrength();
- return asu >= 0 && asu < 32 ? -113 + (2 * asu) : AprsHealthSnapshot.UNKNOWN;
+ return normalizeSignalDbm(reflectedDbm, asu);
} catch (RuntimeException ignored) {
return AprsHealthSnapshot.UNKNOWN;
}
}
+ static int normalizeSignalDbm(Integer reflectedDbm, int gsmAsu) {
+ if (reflectedDbm != null && reflectedDbm >= -140 && reflectedDbm <= -40) {
+ return reflectedDbm;
+ }
+ return gsmAsu >= 0 && gsmAsu < 32
+ ? -113 + (2 * gsmAsu) : AprsHealthSnapshot.UNKNOWN;
+ }
+
+ private int freshMobileRssiDbm() {
+ long age = SystemClock.elapsedRealtime() - mobileRssiElapsedRealtime;
+ return mobileRssiElapsedRealtime > 0L && age >= 0L && age <= 2L * 60L * 1000L
+ ? mobileRssiDbm : AprsHealthSnapshot.UNKNOWN;
+ }
+
private void stopMobileSignalListener() {
if (telephonyManager == null) {
return;
@@ -455,6 +476,8 @@ private void stopMobileSignalListener() {
} catch (SecurityException ignored) {
// Nothing to unregister when phone state permission was revoked.
}
+ mobileRssiDbm = AprsHealthSnapshot.UNKNOWN;
+ mobileRssiElapsedRealtime = 0L;
}
private void schedulePoll(long delayMillis) {
diff --git a/app/src/test/java/se/lublin/mumla/radio/RadioProvisionReceiverTest.java b/app/src/test/java/se/lublin/mumla/radio/RadioProvisionReceiverTest.java
new file mode 100644
index 00000000..282e0188
--- /dev/null
+++ b/app/src/test/java/se/lublin/mumla/radio/RadioProvisionReceiverTest.java
@@ -0,0 +1,14 @@
+package se.lublin.mumla.radio;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class RadioProvisionReceiverTest {
+ @Test
+ public void snapshotDigestIsStableAndDoesNotExposeInput() throws Exception {
+ assertEquals(
+ "3200E947DB45B2FF41CF51B02139F2F97130112557D9946659A9BC1952A7FDCE",
+ RadioProvisionReceiver.sha256("managed-safe-state"));
+ }
+}
diff --git a/app/src/test/java/se/lublin/mumla/radio/tracking/AprsTrackingManagerSignalTest.java b/app/src/test/java/se/lublin/mumla/radio/tracking/AprsTrackingManagerSignalTest.java
new file mode 100644
index 00000000..6f42f7a5
--- /dev/null
+++ b/app/src/test/java/se/lublin/mumla/radio/tracking/AprsTrackingManagerSignalTest.java
@@ -0,0 +1,25 @@
+package se.lublin.mumla.radio.tracking;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class AprsTrackingManagerSignalTest {
+ @Test
+ public void prefersValidVendorDbmForLteWithUnknownGsmAsu() {
+ assertEquals(-98, AprsTrackingManager.normalizeSignalDbm(-98, 99));
+ }
+
+ @Test
+ public void convertsValidGsmAsuWhenVendorDbmIsInvalid() {
+ assertEquals(-103, AprsTrackingManager.normalizeSignalDbm(0, 5));
+ }
+
+ @Test
+ public void rejectsUnknownAndImpossibleValues() {
+ assertEquals(AprsHealthSnapshot.UNKNOWN,
+ AprsTrackingManager.normalizeSignalDbm(Integer.MAX_VALUE, 99));
+ assertEquals(AprsHealthSnapshot.UNKNOWN,
+ AprsTrackingManager.normalizeSignalDbm(-160, -1));
+ }
+}
diff --git a/docs/CELLULAR_PROVISIONING.md b/docs/CELLULAR_PROVISIONING.md
new file mode 100644
index 00000000..d2924f48
--- /dev/null
+++ b/docs/CELLULAR_PROVISIONING.md
@@ -0,0 +1,68 @@
+# Managed cellular policy
+
+The T56 provisioning workflow applies a guarded cellular policy before its final connectivity
+checks and the one-shot provisioner verifies it again after reboot. The policy is deliberately
+limited to the commissioned `UNIPRO/ZX`, Android API 22, build `T56`, L811 modem family. Unknown
+hardware or firmware is rejected before any cellular setting can be changed.
+
+## Policy and cost warning
+
+- Data Roaming defaults to enabled. Roaming can incur carrier charges.
+- Pass `-DisableDataRoaming` to `prepare-t56.ps1` or `provision-minimum-device.ps1` when the SIM
+ agreement prohibits roaming. The opt-out writes and verifies the disabled value.
+- Mobile data is enabled and read back.
+- The commissioned LTE-capable automatic mode with legacy fallback is preserved. LTE-only and
+ unknown modes are unsafe. Android API 22's `Settings.Global` database does not prove that a modem
+ accepted a preferred-mode write, so this workflow does not rewrite the numeric mode or claim it
+ was applied. The accepted T56 reports symbolic
+ `LTE/TDSCDMA/CDMA/EVDO/GSM/WCDMA automatic` (OEM value 22); any other mode is `WARN` and requires a
+ separately verified OEM/telephony control path. Never copy that numeric value to another build.
+- `manage-cellular.ps1 -VerifyOnly` makes no change and is suitable for post-reboot checks.
+
+The report contains model/build, symbolic radio mode, SIM readiness, service state, voice/data RAT,
+roaming state, data state, sanitized signal value/source, and only the status of the selected APN.
+It does not query or print IMEI, IMSI, ICCID, phone number, APN name, APN credentials, or exact cell
+identity. The script requests only the non-secret preferred-APN row identifier. The shell cannot
+read even that projection on the commissioned firmware, so access unavailable is a distinct
+warning rather than reading or guessing APN fields.
+
+## Outcomes and bounded recovery
+
+`PASS` means the setting readbacks, SIM, registration, safe preferred mode, mobile-data policy, APN
+status, and cellular route were verifiable. `WARN` accepts registered 3G/2G fallback, an inactive
+cellular route while another transport is active, an unsafe/unverifiable preferred-mode boundary,
+or OEM-restricted APN inspection. `FAIL` covers a SIM that is not ready, a roaming/mobile-data
+readback mismatch, disabled mobile data, or missing cellular service.
+
+If registration is stale, perform at most one controlled airplane-mode re-registration or reboot,
+then run the verifier again. Do not loop, force LTE-only, overwrite carrier APNs, clear Minimum app
+data, or change device/subscriber identity. Carrier APN changes and band/entitlement claims require
+operator documentation or sanitized modem evidence.
+
+Rollback is explicit: rerun with `-DisableDataRoaming` if roaming must be off. Restore a previously
+recorded preferred-network mode only when its symbolic meaning is verified for the exact firmware;
+do not restore LTE-only or an unknown numeric mode.
+
+## T56 acceptance record (sanitized, 2026-08-13)
+
+- Hardware/software: UNIPRO/ZX, Android 5.1.1/API 22, build T56, baseband
+ `LANSUS1-L811V0.00.01`; installed Minimum was an `e3657bf7` debug descendant before update.
+- SIM/operator: SIM ready, TRUE-H; SIM product class and entitlement were not exposed by Android
+ shell and therefore remain unknown. No subscriber or cell identifiers were recorded.
+- Initial state: Data Roaming `1`, mobile data `1`, automatic LTE-plus-legacy mode `22`, home/not
+ roaming, Android voice/data RAT both LTE, RSRP about -98 dBm. The cellular route was initially
+ inactive with framework reason `dataDisabled`, despite the mobile-data setting readback.
+- One controlled airplane-mode re-registration briefly restored a connected cellular route. The
+ device remained registered on LTE and reported about -100 dBm RSRP afterward. Following the
+ single acceptance reboot, the route returned to `dataDisabled` even with global and per-default-
+ subscription mobile-data settings enabled; the default-data subscription was valid and Android
+ still reported data as possible. One 45-second Wi-Fi-off probe did not recover the route, and the
+ original Wi-Fi state was restored exactly. Bounded recovery was stopped there. This establishes
+ that the old 3G symptom is not currently reproducible and that LTE registration works in the test
+ area, but usable cellular data remains `WARN` pending carrier/framework APN or entitlement
+ diagnosis. It does not prove why an earlier session stayed on 3G.
+- Selected APN inspection is denied to the ADB shell by this OEM provider. The provisioner therefore
+ returns `WARN`, suppresses APN identity/credentials, and does not overwrite carrier APNs.
+- Roaming/network mode readback was idempotent before and after re-registration. Reboot persistence
+ is recorded separately with the install candidate because the same reboot validates the updated
+ telemetry behavior without adding an unnecessary second reboot.
diff --git a/docs/GITHUB_RELEASE_WORKFLOW.md b/docs/GITHUB_RELEASE_WORKFLOW.md
index fc015c12..5b0f0b8a 100644
--- a/docs/GITHUB_RELEASE_WORKFLOW.md
+++ b/docs/GITHUB_RELEASE_WORKFLOW.md
@@ -109,9 +109,10 @@ The manual `.github/workflows/release-apk.yml` workflow checks out an existing n
tag, requires the reviewed Android `versionCode`, builds `:app:assembleFossRelease`, verifies the
package/version/signature, creates SHA-256 files and publishes the tagged GitHub Release. The same
release also contains `minimum-provisioning-.zip`, a standalone Windows bundle with the signed
-APK, double-click launcher, guarded T99/T56 scripts, prebuilt temporary Wi-Fi helper and operator
-README. The bundle uses the included APK/helper and does not require a source checkout or Gradle on
-the field workstation. Its
+APK, provisioning/updater launchers, guarded T99/T56 scripts including the cellular migration,
+prebuilt temporary Wi-Fi helper, updater README and cellular-policy README. The manifest and
+workflow share an exact reviewed file allowlist. The bundle uses the included APK/helper and does
+not require a source checkout or Gradle on the field workstation. Its
protected `release` environment must provide:
- `MINIMUM_RELEASE_KEYSTORE_BASE64`
diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md
index 467581bc..d43329d2 100644
--- a/docs/PROJECT_STATUS.md
+++ b/docs/PROJECT_STATUS.md
@@ -1,6 +1,6 @@
# Minimum project status (source of truth)
-Last reviewed: 2026-08-12
+Last reviewed: 2026-08-13
This is the canonical hand-off document for the `awatchar/minimum` public PoC. If another
document disagrees with this file, verify the code and update this file first.
@@ -16,6 +16,20 @@ document disagrees with this file, verify the code and update this file first.
- Working branch: `agent/minimum-foundation`
- Draft PR: https://github.com/awatchar/minimum/pull/1
- Android application ID: `se.lublin.mumla`
+- The integrated Issue #11/#12 release candidate uses versionCode `3070301`; the compatible next
+ Git-derived release tag/versionName is `3.7.3-minimum.2`. This is a preliminary integration
+ candidate only: it has not been tagged, published or accepted on hardware.
+- The existing-device updater now fail-closes on exact bundle/APK identity, requires Android Build
+ Tools `apksigner` for full signature verification and exactly one reviewed signer, preserves Device ID, selected channel, active
+ Last Known Good config digest and managed safe-settings digest, and identity-correlates a device
+ after reboot. The one-time legacy `3070300` bridge first tries a non-creating `run-as` read of the
+ existing app-private public ID. Its non-debuggable fallback requires the existing RadioShell to
+ already be focused and package-bound Ready in a fresh UI hierarchy; the updater never starts it,
+ persists raw UI XML or calls a receiver without that proof. It then records newly available expanded fields as bootstrapped rather than
+ falsely preserved. The versioned `CELLULAR_POLICY_V1_T56` migration applies only from versionCode
+ `3070300` or older to `3070301`, is T56-only/reversible, and requires post-reboot verify-only
+ evidence. Cellular WARN remains overall WARN; no modem-mode mutation or cellular-ready PASS is
+ claimed from a Settings database readback.
- Current supported build target: FOSS debug APK
- Local FOSS release assembly and release Lint now pass, but the output is not an approved public
release until the application ID/signing identity, protected GitHub environment, tagged workflow
diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt
index d7c51d50..85b37e1b 100644
--- a/docs/PROVISIONING_BUNDLE_README.txt
+++ b/docs/PROVISIONING_BUNDLE_README.txt
@@ -1,7 +1,37 @@
Minimum device provisioning bundle
==================================
-This bundle prepares one supported T99, T56 or RYKS radio on Windows.
+This bundle provisions or updates one supported T99, T56 or RYKS radio on Windows.
+
+Choose the correct workflow
+---------------------------
+
+- New, reset or unregistered radio: double-click "Provision Minimum Device.cmd".
+- Already-provisioned radio with an existing Device ID and managed config: double-click
+ "Update Minimum Device.cmd".
+
+The updater is deliberately separate. It does not rerun model provisioning, remove apps, rewrite
+Wi-Fi, reopen Location consent or require Portal registration. It verifies the Release manifest,
+all bundle file hashes, APK checksum/package/version/exactly-one-reviewed-signer, installed signer compatibility,
+identity/config preservation and Ready. Read "UPDATER-README.md" in this bundle for advanced
+modes and recovery guidance.
+
+For the one-time 3070300-to-3070301 compatibility bridge, a non-creating Android run-as probe must
+first read only the existing public Device ID from app-private preferences. If run-as is unavailable,
+the operator must wake/unlock and manually open the existing RadioShell Ready screen. The updater
+does not start it: it requires the activity already focused plus package-bound minimum-state-ready
+in a fresh UI hierarchy, records LEGACY_READY_UI and a safe Channel baseline, then deletes the
+temporary UI file without reporting raw XML. Wrong-package, unfocused or non-Ready evidence fails
+before any legacy receiver. Status and identity are called only after either proof and must agree.
+Fields unavailable in 3070300 are marked BOOTSTRAPPED_POST_UPDATE after the new
+expanded report; they are not falsely reported as preserved from the legacy baseline.
+
+The bundle also includes "CELLULAR-README.md" and "scripts\manage-cellular.ps1". On the reviewed
+3.7.3-minimum.2 / versionCode 3070301 update, T56 devices crossing from versionCode 3070300 or
+older receive the exact CELLULAR_POLICY_V1_T56 migration and post-reboot verification. T99 and
+RYKS do not receive that model-specific setting change. Cellular readiness may remain WARN when
+the OEM blocks APN inspection or the carrier route is unavailable; read the cellular guide before
+accepting that limitation. Data Roaming can incur carrier charges.
Supported hardware identities
-----------------------------
@@ -17,6 +47,8 @@ Requirements
- Windows 10 or Windows 11
- Android Platform Tools (adb.exe) available in PATH
+- Android Build Tools apksigner available in PATH, ANDROID_HOME/ANDROID_SDK_ROOT, or the standard
+ local Android SDK; the updater refuses installation when full signature verification is absent
- Internet access to https://minimum.vra.or.th/
- A Minimum Portal administrator account
- USB debugging enabled and authorized on the radio
@@ -68,12 +100,22 @@ Security and safety
- PASS requires managed config activation and Ready both before and after reboot.
- Ready messages before reboot are checkpoints only; the sole final PASS is emitted after the
returning unit is identified and reaches Ready with the same Device ID.
+- Verify the separately published ZIP checksum before extraction. The updater also verifies the
+ exact in-bundle manifest/allowlist/checksums and APK identity/signer. An existing operator
+ workstation is supported; verification does not require wiping or rebuilding it.
+- The updater never uninstalls Minimum, clears app data, transmits PTT, exports app data, or stores
+ Android/USB/subscriber identifiers in its sanitized reports.
An existing debug-signed Minimum APK cannot be upgraded in place by the release-signed APK. The
installer stops on a signature mismatch rather than clearing app data automatically. Preserve any
required device identity/config information and perform an explicitly approved uninstall before
switching a lab device from debug signing to release signing.
+For updater testing on an existing debug-signed radio, use two reviewed versions signed by the
+same debug key and update in place without reset. Do not attempt a Release-signed installation on
+that device. This proves only the debug-channel updater path; it does not prove Release-signature
+acceptance.
+
Checksum verification
---------------------
diff --git a/docs/T56_DEVICE_PROFILE.md b/docs/T56_DEVICE_PROFILE.md
index ae1194ac..307b589a 100644
--- a/docs/T56_DEVICE_PROFILE.md
+++ b/docs/T56_DEVICE_PROFILE.md
@@ -1,5 +1,9 @@
# T56 device profile
+Managed cellular provisioning and its roaming-cost opt-out are documented in
+[CELLULAR_PROVISIONING.md](CELLULAR_PROVISIONING.md). T56 uses an automatic LTE-capable mode with
+legacy fallback; LTE-only is never provisioned.
+
Last captured: 2026-08-07
T56 is the project name for the UNIPRO/ZX Android PTT radio identified below. It is the maintained
diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md
index d84fbbf4..4c83d074 100644
--- a/docs/TEST_MATRIX.md
+++ b/docs/TEST_MATRIX.md
@@ -4,7 +4,9 @@
|---|---|---|
| FOSS debug unit tests | PASS | `:app:testFossDebugUnitTest` |
| FOSS debug APK build | PASS | `:app:assembleFossDebug` |
-| FOSS release APK assembly | PASS LOCALLY / UNSIGNED | `:app:assembleFossRelease`; release-only Lint passes after removing invalid redundant `noBackup` XML domains. Signing and tagged GitHub provenance remain open. |
+| FOSS release APK assembly | PASS LOCALLY / UNSIGNED | `:app:assembleFossRelease`; signing and tagged GitHub provenance remain open. |
+| Android full Lint | FAIL (PRE-EXISTING BASELINE) | Clean `lintFossDebug` and `lintFossRelease` each report 32 errors/343 warnings across legacy permissions, receiver flags, layouts, locale plurals and other existing code. The first is the unchanged `AprsTrackingManager.removeUpdates` permission finding. No Lint baseline is committed. |
+| Existing-device updater integration | PASS IN STATIC/AUTOMATED TESTS / PHYSICAL OPEN | PowerShell 5.1 AST and 30 policy/fixture/state-machine tests cover non-creating legacy run-as and focused Ready-UI proof, wrong-package/not-Ready/unfocused refusal before receivers, signer/no-mutation, Linux/Windows `apksigner` discovery, recovery, model routing, reboot correlation and partial sessions; cellular verifier, exact workflow allowlists and full `apksigner` contract also pass. E7ROW7 same-debug-signer update and T99/RYKS physical acceptance remain open. |
| GitHub Actions integrated CI | PASS | Run `31306714812` on commit `6ee5c5e6`: Android unit tests/debug APK/unsigned release assembly and Portal tests/type-check/production build all passed. |
| T99 ADB install | PASS | T99 serial `12344321` |
| T99 Device ID format/persistence | PASS | `DeviceIdentityManagerTest`; startup integration added |
diff --git a/docs/UPDATER_RUNBOOK.md b/docs/UPDATER_RUNBOOK.md
new file mode 100644
index 00000000..617977d7
--- /dev/null
+++ b/docs/UPDATER_RUNBOOK.md
@@ -0,0 +1,128 @@
+# Minimum existing-device updater
+
+`Update Minimum Device.cmd` is for a supported radio that already has Minimum identity and an
+active managed configuration. Use `Provision Minimum Device.cmd` for a factory-reset, new or
+unregistered radio. The updater never registers a device in the Portal, reprovisions Wi-Fi,
+removes OEM apps, reopens Location consent or reapplies unrelated device settings.
+
+## Requirements and trust boundary
+
+- Windows 10 or 11, Android Platform Tools (`adb.exe`), and Android Build Tools `apksigner`.
+ The updater finds `apksigner` in `PATH`, `ANDROID_HOME`/`ANDROID_SDK_ROOT`, or the standard local
+ Android SDK. This fail-closed dependency is required for full APK signature verification.
+- One complete, extracted `minimum-provisioning-.zip` from a reviewed GitHub Release.
+- The separately published ZIP `.sha256` must match before extraction. The updater then verifies
+ the exact file allowlist and hashes in `RELEASE-MANIFEST.json`, the APK checksum file, binary APK
+ package/version, and APK signer. This makes bundle verification repeatable on an operator's
+ existing workstation; a freshly installed or otherwise "clean" workstation is not required.
+- USB debugging must be enabled and authorized. The normal path accepts exactly one device in the
+ Android `device` state. Offline, unauthorized, recovery, unknown and ambiguous targets stop
+ before mutation.
+
+No source checkout, Gradle, Android Studio, signing key, Portal login or app-data export is used.
+Do not use a bundle whose ZIP checksum does not match the checksum on its exact Release.
+
+## Normal update
+
+1. Verify the downloaded ZIP SHA-256, then extract the entire ZIP.
+2. Connect one already-provisioned supported radio and authorize USB debugging.
+3. Ensure it is not transmitting, then double-click `Update Minimum Device.cmd`.
+4. Type `UPDATE` only after checking the physical radio is not transmitting.
+5. Keep it connected until `PASS` or an actionable `FAIL` appears.
+
+The updater inventories battery/power, supported model, installed version, Device ID, managed
+configuration and Ready state. It requires the installed APK and bundled APK each to have exactly
+the one reviewed signing certificate before any installation; extra signers fail closed. It rejects an unintended downgrade. It uses only an in-place
+`adb install -r` (or explicitly authorized `-r -d`) and contains no uninstall or clear-data path.
+
+`PASS` means the exact target package/version is installed and the original Device ID, non-pending
+managed configuration, last-known-good evidence and Ready state were verified. If the manifest
+requires reboot, or `-FullRebootAcceptance` is requested, PASS also requires the same supported
+profile and Device ID to return to Ready after reboot. `ALREADY_OK` is a successful idempotent
+recheck of an already-installed exact version.
+
+For `3.7.3-minimum.2` / versionCode `3070301`, an upgrade from versionCode `3070300` or older on
+T56 also runs the exact reversible `CELLULAR_POLICY_V1_T56` migration before APK replacement. It
+applies the guarded roaming/mobile-data/automatic-LTE policy, then requires a reboot and verifies
+the same policy again. A carrier/APN readiness warning produces overall `WARN`, not a false PASS.
+T99 and RYKS skip this model-gated migration.
+
+The `3070300` receiver exposes only the five-field legacy status and its legacy actions can create
+identity. For this single `3070300`-or-older to `3070301` bridge, the updater first uses Android's
+read-only `run-as` boundary to extract only the existing six-character public identity from the
+debuggable app's private preferences, without invoking application code or printing the preference
+file. If `run-as` is unavailable, it does not start Minimum. Instead, the operator must already have
+woken/unlocked the radio and manually opened the existing RadioShell Ready screen. The updater then
+requires that exact activity to be focused and a fresh UI hierarchy to contain package-bound
+`minimum-state-ready`; it records proof mode `LEGACY_READY_UI` and a safe `Channel ` baseline
+when present. The temporary device-side hierarchy is removed immediately and its raw XML is never
+saved in a report. Wrong-package, unfocused, screen-off or non-Ready evidence fails with
+`LEGACY_NONCREATING_PROBE_UNAVAILABLE` before any receiver. After either proof, status must also
+prove a non-pending active LKG and the legacy status/identity results must match each other (and the
+private ID when `run-as` supplied one).
+Selected channel, LKG digest and safe-settings digest are
+recorded as `UNAVAILABLE_LEGACY`; after installation the new expanded report is mandatory and those
+fields are marked `BOOTSTRAPPED_POST_UPDATE`, not falsely claimed as pre/post preservation proof.
+Subsequent updates use the expanded report and require exact preservation of all reported fields.
+
+## Safe advanced modes
+
+From PowerShell, optional modes include:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\update-minimum-device.ps1 -ReportOnly
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\update-minimum-device.ps1 -WhatIf
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\update-minimum-device.ps1 -UpdateSession
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\update-minimum-device.ps1 -FullRebootAcceptance
+```
+
+`-UpdateSession` handles one physical radio at a time, requires disconnection before continuing,
+retains earlier results after a later failure, and prints totals. Use `-Serial` or `-TransportId`
+only for an intentional advanced selection. Reports are written under the current user's local app
+data by default; they include Device ID for fleet correlation but exclude Android/USB serials,
+subscriber identifiers, credentials, certificate fingerprints, private coordinates, app data and
+logs.
+
+Downgrade is intentionally refused unless `-AllowDowngrade` is supplied. That switch produces a
+prominent warning and does not bypass signer verification, change identity, uninstall Minimum or
+clear data. Use it only with an explicitly reviewed recovery plan. The report states that rollback
+is not automated; the old APK is not included in the bundle.
+
+## Failure and recovery
+
+- `SIGNER_MISMATCH`: stop. A debug-signed APK cannot be replaced in place by a differently signed
+ Release APK. The updater has made no uninstall/data-clear attempt. Preserve the device and seek an
+ explicitly reviewed migration decision.
+- `DOWNGRADE_REFUSED`: obtain the correct newer reviewed bundle; do not bypass the gate casually.
+- `BUNDLE_*`, `MANIFEST_*`, `APK_*`: discard the extraction, re-download the exact Release, verify
+ its published ZIP checksum and retry.
+- `INSUFFICIENT_STORAGE`: free non-Minimum storage and rerun. Do not clear Minimum data.
+- `IDENTITY_UNREADABLE` or `CONFIG_UNVERIFIED`: do not update. Relaunch the existing app, restore
+ connectivity if safe, and use the sanitized report for diagnosis.
+- `LEGACY_NONCREATING_PROBE_UNAVAILABLE`, `LEGACY_NOT_PROVISIONED` or `LEGACY_BRIDGE_UNSUPPORTED`:
+ no receiver or install was allowed when the non-creating probe failed;
+ wake/unlock and manually open the existing Ready RadioShell, or provision the radio through the
+ reviewed provisioning path. The updater never starts the app to manufacture this evidence.
+- `READY_TIMEOUT`, `BOOT_TIMEOUT`, `REBOOT_TARGET_AMBIGUOUS`: keep the intended unit isolated,
+ restore USB authorization/connectivity and rerun. A successful install alone is never PASS.
+- Interrupted USB: reconnect the same radio and rerun. Verification and migrations are designed to
+ report `ALREADY_OK` where the intended state is already present.
+
+Attach the generated `.txt` and matching `.json` report when requesting help. Never attach a full
+bugreport, app-data backup, raw `dumpsys`, or unsanitized ADB log.
+
+## Migration and physical-acceptance policy
+
+Migration entries are keyed by installed/target version and supported model. An unknown manifest
+migration is refused; it is not silently skipped. The reviewed `CELLULAR_POLICY_V1_T56` mapping is
+accepted only with `fromVersionCodeMax=3070300`, `toVersionCode=3070301`, profile `T56`,
+`rebootRequired=true` and `irreversible=false`. Its helper is idempotent and firmware-gated to the
+accepted UNIPRO/ZX API-22 T56/L811 combination. See `CELLULAR-README.md` for its cost warning,
+sanitized evidence, documented readiness limitation and rollback boundary.
+
+The known E7ROW7 T56 has a debug-signed build. Do not try to install a Release-signed APK on it.
+Physical updater acceptance without reset must instead use two reviewed APK versions signed by the
+same debug key: record same ID/config/Ready on version A, update in place to version B, verify the
+same ID/config/Ready, optionally reboot for same-ID Ready, then rerun version B to prove
+`ALREADY_OK`. This is debug-channel updater evidence only; it does not prove Release-signer
+acceptance or authorize a data-destructive signing-key transition.
diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md
index 1998c6d7..aca2a855 100644
--- a/docs/WORK_LOG.md
+++ b/docs/WORK_LOG.md
@@ -398,6 +398,13 @@ This short log records meaningful project milestones. Detailed code truth remain
## Prior milestones
+- Integrated the guarded T56 cellular policy and secure existing-device updater on versionCode
+ `3070301` (`3.7.3-minimum.2` tag contract). Review hardening added verify-only reboot persistence,
+ truthful cellular WARN propagation, non-secret APN projection, no unverified modem-mode writes,
+ immutable T56 hardware gates, full `apksigner` verification, read-only preservation snapshots,
+ identity-correlated reboot recovery, sanitized failure recovery and an exact release-bundle
+ allowlist. Physical updater acceptance and publication remain deliberately open.
+
- T99 hardware/ADB/input investigation and sanitized profile documentation.
- MediaSession PTT bridge and 120-second fail-safe watchdog.
- Automatic certificate generation on first run.
diff --git a/scripts/manage-cellular.ps1 b/scripts/manage-cellular.ps1
new file mode 100644
index 00000000..3ea26de7
--- /dev/null
+++ b/scripts/manage-cellular.ps1
@@ -0,0 +1,220 @@
+<#[
+.SYNOPSIS
+ Applies and verifies the managed T56 cellular-readiness policy.
+
+.DESCRIPTION
+ The command is deliberately model- and firmware-gated. It enables Data Roaming by default,
+ keeps an LTE-capable automatic mode with legacy fallback, enables mobile data, and prints a
+ sanitized PASS/WARN/FAIL report. It never prints subscriber/APN identity fields and never
+ selects LTE-only mode. Use -DisableDataRoaming for carrier policies that prohibit roaming.
+#>
+
+[CmdletBinding(SupportsShouldProcess)]
+param(
+ [string]$Serial = "",
+ [int]$TransportId = 0,
+ [int]$AdbPort = 5037,
+ [switch]$DisableDataRoaming,
+ [switch]$VerifyOnly,
+ [ValidateRange(5, 180)][int]$TimeoutSeconds = 45
+)
+
+$ErrorActionPreference = "Stop"
+$adbPath = (Get-Command adb -ErrorAction Stop).Source
+$serverArgs = @("-P", "$AdbPort")
+
+function Convert-PreferredNetworkMode {
+ param([Parameter(Mandatory)][string]$Value)
+ $modes = @{
+ "0" = @{ Name = "WCDMA/GSM automatic"; Lte = $false; Fallback = $true }
+ "1" = @{ Name = "GSM only"; Lte = $false; Fallback = $false }
+ "2" = @{ Name = "WCDMA only"; Lte = $false; Fallback = $false }
+ "3" = @{ Name = "GSM/WCDMA automatic"; Lte = $false; Fallback = $true }
+ "7" = @{ Name = "CDMA/EVDO/GSM/WCDMA automatic"; Lte = $false; Fallback = $true }
+ "8" = @{ Name = "LTE/CDMA/EVDO automatic"; Lte = $true; Fallback = $true }
+ "9" = @{ Name = "LTE/GSM/WCDMA automatic"; Lte = $true; Fallback = $true }
+ "10" = @{ Name = "LTE/CDMA/EVDO/GSM/WCDMA automatic"; Lte = $true; Fallback = $true }
+ "11" = @{ Name = "LTE only"; Lte = $true; Fallback = $false }
+ "12" = @{ Name = "LTE/WCDMA automatic"; Lte = $true; Fallback = $true }
+ # Verified on UNIPRO/ZX build T56 / API 22. Do not copy this constant to another OEM.
+ "22" = @{ Name = "LTE/TDSCDMA/CDMA/EVDO/GSM/WCDMA automatic"; Lte = $true; Fallback = $true }
+ }
+ $key = $Value.Trim()
+ if (-not $modes.ContainsKey($key)) {
+ return [pscustomobject]@{ Value = $key; Name = "unknown"; Lte = $false; Fallback = $false }
+ }
+ return [pscustomobject]@{
+ Value = $key
+ Name = $modes[$key].Name
+ Lte = $modes[$key].Lte
+ Fallback = $modes[$key].Fallback
+ }
+}
+
+function Convert-ServiceState {
+ param([string]$Text)
+ $result = [ordered]@{ InService = $false; Roaming = "unknown"; VoiceRat = "unknown"; DataRat = "unknown" }
+ if (-not $Text) { return [pscustomobject]$result }
+ $match = [regex]::Match($Text, '^\s*(\d+)\s+(\d+)\s+(home|roaming|unknown)\s+', 'IgnoreCase')
+ if ($match.Success) {
+ $result.InService = $match.Groups[1].Value -eq "0"
+ $result.Roaming = $match.Groups[3].Value.ToLowerInvariant()
+ }
+ $rat = [regex]::Match($Text, '\s([A-Z0-9_-]+)\s+([A-Z0-9_-]+)\s+CSS\s', 'IgnoreCase')
+ if ($rat.Success) {
+ $result.VoiceRat = $rat.Groups[1].Value.ToUpperInvariant()
+ $result.DataRat = $rat.Groups[2].Value.ToUpperInvariant()
+ }
+ return [pscustomobject]$result
+}
+
+function Convert-SignalStrength {
+ param([string]$Text)
+ if (-not $Text) { return "unavailable" }
+ $numbers = @([regex]::Matches($Text, '-?\d+') | ForEach-Object { [long]$_.Value })
+ # AOSP API-22 layout: LTE RSRP is item 9 (zero-based 8). Accept only physical RSRP range.
+ if ($numbers.Count -gt 8 -and $numbers[8] -ge -140 -and $numbers[8] -le -40) {
+ return "LTE RSRP $($numbers[8]) dBm (Android telephony registry)"
+ }
+ $gsmAsu = if ($numbers.Count -gt 0) { $numbers[0] } else { 99 }
+ if ($gsmAsu -ge 0 -and $gsmAsu -le 31) {
+ return "GSM $(-113 + (2 * $gsmAsu)) dBm (ASU conversion)"
+ }
+ return "unavailable"
+}
+
+$deviceLines = @(& $adbPath @serverArgs devices -l)
+if ($TransportId -gt 0) {
+ $match = @($deviceLines | Where-Object { $_ -match "\btransport_id:$TransportId\b" })
+ if ($match.Count -ne 1) { throw "Expected one authorized device on ADB transport $TransportId." }
+ $targetArgs = $serverArgs + @("-t", "$TransportId")
+} elseif ($Serial) {
+ $match = @($deviceLines | Where-Object { $_ -match "^$([regex]::Escape($Serial))\s+device\s+" })
+ if ($match.Count -ne 1) { throw "Expected one authorized device with the specified ADB serial." }
+ $targetArgs = $serverArgs + @("-s", $Serial)
+} else {
+ throw "Pin the target with -Serial or -TransportId. Automatic cellular mutation is refused."
+}
+
+function Invoke-TargetAdb {
+ param([Parameter(Mandatory)][string[]]$Arguments, [switch]$AllowFailure)
+ $output = @(& $adbPath @($targetArgs + $Arguments) 2>&1)
+ $exit = $LASTEXITCODE
+ if (-not $AllowFailure -and $exit -ne 0) { throw "Pinned-target ADB command failed (exit $exit)." }
+ return ($output | ForEach-Object { $_.ToString() })
+}
+
+function Get-Property([string]$Name) {
+ return ((Invoke-TargetAdb @("shell", "getprop", $Name)) -join "").Trim()
+}
+function Get-GlobalSetting([string]$Name) {
+ return ((Invoke-TargetAdb @("shell", "settings", "get", "global", $Name)) -join "").Trim()
+}
+function Set-GlobalSetting([string]$Name, [string]$Value) {
+ Invoke-TargetAdb @("shell", "settings", "put", "global", $Name, $Value) | Out-Null
+ $actual = Get-GlobalSetting $Name
+ if ($actual -ne $Value) { throw "Cellular setting '$Name' read back as '$actual', expected '$Value'." }
+}
+function Get-RegistryField([string]$Registry, [string]$Name) {
+ $match = [regex]::Match($Registry, "(?m)^\s*$([regex]::Escape($Name))=(.*)$")
+ if ($match.Success) { return $match.Groups[1].Value.Trim() }
+ return ""
+}
+
+$manufacturer = Get-Property "ro.product.manufacturer"
+$model = Get-Property "ro.product.model"
+$api = Get-Property "ro.build.version.sdk"
+$build = Get-Property "ro.build.display.id"
+$baseband = Get-Property "gsm.version.baseband"
+if ($manufacturer -ine "UNIPRO" -or $model -ine "ZX") {
+ throw "Unsupported hardware '$manufacturer/$model'; cellular mutation is hard-gated to UNIPRO/ZX."
+}
+if ($api -ne "22" -or $build -ne "T56" -or $baseband -notlike "LANSUS1-L811*") {
+ throw "Unverified T56 firmware (API=$api build=$build baseband=$baseband); refusing numeric network-mode mutation."
+}
+
+$originalRoaming = Get-GlobalSetting "data_roaming"
+$originalMode = Convert-PreferredNetworkMode (Get-GlobalSetting "preferred_network_mode")
+$originalMobileData = Get-GlobalSetting "mobile_data"
+$desiredRoaming = if ($DisableDataRoaming) { "0" } else { "1" }
+$settingsChanged = $false
+
+Write-Host "CELLULAR COST WARNING: Data Roaming can incur carrier charges. Use -DisableDataRoaming to opt out."
+Write-Host "Cellular target verified: UNIPRO/ZX, Android API 22, known T56 modem firmware (subscriber identifiers suppressed)."
+Write-Host "Original policy: roaming=$originalRoaming; preferred=$($originalMode.Name); mobileData=$originalMobileData."
+
+if (-not $VerifyOnly -and $PSCmdlet.ShouldProcess("pinned UNIPRO/ZX T56", "apply managed cellular policy")) {
+ if (-not $WhatIfPreference) {
+ if ((Get-GlobalSetting "data_roaming") -ne $desiredRoaming) {
+ Set-GlobalSetting "data_roaming" $desiredRoaming
+ $settingsChanged = $true
+ }
+ # Do not bounce an already-enabled data service: repeated provisioning must be inert.
+ if ((Get-GlobalSetting "mobile_data") -ne "1") {
+ Invoke-TargetAdb @("shell", "svc", "data", "enable") | Out-Null
+ $settingsChanged = $true
+ }
+ if ((Get-GlobalSetting "mobile_data") -ne "1") {
+ throw "Mobile data could not be verified enabled."
+ }
+ # API-22 Settings.Global writes do not prove the modem accepted a preferred mode. Preserve
+ # the commissioned safe automatic mode; an unsafe/unknown mode is reported below instead
+ # of claiming a database write changed the modem.
+ }
+}
+
+$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+$registry = ""
+$service = $null
+do {
+ $registry = (Invoke-TargetAdb @("shell", "dumpsys", "telephony.registry")) -join "`n"
+ $service = Convert-ServiceState (Get-RegistryField $registry "mServiceState")
+ if ($service.InService) { break }
+ Start-Sleep -Seconds 2
+} while ((Get-Date) -lt $deadline)
+
+$effectiveRoaming = Get-GlobalSetting "data_roaming"
+$effectiveMode = Convert-PreferredNetworkMode (Get-GlobalSetting "preferred_network_mode")
+$mobileData = Get-GlobalSetting "mobile_data"
+$simState = Get-Property "gsm.sim.state"
+$dataState = Get-RegistryField $registry "mDataConnectionState"
+$dataPossible = Get-RegistryField $registry "mDataConnectionPossible"
+$dataReason = Get-RegistryField $registry "mDataConnectionReason"
+$signal = Convert-SignalStrength (Get-RegistryField $registry "mSignalStrength")
+$connectivity = (Invoke-TargetAdb @("shell", "dumpsys", "connectivity")) -join "`n"
+$cellularRoute = $connectivity -match '(?is)type:\s*MOBILE.*?state:\s*CONNECTED/CONNECTED'
+$apnOutput = (Invoke-TargetAdb @("shell", "content", "query", "--uri",
+ "content://telephony/carriers/preferapn", "--projection", "_id") -AllowFailure) -join "`n"
+$apnStatus = if ($apnOutput -match '(?i)permission denial|securityexception') {
+ "unverifiable (OEM provider denies shell access)"
+} elseif ($apnOutput -match '(?m)^Row:') {
+ "selected (identity and credentials suppressed)"
+} else { "not selected or unavailable" }
+
+$failures = @()
+$warnings = @()
+if ($simState -ne "READY") { $failures += "SIM is $simState" }
+if ($effectiveRoaming -ne $desiredRoaming) { $failures += "Data Roaming readback mismatch" }
+if (-not ($effectiveMode.Lte -and $effectiveMode.Fallback)) { $warnings += "preferred mode is not safe LTE automatic/fallback" }
+if ($mobileData -ne "1") { $failures += "mobile data is disabled" }
+if (-not $service.InService) { $failures += "cellular service did not register" }
+if ($apnStatus -like 'not selected*') { $warnings += "selected APN unavailable; no APN fields were read" }
+if ($apnStatus -like 'unverifiable*') { $warnings += $apnStatus }
+if (-not ($originalMode.Lte -and $originalMode.Fallback)) {
+ $warnings += "preferred mode is unsafe/unknown; API-22 modem mutation is not safely verifiable and was not attempted"
+}
+if (-not $cellularRoute) { $warnings += "no active cellular route (dataState=$dataState reason=$dataReason possible=$dataPossible)" }
+if ($service.DataRat -notmatch 'LTE') { $warnings += "registered data RAT is $($service.DataRat), documented fallback accepted" }
+if ($signal -eq "unavailable") { $warnings += "signal unavailable/invalid; no weak-value claim made" }
+
+$outcome = if ($failures.Count) { "FAIL" } elseif ($warnings.Count) { "WARN" } else { "PASS" }
+Write-Host "Effective policy: roaming=$effectiveRoaming; preferred=$($effectiveMode.Name); mobileData=$mobileData."
+Write-Host "Cellular state: SIM=$simState; service=$(if($service.InService){'in-service'}else{'out-of-service'}); voice=$($service.VoiceRat); data=$($service.DataRat); roaming=$($service.Roaming); route=$cellularRoute."
+Write-Host "APN: $apnStatus. Signal: $signal."
+Write-Host "MIGRATION_OUTCOME: $(if ($settingsChanged) { 'APPLIED' } else { 'ALREADY_OK' })"
+if ($warnings.Count) { Write-Warning ($warnings -join "; ") }
+if ($failures.Count) { Write-Error ($failures -join "; ") -ErrorAction Continue }
+Write-Host "$outcome`: managed cellular readiness."
+if ($outcome -eq "FAIL") { exit 1 }
+if ($outcome -eq "WARN") { exit 2 }
+exit 0
diff --git a/scripts/prepare-t56.ps1 b/scripts/prepare-t56.ps1
index ebe0bf2c..01092f5d 100644
--- a/scripts/prepare-t56.ps1
+++ b/scripts/prepare-t56.ps1
@@ -19,6 +19,7 @@ param(
[switch]$SkipMinimumHome,
[switch]$SkipLabWifi,
[switch]$SkipLocation,
+ [switch]$DisableDataRoaming,
[switch]$RequestNetworkLocationConsent,
[switch]$RefreshLabWifi,
[switch]$ReportOnly,
@@ -62,6 +63,7 @@ $forward = @{
SkipMinimumHome = $SkipMinimumHome
SkipLabWifi = $SkipLabWifi
SkipLocation = $SkipLocation
+ DisableDataRoaming = $DisableDataRoaming
RequestNetworkLocationConsent = $RequestNetworkLocationConsent
RefreshLabWifi = $RefreshLabWifi
ReportOnly = $ReportOnly
diff --git a/scripts/prepare-t99.ps1 b/scripts/prepare-t99.ps1
index 956112f5..364ecdad 100644
--- a/scripts/prepare-t99.ps1
+++ b/scripts/prepare-t99.ps1
@@ -26,6 +26,7 @@ param(
[switch]$SkipMinimumHome,
[switch]$SkipLabWifi,
[switch]$SkipLocation,
+ [switch]$DisableDataRoaming,
[switch]$RequestNetworkLocationConsent,
[switch]$RefreshLabWifi,
[switch]$ReportOnly,
@@ -42,6 +43,7 @@ param(
)
$ErrorActionPreference = "Stop"
+$script:CellularReadinessWarning = $false
$PackageName = "com.loudtalks"
$MinimumPackage = "se.lublin.mumla"
$MinimumActivity = "se.lublin.mumla/.radio.RadioShellActivity"
@@ -641,6 +643,31 @@ if ($SkipLocation -and $RequestNetworkLocationConsent) {
if ($RequestNetworkLocationConsent -and $TargetName -ne "T56") {
throw "-RequestNetworkLocationConsent is supported only by the T56 provisioning flow."
}
+if ($DisableDataRoaming -and $TargetName -ne "T56") {
+ throw "-DisableDataRoaming is supported only by the T56 managed-cellular flow."
+}
+
+if ($TargetName -eq "T56") {
+ $cellularScript = Join-Path $PSScriptRoot "manage-cellular.ps1"
+ $cellularArgs = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $cellularScript,
+ "-AdbPort", "$AdbPort")
+ if ($TransportId -gt 0) {
+ $cellularArgs += @("-TransportId", "$TransportId")
+ } else {
+ $cellularArgs += @("-Serial", $Serial)
+ }
+ if ($DisableDataRoaming) { $cellularArgs += "-DisableDataRoaming" }
+ if ($ReportOnly -or $WhatIfPreference) { $cellularArgs += "-VerifyOnly" }
+ Write-Host "Applying the guarded T56 cellular-readiness policy before final connectivity checks..."
+ & powershell.exe @cellularArgs
+ $cellularExit = $LASTEXITCODE
+ if ($cellularExit -eq 2) {
+ $script:CellularReadinessWarning = $true
+ Write-Warning "T56 cellular policy verified with a readiness warning; provisioning continues with documented fallback."
+ } elseif ($cellularExit -ne 0) {
+ throw "T56 cellular readiness failed with exit code $cellularExit."
+ }
+}
$adbSerial = (& $adbPath @targetArgs get-serialno) -join ""
$systemSerial = Get-TargetProperty -Name ro.serialno
$bootSerial = Get-TargetProperty -Name ro.boot.serialno
@@ -914,3 +941,4 @@ if (-not $ReportOnly -and -not $SkipMinimumHome -and -not $WhatIfPreference) {
}
Write-Host "Preparation report complete. USB/ADB serial remains '$adbSerial'; Minimum identity is the per-device ID."
+if ($script:CellularReadinessWarning) { exit 2 }
diff --git a/scripts/provision-minimum-device.ps1 b/scripts/provision-minimum-device.ps1
index 91c9a4b5..e4872686 100644
--- a/scripts/provision-minimum-device.ps1
+++ b/scripts/provision-minimum-device.ps1
@@ -33,6 +33,7 @@ param(
[switch]$SkipMinimumHome,
[switch]$SkipLabWifi,
[switch]$SkipLocation,
+ [switch]$DisableDataRoaming,
[switch]$RequestNetworkLocationConsent,
[switch]$RefreshLabWifi,
[string]$LabWifiSsid = "..@EmergencyTU",
@@ -67,6 +68,7 @@ $serverArgs = @()
$script:targetArgs = @()
$script:targetLabel = ""
$script:targetRecord = $null
+$script:CellularReadinessWarning = $false
if ($DeviceProfile -and (($DeviceProfile -cnotmatch '^[A-Z0-9]{6}$') -or
($DeviceProfile -notmatch '[A-Z]') -or ($DeviceProfile -notmatch '\d'))) {
@@ -511,6 +513,7 @@ function Invoke-ModelPreparation {
if ($SkipMinimumHome) { $arguments += "-SkipMinimumHome" }
if ($SkipLabWifi) { $arguments += "-SkipLabWifi" }
if ($SkipLocation) { $arguments += "-SkipLocation" }
+ if ($DisableDataRoaming) { $arguments += "-DisableDataRoaming" }
if ($RequestNetworkLocationConsent) { $arguments += "-RequestNetworkLocationConsent" }
if ($RefreshLabWifi) { $arguments += "-RefreshLabWifi" }
if ($LabWifiCredentialPath) {
@@ -519,7 +522,10 @@ function Invoke-ModelPreparation {
Write-Host "Running guarded $Profile preparation..."
& powershell.exe @arguments
- if ($LASTEXITCODE -ne 0) {
+ if ($LASTEXITCODE -eq 2 -and $Profile -eq "T56") {
+ $script:CellularReadinessWarning = $true
+ Write-Warning "T56 preparation completed with a documented cellular-readiness warning."
+ } elseif ($LASTEXITCODE -ne 0) {
throw "$Profile preparation failed with exit code $LASTEXITCODE."
}
}
@@ -735,7 +741,32 @@ $returningTarget = Wait-ForReturningTarget -Manufacturer $manufacturer -Model $m
-OriginalSerial $originalSerial -TimeoutSeconds $BootTimeoutSeconds
Set-Target -Record $returningTarget
Wait-AndroidBootCompleted -TimeoutSeconds $BootTimeoutSeconds
+if ($target.Profile -eq "T56") {
+ $cellularScript = Join-Path $PSScriptRoot "manage-cellular.ps1"
+ $cellularArguments = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
+ $cellularScript, "-AdbPort", "$AdbPort")
+ if ($returningTarget.TransportId -gt 0) {
+ $cellularArguments += @("-TransportId", "$($returningTarget.TransportId)")
+ } else {
+ $cellularArguments += @("-Serial", $returningTarget.Serial)
+ }
+ if ($DisableDataRoaming) { $cellularArguments += "-DisableDataRoaming" }
+ $cellularArguments += "-VerifyOnly"
+ Write-Host "Verifying T56 cellular-policy persistence after reboot without rewriting settings..."
+ & powershell.exe @cellularArguments
+ $cellularExit = $LASTEXITCODE
+ if ($cellularExit -eq 2) {
+ $script:CellularReadinessWarning = $true
+ Write-Warning "Post-reboot cellular verification remains WARN; no persistence PASS is claimed."
+ } elseif ($cellularExit -ne 0) {
+ throw "Post-reboot T56 cellular verification failed with exit code $cellularExit."
+ }
+}
Wait-MinimumReady -Phase "after reboot" -ExpectedDeviceId $deviceId `
-TimeoutSeconds $ReadyTimeoutSeconds
+if ($script:CellularReadinessWarning) {
+ Write-Warning "WARN: $($target.Profile) Device ID $deviceId is provisioned and Ready, but cellular readiness is not fully accepted."
+ exit 2
+}
Write-Host "PASS: $($target.Profile) Device ID $deviceId is provisioned and Ready."
diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1
new file mode 100644
index 00000000..8a4f4e1c
--- /dev/null
+++ b/scripts/update-minimum-device.ps1
@@ -0,0 +1,1241 @@
+<#
+.SYNOPSIS
+ Securely updates one already-provisioned Minimum radio in place.
+
+.DESCRIPTION
+ Validates the extracted Release bundle and signed APK, selects one supported radio, verifies
+ the installed package/signer/version and managed identity, performs an in-place update, runs
+ only approved version/model-gated migrations, and verifies same-ID Ready. Reports never persist
+ Android/USB serials, subscriber identifiers, credentials, certificate fingerprints or logs.
+#>
+
+[CmdletBinding(SupportsShouldProcess = $true)]
+param(
+ [string]$Serial = "",
+ [int]$TransportId = 0,
+ [ValidateRange(0, 65535)][int]$AdbPort = 0,
+ [string]$BundleRoot = "",
+ [string]$ReportDirectory = "",
+ [switch]$UpdateSession,
+ [switch]$ReportOnly,
+ [switch]$AllowDowngrade,
+ [switch]$FullRebootAcceptance,
+ [switch]$ConfirmNotTransmitting,
+ [switch]$NonInteractive,
+ [ValidateRange(30, 900)][int]$ReadyTimeoutSeconds = 180,
+ [ValidateRange(30, 900)][int]$BootTimeoutSeconds = 180,
+ [Parameter(DontShow = $true)][switch]$LibraryOnly
+)
+
+$ErrorActionPreference = "Stop"
+$MinimumPackage = "se.lublin.mumla"
+$MinimumActivity = "se.lublin.mumla/.radio.RadioShellActivity"
+$ProvisionReceiver = "se.lublin.mumla/.radio.RadioProvisionReceiver"
+$ExistingIdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_EXISTING_IDENTITY"
+$LegacyIdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_IDENTITY"
+$ProvisionStatusAction = "se.lublin.mumla.action.PROVISION_REPORT_STATUS"
+$script:AdbExecutable = ""
+$script:ServerArguments = @()
+$script:CurrentTarget = $null
+
+function Get-DeviceProfile {
+ param([string]$Manufacturer, [string]$Model)
+ if ($Manufacturer -ieq "UNIPRO" -and $Model -ieq "ZX") { return "T56" }
+ if ($Manufacturer -ieq "Youdotech" -and $Model -ieq "QM011") { return "T99" }
+ if ($Manufacturer -ieq "ELINK" -and $Model -ieq "ym_258") { return "RYKS" }
+ return ""
+}
+
+function ConvertTo-SafeMessage {
+ param([AllowNull()][string]$Text)
+ if (-not $Text) { return "" }
+ $safe = $Text
+ $safe = [regex]::Replace($safe, '(?i)\b(serial|imei|imsi|iccid|phone|token|password|secret)\s*[=:]\s*[^\s;,]+', '$1=')
+ $safe = [regex]::Replace($safe, '(?i)\b(?:[0-9a-f]{2}:){31}[0-9a-f]{2}\b', '')
+ $safe = [regex]::Replace($safe, '(?i)\b(?:gh[pousr]_[A-Za-z0-9]{20,}|Bearer\s+[A-Za-z0-9._~-]+)\b', '')
+ return $safe
+}
+
+function Get-ErrorCategory {
+ param([string]$Message)
+ $match = [regex]::Match($Message, '^\[([A-Z0-9_]+)\]\s*')
+ if ($match.Success) { return $match.Groups[1].Value }
+ return "UNEXPECTED_FAILURE"
+}
+
+function Throw-UpdateError {
+ param([Parameter(Mandatory)][string]$Code, [Parameter(Mandatory)][string]$Message)
+ throw "[$Code] $Message"
+}
+
+function Get-FileSha256 {
+ param([Parameter(Mandatory)][string]$Path)
+ return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToUpperInvariant()
+}
+
+function Test-Sha256Value {
+ param([string]$Expected, [string]$Actual)
+ return $Expected -match '^[0-9A-Fa-f]{64}$' -and $Actual -match '^[0-9A-Fa-f]{64}$' -and
+ $Expected.ToUpperInvariant() -ceq $Actual.ToUpperInvariant()
+}
+
+function Read-UInt16LittleEndian {
+ param([byte[]]$Bytes, [int]$Offset)
+ return [BitConverter]::ToUInt16($Bytes, $Offset)
+}
+
+function Read-UInt32LittleEndian {
+ param([byte[]]$Bytes, [int]$Offset)
+ return [BitConverter]::ToUInt32($Bytes, $Offset)
+}
+
+function Read-AxmlLength8 {
+ param([byte[]]$Bytes, [ref]$Offset)
+ $value = [int]$Bytes[$Offset.Value]
+ $Offset.Value++
+ if (($value -band 0x80) -ne 0) {
+ $value = (($value -band 0x7f) -shl 8) -bor [int]$Bytes[$Offset.Value]
+ $Offset.Value++
+ }
+ return $value
+}
+
+function Read-AxmlLength16 {
+ param([byte[]]$Bytes, [ref]$Offset)
+ $value = [int](Read-UInt16LittleEndian -Bytes $Bytes -Offset $Offset.Value)
+ $Offset.Value += 2
+ if (($value -band 0x8000) -ne 0) {
+ $second = [int](Read-UInt16LittleEndian -Bytes $Bytes -Offset $Offset.Value)
+ $Offset.Value += 2
+ $value = (($value -band 0x7fff) -shl 16) -bor $second
+ }
+ return $value
+}
+
+function Read-AxmlStringPool {
+ param([byte[]]$Bytes, [int]$ChunkOffset)
+ $headerSize = [int](Read-UInt16LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + 2))
+ $chunkSize = [int](Read-UInt32LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + 4))
+ $count = [int](Read-UInt32LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + 8))
+ $flags = [int](Read-UInt32LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + 16))
+ $stringsStart = [int](Read-UInt32LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + 20))
+ if ($headerSize -lt 28 -or $chunkSize -lt $headerSize -or $count -lt 1 -or $count -gt 100000) {
+ Throw-UpdateError "APK_IDENTITY_INVALID" "The APK binary manifest has an invalid string pool."
+ }
+ $utf8 = ($flags -band 0x100) -ne 0
+ $values = New-Object System.Collections.Generic.List[string]
+ for ($index = 0; $index -lt $count; $index++) {
+ $relative = [int](Read-UInt32LittleEndian -Bytes $Bytes -Offset ($ChunkOffset + $headerSize + 4 * $index))
+ $cursor = $ChunkOffset + $stringsStart + $relative
+ if ($cursor -lt 0 -or $cursor -ge $Bytes.Length) {
+ Throw-UpdateError "APK_IDENTITY_INVALID" "The APK binary manifest contains an invalid string offset."
+ }
+ if ($utf8) {
+ [void](Read-AxmlLength8 -Bytes $Bytes -Offset ([ref]$cursor))
+ $byteLength = Read-AxmlLength8 -Bytes $Bytes -Offset ([ref]$cursor)
+ if ($cursor + $byteLength -gt $Bytes.Length) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest string is truncated." }
+ $values.Add([Text.Encoding]::UTF8.GetString($Bytes, $cursor, $byteLength))
+ } else {
+ $charLength = Read-AxmlLength16 -Bytes $Bytes -Offset ([ref]$cursor)
+ $byteLength = $charLength * 2
+ if ($cursor + $byteLength -gt $Bytes.Length) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest string is truncated." }
+ $values.Add([Text.Encoding]::Unicode.GetString($Bytes, $cursor, $byteLength))
+ }
+ }
+ return $values.ToArray()
+}
+
+function Get-AxmlString {
+ param([string[]]$Pool, [uint32]$Index)
+ if ($Index -eq [uint32]::MaxValue) { return $null }
+ if ($Index -ge $Pool.Count) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest references an invalid string." }
+ return $Pool[[int]$Index]
+}
+
+function Get-ApkManifestIdentity {
+ param([Parameter(Mandatory)][string]$ApkPath)
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archive = [IO.Compression.ZipFile]::OpenRead($ApkPath)
+ try {
+ $entry = $archive.GetEntry("AndroidManifest.xml")
+ if (-not $entry) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK has no AndroidManifest.xml." }
+ $stream = $entry.Open()
+ $memory = New-Object IO.MemoryStream
+ try { $stream.CopyTo($memory); $bytes = $memory.ToArray() } finally { $stream.Dispose(); $memory.Dispose() }
+ } finally { $archive.Dispose() }
+ if ($bytes.Length -lt 16 -or (Read-UInt16LittleEndian -Bytes $bytes -Offset 0) -ne 3) {
+ Throw-UpdateError "APK_IDENTITY_INVALID" "AndroidManifest.xml is not a valid binary XML document."
+ }
+ $declaredSize = [int](Read-UInt32LittleEndian -Bytes $bytes -Offset 4)
+ if ($declaredSize -gt $bytes.Length -or $declaredSize -lt 8) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest size is invalid." }
+ $pool = $null
+ $offset = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset 2)
+ while ($offset + 8 -le $declaredSize) {
+ $type = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset $offset)
+ $header = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset ($offset + 2))
+ $size = [int](Read-UInt32LittleEndian -Bytes $bytes -Offset ($offset + 4))
+ if ($header -lt 8 -or $size -lt $header -or $offset + $size -gt $declaredSize) {
+ Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest contains an invalid chunk."
+ }
+ if ($type -eq 1) { $pool = @(Read-AxmlStringPool -Bytes $bytes -ChunkOffset $offset) }
+ if ($type -eq 0x0102 -and $pool) {
+ $elementName = Get-AxmlString -Pool $pool -Index (Read-UInt32LittleEndian -Bytes $bytes -Offset ($offset + 20))
+ if ($elementName -ceq "manifest") {
+ $attributeStart = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset ($offset + 24))
+ $attributeSize = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset ($offset + 26))
+ $attributeCount = [int](Read-UInt16LittleEndian -Bytes $bytes -Offset ($offset + 28))
+ if ($attributeSize -lt 20 -or $attributeCount -gt 256) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest attributes are invalid." }
+ $values = @{}
+ for ($index = 0; $index -lt $attributeCount; $index++) {
+ $attributeOffset = $offset + 16 + $attributeStart + ($index * $attributeSize)
+ if ($attributeOffset + 20 -gt $offset + $size) { Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest attribute is truncated." }
+ $name = Get-AxmlString -Pool $pool -Index (Read-UInt32LittleEndian -Bytes $bytes -Offset ($attributeOffset + 4))
+ $rawIndex = Read-UInt32LittleEndian -Bytes $bytes -Offset ($attributeOffset + 8)
+ $dataType = [int]$bytes[$attributeOffset + 15]
+ $data = Read-UInt32LittleEndian -Bytes $bytes -Offset ($attributeOffset + 16)
+ if ($rawIndex -ne [uint32]::MaxValue) { $value = Get-AxmlString -Pool $pool -Index $rawIndex }
+ elseif ($dataType -eq 3) { $value = Get-AxmlString -Pool $pool -Index $data }
+ elseif ($dataType -in @(0x10, 0x11)) { $value = [string]$data }
+ else { continue }
+ $values[$name] = $value
+ }
+ if (-not $values.ContainsKey("package") -or -not $values.ContainsKey("versionCode") -or -not $values.ContainsKey("versionName")) {
+ Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest identity fields are incomplete."
+ }
+ return [pscustomobject]@{ ApplicationId = $values["package"]; VersionCode = [long]$values["versionCode"]; VersionName = $values["versionName"] }
+ }
+ }
+ $offset += $size
+ }
+ Throw-UpdateError "APK_IDENTITY_INVALID" "The APK manifest element could not be verified."
+}
+
+function Assert-SafeRelativePath {
+ param([Parameter(Mandatory)][string]$Path)
+ if (-not $Path -or $Path -match '\\' -or $Path.StartsWith('/') -or
+ $Path -match '(^|/)\.\.?(/|$)' -or $Path -match '(^|/)\.(?:git|secrets)(/|$)' -or
+ [IO.Path]::IsPathRooted($Path)) {
+ Throw-UpdateError "BUNDLE_PATH_UNSAFE" "Release manifest contains an unsafe bundle path."
+ }
+}
+
+function Read-ReleaseBundle {
+ param([Parameter(Mandatory)][string]$Root)
+ $resolvedRoot = (Resolve-Path -LiteralPath $Root).Path
+ $manifestPath = Join-Path $resolvedRoot "RELEASE-MANIFEST.json"
+ if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
+ Throw-UpdateError "MANIFEST_MISSING" "RELEASE-MANIFEST.json is missing. Use a complete reviewed Release ZIP."
+ }
+ try {
+ $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
+ } catch {
+ Throw-UpdateError "MANIFEST_INVALID" "The release manifest is not valid JSON."
+ }
+ $required = @("schemaVersion", "releaseTag", "applicationId", "versionCode", "versionName",
+ "apkFile", "apkSha256", "signerSha256", "rebootRequired", "migrations", "files")
+ $names = @($manifest.PSObject.Properties.Name)
+ if (@($required | Where-Object { $_ -notin $names }).Count -gt 0 -or
+ @($names | Where-Object { $_ -notin $required }).Count -gt 0) {
+ Throw-UpdateError "MANIFEST_SCHEMA" "The release manifest schema does not match the reviewed updater contract."
+ }
+ if ([int]$manifest.schemaVersion -ne 1 -or
+ [string]$manifest.applicationId -cne "se.lublin.mumla" -or
+ [string]$manifest.releaseTag -cne [string]$manifest.versionName -or
+ [string]$manifest.releaseTag -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:[-.][0-9A-Za-z.-]+)?$' -or
+ [long]$manifest.versionCode -le 0 -or
+ [string]$manifest.apkFile -cne "minimum-foss.apk" -or
+ [string]$manifest.apkSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or
+ [string]$manifest.signerSha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ Throw-UpdateError "MANIFEST_IDENTITY" "The release manifest has an invalid package, version, APK or signer identity."
+ }
+ $versionText = (Get-Content -LiteralPath (Join-Path $resolvedRoot "VERSION.txt") -Raw).Trim()
+ if ($versionText -cne [string]$manifest.releaseTag) {
+ Throw-UpdateError "VERSION_BINDING" "VERSION.txt does not match the exact release tag in the manifest."
+ }
+ $listed = @{}
+ foreach ($entry in @($manifest.files)) {
+ $entryNames = @($entry.PSObject.Properties.Name)
+ if ($entryNames.Count -ne 2 -or "path" -notin $entryNames -or "sha256" -notin $entryNames) {
+ Throw-UpdateError "MANIFEST_FILES" "A release-manifest file entry has an unexpected shape."
+ }
+ $relative = [string]$entry.path
+ Assert-SafeRelativePath -Path $relative
+ if ($listed.ContainsKey($relative)) {
+ Throw-UpdateError "MANIFEST_FILES" "The release manifest contains a duplicate file path."
+ }
+ if ([string]$entry.sha256 -notmatch '^[0-9A-Fa-f]{64}$') {
+ Throw-UpdateError "MANIFEST_FILES" "A release-manifest file checksum is invalid."
+ }
+ $listed[$relative] = ([string]$entry.sha256).ToUpperInvariant()
+ }
+ $approvedFiles = @(
+ "Provision Minimum Device.cmd",
+ "README.txt",
+ "UPDATER-README.md",
+ "Update Minimum Device.cmd",
+ "VERSION.txt",
+ "assets/t99-wifi-provisioner.apk",
+ "minimum-foss.apk",
+ "minimum-foss.apk.sha256",
+ "CELLULAR-README.md",
+ "scripts/manage-cellular.ps1",
+ "scripts/prepare-ryks.ps1",
+ "scripts/prepare-t56.ps1",
+ "scripts/prepare-t99.ps1",
+ "scripts/provision-minimum-device.ps1",
+ "scripts/update-minimum-device.ps1"
+ ) | Sort-Object
+ $manifestFiles = @($listed.Keys | Sort-Object)
+ if (($manifestFiles -join "`n") -cne ($approvedFiles -join "`n")) {
+ Throw-UpdateError "BUNDLE_ALLOWLIST" "Release manifest files differ from the updater's exact reviewed allowlist."
+ }
+ $specialEntry = Get-ChildItem -LiteralPath $resolvedRoot -Recurse -Force | Where-Object {
+ $_.Attributes -band [IO.FileAttributes]::ReparsePoint
+ } | Select-Object -First 1
+ if ($specialEntry) {
+ Throw-UpdateError "BUNDLE_SPECIAL_FILE" "The extracted bundle contains a link or reparse point."
+ }
+ $actual = @(Get-ChildItem -LiteralPath $resolvedRoot -Recurse -Force -File | ForEach-Object {
+ $_.FullName.Substring($resolvedRoot.Length).TrimStart('\', '/').Replace('\', '/')
+ } | Where-Object { $_ -cne "RELEASE-MANIFEST.json" } | Sort-Object)
+ $expected = $manifestFiles
+ if (($actual -join "`n") -cne ($expected -join "`n")) {
+ Throw-UpdateError "BUNDLE_ALLOWLIST" "Extracted bundle files differ from the exact release manifest allowlist."
+ }
+ foreach ($relative in $expected) {
+ $path = Join-Path $resolvedRoot $relative.Replace('/', '\')
+ $item = Get-Item -LiteralPath $path -Force
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ Throw-UpdateError "BUNDLE_SPECIAL_FILE" "The extracted bundle contains a link or reparse-point file."
+ }
+ $actualHash = Get-FileSha256 -Path $path
+ if (-not (Test-Sha256Value -Expected $listed[$relative] -Actual $actualHash)) {
+ Throw-UpdateError "BUNDLE_CHECKSUM" "A bundled file does not match the exact release manifest checksum: $relative"
+ }
+ }
+ $apkPath = Join-Path $resolvedRoot ([string]$manifest.apkFile)
+ $apkHash = Get-FileSha256 -Path $apkPath
+ if (-not (Test-Sha256Value -Expected ([string]$manifest.apkSha256) -Actual $apkHash)) {
+ Throw-UpdateError "APK_CHECKSUM" "minimum-foss.apk does not match the manifest checksum."
+ }
+ $checksumLine = (Get-Content -LiteralPath (Join-Path $resolvedRoot "minimum-foss.apk.sha256") -Raw).Trim()
+ $checksumMatch = [regex]::Match($checksumLine, '^([0-9A-Fa-f]{64})\s+\*?minimum-foss\.apk$')
+ if (-not $checksumMatch.Success -or
+ -not (Test-Sha256Value -Expected $checksumMatch.Groups[1].Value -Actual $apkHash)) {
+ Throw-UpdateError "APK_CHECKSUM_FILE" "minimum-foss.apk.sha256 is not an exact checksum binding for minimum-foss.apk."
+ }
+ return [pscustomobject]@{ Root = $resolvedRoot; Manifest = $manifest; ApkPath = $apkPath }
+}
+
+function Resolve-ApkSigner {
+ $command = Get-Command apksigner, apksigner.bat -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($command) { return $command.Source }
+ $sdkRoots = @(@($env:ANDROID_HOME, $env:ANDROID_SDK_ROOT) | Where-Object { $_ })
+ if ($env:LOCALAPPDATA) {
+ $sdkRoots += Join-Path $env:LOCALAPPDATA "Android\Sdk"
+ }
+ $candidate = Find-ApkSignerInSdkRoots -SdkRoots $sdkRoots
+ if ($candidate) { return $candidate }
+ Throw-UpdateError "APKSIGNER_MISSING" "Android Build Tools apksigner is required to cryptographically verify the APK. Install Android Platform/Build Tools and rerun; no installation was attempted."
+}
+
+function Find-ApkSignerInSdkRoots {
+ param([string[]]$SdkRoots)
+ foreach ($sdkRoot in $sdkRoots) {
+ if (-not $sdkRoot) { continue }
+ $buildTools = Join-Path $sdkRoot "build-tools"
+ if (-not (Test-Path -LiteralPath $buildTools -PathType Container)) { continue }
+ $candidate = Get-ChildItem -LiteralPath $buildTools -Directory | Sort-Object Name -Descending |
+ ForEach-Object {
+ Join-Path $_.FullName "apksigner"
+ Join-Path $_.FullName "apksigner.bat"
+ } |
+ Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
+ if ($candidate) { return $candidate }
+ }
+ return $null
+}
+
+function Parse-ApkSignerOutput {
+ param([string]$Text)
+ $digests = @([regex]::Matches($Text,
+ '(?im)^Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f]{64})\s*$') |
+ ForEach-Object { $_.Groups[1].Value.ToUpperInvariant() })
+ if ($digests.Count -eq 0) {
+ Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report a verified signing certificate."
+ }
+ return $digests
+}
+
+function Get-ApkSignerDigests {
+ param([Parameter(Mandatory)][string]$ApkPath)
+ $apksigner = Resolve-ApkSigner
+ $previous = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = "Continue"
+ $output = @(& $apksigner verify --verbose --print-certs $ApkPath 2>&1)
+ $exitCode = $LASTEXITCODE
+ } finally { $ErrorActionPreference = $previous }
+ $text = (($output | ForEach-Object { [string]$_ }) -join "`n").Trim()
+ if ($exitCode -ne 0) {
+ Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner rejected the APK signature; no installation was attempted."
+ }
+ return @(Parse-ApkSignerOutput -Text $text)
+}
+
+function Assert-SignerCompatibility {
+ param([string[]]$InstalledDigests, [Parameter(Mandatory)][string]$TargetDigest)
+ $installed = @($InstalledDigests)
+ if ($installed.Count -ne 1 -or $installed[0] -cne $TargetDigest.ToUpperInvariant()) {
+ Throw-UpdateError "SIGNER_MISMATCH" "Installed Minimum and the Release APK use different signing certificates. No uninstall or data clear was attempted. A debug-to-release switch requires an explicitly reviewed manual recovery."
+ }
+}
+
+function Compare-VersionCode {
+ param([long]$Installed, [long]$Target)
+ if ($Installed -lt $Target) { return -1 }
+ if ($Installed -gt $Target) { return 1 }
+ return 0
+}
+
+function Convert-AdbDeviceLines {
+ param([string[]]$Lines)
+ $records = foreach ($line in $Lines) {
+ if ($line -match '^([^\s]+)\s+(device|unauthorized|offline|recovery)(?:\s|$)') {
+ $recordSerial = $Matches[1]
+ $recordState = $Matches[2]
+ $transport = 0
+ if ($line -match '\btransport_id:(\d+)\b') { $transport = [int]$Matches[1] }
+ [pscustomobject]@{ Serial = $recordSerial; State = $recordState; TransportId = $transport }
+ }
+ }
+ return @($records)
+}
+
+function Select-TargetRecord {
+ param([object[]]$Records, [string]$RequestedSerial = "", [int]$RequestedTransportId = 0)
+ $authorized = @($Records | Where-Object { $_.State -eq "device" })
+ if ($RequestedTransportId -gt 0) {
+ $matches = @($authorized | Where-Object { $_.TransportId -eq $RequestedTransportId })
+ if ($matches.Count -ne 1) { Throw-UpdateError "TARGET_NOT_FOUND" "The selected authorized ADB transport was not found exactly once." }
+ return $matches[0]
+ }
+ if ($RequestedSerial) {
+ $matches = @($authorized | Where-Object { $_.Serial -ceq $RequestedSerial })
+ if ($matches.Count -ne 1) { Throw-UpdateError "SERIAL_AMBIGUOUS" "The selected ADB serial was not found exactly once; use -TransportId for a duplicate serial." }
+ return $matches[0]
+ }
+ if ($authorized.Count -ne 1) {
+ if ($authorized.Count -eq 0 -and @($Records).Count -gt 0) {
+ Throw-UpdateError "TARGET_NOT_AUTHORIZED" "No device is in the authorized normal Android state. Unlock it and authorize USB debugging."
+ }
+ Throw-UpdateError "TARGET_COUNT" "Connect exactly one authorized radio, or use -Serial/-TransportId explicitly."
+ }
+ return $authorized[0]
+}
+
+function Find-ReturningCandidate {
+ param([object[]]$Records, [string]$Manufacturer, [string]$Model, [string]$OriginalSerial,
+ [string]$ExpectedDeviceId = "")
+ $sameSerial = @($Records | Where-Object {
+ $_.State -eq "device" -and $_.Serial -ceq $OriginalSerial -and
+ $_.Manufacturer -ieq $Manufacturer -and $_.Model -ieq $Model
+ })
+ if ($sameSerial.Count -eq 1) { return $sameSerial[0] }
+ $sameModel = @($Records | Where-Object {
+ $_.State -eq "device" -and $_.Manufacturer -ieq $Manufacturer -and $_.Model -ieq $Model
+ })
+ if ($ExpectedDeviceId) {
+ $identityMatches = @($sameModel | Where-Object {
+ $_.PSObject.Properties.Name -contains "DeviceId" -and $_.DeviceId -ceq $ExpectedDeviceId
+ })
+ if ($identityMatches.Count -eq 1) { return $identityMatches[0] }
+ }
+ return $null
+}
+
+function Parse-PackageState {
+ param([string]$Text)
+ $code = [regex]::Match($Text, '(?m)^\s*versionCode=(\d+)\b')
+ $name = [regex]::Match($Text, '(?m)^\s*versionName=([^\r\n]+)$')
+ if (-not $code.Success -or -not $name.Success) { return $null }
+ return [pscustomobject]@{ VersionCode = [long]$code.Groups[1].Value; VersionName = $name.Groups[1].Value.Trim() }
+}
+
+function Parse-ProvisioningStatus {
+ param([string]$Text)
+ $corePattern = 'data="?deviceId=([A-Z0-9]{6});activeDeviceId=([A-Z0-9*]{1,6});configVersion=(-?\d+);pending=(true|false);lastSuccessMs=(\d+)'
+ $core = [regex]::Match($Text, $corePattern)
+ if (-not $core.Success) { return $null }
+ $extended = [regex]::Match($Text, $corePattern + ';selectedChannel=([a-zA-Z0-9._-]{0,64});activeConfigSha256=([0-9A-F]{64});safeSettingsSha256=([0-9A-F]{64})"?')
+ return [pscustomobject]@{
+ SnapshotLevel = if ($extended.Success) { "EXTENDED" } else { "LEGACY" }
+ DeviceId = $core.Groups[1].Value
+ ActiveDeviceId = $core.Groups[2].Value
+ ConfigVersion = [int]$core.Groups[3].Value
+ Pending = $core.Groups[4].Value -eq "true"
+ LastSuccessMs = [long]$core.Groups[5].Value
+ SelectedChannel = if ($extended.Success) { $extended.Groups[6].Value } else { "UNAVAILABLE_LEGACY" }
+ ActiveConfigSha256 = if ($extended.Success) { $extended.Groups[7].Value } else { "UNAVAILABLE_LEGACY" }
+ SafeSettingsSha256 = if ($extended.Success) { $extended.Groups[8].Value } else { "UNAVAILABLE_LEGACY" }
+ }
+}
+
+function Assert-LegacyBridgeEligible {
+ param($Status, [long]$InstalledVersionCode, [long]$TargetVersionCode)
+ if (-not $Status -or $Status.SnapshotLevel -cne "LEGACY" -or
+ $Status.ActiveDeviceId -ceq "*" -or $Status.ActiveDeviceId -notmatch '^[A-Z0-9]{6}$' -or
+ $Status.Pending -or $Status.ConfigVersion -le 0 -or $Status.LastSuccessMs -le 0) {
+ Throw-UpdateError "LEGACY_NOT_PROVISIONED" "Legacy Minimum did not prove an existing active identity and Last Known Good configuration; the identity action was not called."
+ }
+ if ($InstalledVersionCode -gt 3070300 -or $TargetVersionCode -ne 3070301) {
+ Throw-UpdateError "LEGACY_BRIDGE_UNSUPPORTED" "The limited legacy preservation bridge is approved only for an installed build at or below 3070300 updating to 3070301."
+ }
+}
+
+function Assert-PreservedState {
+ param([Parameter(Mandatory)]$Before, [Parameter(Mandatory)]$After, [string]$Phase)
+ if ($After.DeviceId -cne $Before.DeviceId -or
+ $After.ActiveDeviceId -cne $Before.ActiveDeviceId -or
+ $After.Pending -or $After.ConfigVersion -lt $Before.ConfigVersion -or
+ $After.LastSuccessMs -le 0) {
+ Throw-UpdateError "STATE_PRESERVATION_FAILED" "Identity or Last Known Good configuration regressed $Phase."
+ }
+ if ($Before.SnapshotLevel -ceq "LEGACY") {
+ if ($After.SnapshotLevel -cne "EXTENDED") {
+ Throw-UpdateError "STATE_PRESERVATION_FAILED" "The updated app did not provide the required expanded preservation report $Phase."
+ }
+ return "BOOTSTRAPPED_POST_UPDATE"
+ }
+ if ($After.SnapshotLevel -cne "EXTENDED" -or
+ $After.SelectedChannel -cne $Before.SelectedChannel -or
+ $After.ActiveConfigSha256 -cne $Before.ActiveConfigSha256 -or
+ $After.SafeSettingsSha256 -cne $Before.SafeSettingsSha256) {
+ Throw-UpdateError "STATE_PRESERVATION_FAILED" "Identity, selected channel, safe device settings or Last Known Good configuration changed $Phase."
+ }
+ return "PRESERVED_EXTENDED"
+}
+
+function Get-RequiredMigrations {
+ param([object[]]$ManifestMigrations, [long]$InstalledVersionCode, [long]$TargetVersionCode, [string]$Profile)
+ $required = @()
+ foreach ($migration in @($ManifestMigrations)) {
+ $properties = @($migration.PSObject.Properties.Name)
+ $migrationFields = @("id", "fromVersionCodeMax", "toVersionCode", "profiles", "rebootRequired", "irreversible")
+ if (@($migrationFields | Where-Object { $_ -notin $properties }).Count -gt 0 -or
+ @($properties | Where-Object { $_ -notin $migrationFields }).Count -gt 0 -or
+ [string]$migration.id -notmatch '^[A-Z0-9][A-Z0-9_]{0,63}$' -or
+ @($migration.profiles | Where-Object { $_ -notin @("T56", "T99", "RYKS") }).Count -gt 0) {
+ Throw-UpdateError "MIGRATION_CONTRACT" "A release migration entry does not match the reviewed contract."
+ }
+ if ([string]$migration.id -cne "CELLULAR_POLICY_V1_T56" -or
+ [long]$migration.fromVersionCodeMax -ne 3070300 -or
+ [long]$migration.toVersionCode -ne 3070301 -or
+ @($migration.profiles).Count -ne 1 -or
+ [string]$migration.profiles[0] -cne "T56" -or
+ $migration.rebootRequired -isnot [bool] -or -not [bool]$migration.rebootRequired -or
+ $migration.irreversible -isnot [bool] -or [bool]$migration.irreversible) {
+ Throw-UpdateError "MIGRATION_NOT_IMPLEMENTED" "Release requests a migration that this reviewed updater does not implement exactly."
+ }
+ if ($TargetVersionCode -ne [long]$migration.toVersionCode) {
+ Throw-UpdateError "MIGRATION_CONTRACT" "The cellular migration is not bound to the exact target versionCode."
+ }
+ if ($Profile -in @($migration.profiles) -and
+ $InstalledVersionCode -le [long]$migration.fromVersionCodeMax) {
+ $required += $migration
+ }
+ }
+ return @($required)
+}
+
+function New-MigrationResult {
+ param(
+ [string]$Id,
+ [ValidateSet("APPLIED", "ALREADY_OK", "SKIPPED", "FAILED")][string]$Outcome,
+ [string]$Detail = ""
+ )
+ return [pscustomobject]@{ Id = $Id; Outcome = $Outcome; Detail = $Detail }
+}
+
+function Invoke-CellularPolicyMigration {
+ param([switch]$VerifyOnly)
+ if ($script:CurrentTarget.Profile -ne "T56") {
+ Throw-UpdateError "MIGRATION_PROFILE" "The T56 cellular policy was routed to a different hardware profile."
+ }
+ $cellularScript = Join-Path $PSScriptRoot "manage-cellular.ps1"
+ if (-not (Test-Path -LiteralPath $cellularScript -PathType Leaf)) {
+ Throw-UpdateError "MIGRATION_SCRIPT_MISSING" "The reviewed T56 cellular migration script is missing from the bundle."
+ }
+ $arguments = @("-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $cellularScript,
+ "-AdbPort", "$AdbPort")
+ if ($script:CurrentTarget.TransportId -gt 0) {
+ $arguments += @("-TransportId", "$($script:CurrentTarget.TransportId)")
+ } else {
+ $arguments += @("-Serial", $script:CurrentTarget.Serial)
+ }
+ if ($VerifyOnly) { $arguments += "-VerifyOnly" }
+ $output = @(& powershell.exe @arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+ $safeLines = @($output | ForEach-Object { ConvertTo-SafeMessage -Text ([string]$_) })
+ $text = ($safeLines -join "`n").Trim()
+ if ($text) { Write-Host $text }
+ if ($exitCode -notin @(0, 2)) {
+ Throw-UpdateError "CELLULAR_POLICY_V1_T56" "The guarded T56 cellular migration failed verification."
+ }
+ $marker = [regex]::Match($text, '(?m)^MIGRATION_OUTCOME:\s*(APPLIED|ALREADY_OK)\s*$')
+ if (-not $marker.Success) {
+ Throw-UpdateError "MIGRATION_RESULT_INVALID" "The T56 cellular migration did not return its reviewed outcome marker."
+ }
+ $detail = if ($exitCode -eq 2) { "READINESS_WARN" } elseif ($VerifyOnly) { "POST_REBOOT_VERIFIED" } else { "VERIFIED" }
+ return New-MigrationResult -Id "CELLULAR_POLICY_V1_T56" -Outcome $marker.Groups[1].Value -Detail $detail
+}
+
+function Invoke-RequiredMigration {
+ param([Parameter(Mandatory)]$Migration, [switch]$VerifyOnly)
+ switch -CaseSensitive ([string]$Migration.id) {
+ "CELLULAR_POLICY_V1_T56" { return Invoke-CellularPolicyMigration -VerifyOnly:$VerifyOnly }
+ default { Throw-UpdateError "MIGRATION_NOT_IMPLEMENTED" "The migration has no reviewed execution handler." }
+ }
+}
+
+function Format-SessionSummary {
+ param([object[]]$Results, [string]$TargetVersion)
+ $lines = New-Object System.Collections.Generic.List[string]
+ $lines.Add("Minimum update session")
+ $lines.Add("Target version: $TargetVersion")
+ foreach ($group in @($Results | Group-Object { if ($_.Profile) { $_.Profile } else { "UNKNOWN" } } | Sort-Object Name)) {
+ $lines.Add("")
+ $lines.Add("[$($group.Name)]")
+ foreach ($result in @($group.Group)) {
+ $deviceId = if ($result.DeviceId) { $result.DeviceId } else { "------" }
+ $lines.Add(("{0} {1} {2}" -f $deviceId, $result.Result, $result.Detail))
+ }
+ }
+ $pass = @($Results | Where-Object { $_.Result -eq "PASS" }).Count
+ $warn = @($Results | Where-Object { $_.Result -eq "WARN" }).Count
+ $fail = @($Results | Where-Object { $_.Result -eq "FAIL" }).Count
+ $lines.Add("")
+ $lines.Add("Totals: $pass PASS, $warn WARN, $fail FAIL")
+ return $lines -join "`r`n"
+}
+
+function Invoke-AdbRaw {
+ param([string[]]$Arguments)
+ $previous = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = "Continue"
+ $output = @(& $script:AdbExecutable @Arguments 2>&1)
+ $exitCode = $LASTEXITCODE
+ } finally { $ErrorActionPreference = $previous }
+ $text = (($output | ForEach-Object { if ($_ -is [Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ } }) -join "`n").Trim()
+ return [pscustomobject]@{ ExitCode = $exitCode; Output = $text }
+}
+
+function Get-ListeningAdbPorts {
+ $ports = @()
+ try {
+ $ports = @(Get-NetTCPConnection -State Listen -ErrorAction Stop |
+ Where-Object { $_.LocalPort -in @(5037, 5041) } | Select-Object -ExpandProperty LocalPort -Unique)
+ } catch {
+ foreach ($line in @(& netstat.exe -ano -p TCP 2>$null)) {
+ if ($line -match '^\s*TCP\s+\S+:(5037|5041)\s+\S+\s+LISTENING\s+') { $ports += [int]$Matches[1] }
+ }
+ }
+ return @($ports | Sort-Object -Unique)
+}
+
+function Get-AdbRecords {
+ $result = Invoke-AdbRaw -Arguments ($script:ServerArguments + @("devices", "-l"))
+ if ($result.ExitCode -ne 0) { Throw-UpdateError "ADB_QUERY" "Could not query the selected ADB server." }
+ return @(Convert-AdbDeviceLines -Lines ($result.Output -split "`r?`n"))
+}
+
+function Get-ReturningAdbRecords {
+ # Reviewed T56 firmware can return on the alternate local ADB server after reboot.
+ $records = @()
+ foreach ($port in @(5037, 5041)) {
+ $result = Invoke-AdbRaw -Arguments @("-P", "$port", "devices", "-l")
+ if ($result.ExitCode -ne 0) { continue }
+ foreach ($record in @(Convert-AdbDeviceLines -Lines ($result.Output -split "`r?`n"))) {
+ $record | Add-Member AdbPort $port -Force
+ $records += $record
+ }
+ }
+ return @($records)
+}
+
+function Set-TargetServerArguments {
+ param([Parameter(Mandatory)]$Target)
+ if ($Target.PSObject.Properties.Name -contains "AdbPort" -and $Target.AdbPort -gt 0) {
+ $script:ServerArguments = @("-P", "$($Target.AdbPort)")
+ }
+}
+
+function Select-AdbPort {
+ if ($AdbPort -gt 0) { return $AdbPort }
+ $listening = @(Get-ListeningAdbPorts)
+ if ($listening.Count -eq 0) { return 5037 }
+ $active = @()
+ foreach ($port in $listening) {
+ $probe = Invoke-AdbRaw -Arguments @("-P", "$port", "devices")
+ if ($probe.ExitCode -eq 0 -and $probe.Output -match '(?m)^[^\s]+\s+(device|unauthorized|offline|recovery)(?:\s|$)') { $active += $port }
+ }
+ if ($active.Count -eq 1) { return $active[0] }
+ if ($listening.Count -eq 1) { return $listening[0] }
+ Throw-UpdateError "ADB_PORT_AMBIGUOUS" "Both supported ADB servers are active; pass -AdbPort 5037 or -AdbPort 5041."
+}
+
+function Get-TargetArguments {
+ param([Parameter(Mandatory)]$Target)
+ if ($Target.TransportId -gt 0) { return $script:ServerArguments + @("-t", "$($Target.TransportId)") }
+ return $script:ServerArguments + @("-s", $Target.Serial)
+}
+
+function Invoke-TargetAdb {
+ param([string[]]$Arguments, [switch]$AllowFailure)
+ $result = Invoke-AdbRaw -Arguments ((Get-TargetArguments -Target $script:CurrentTarget) + $Arguments)
+ if (-not $AllowFailure -and $result.ExitCode -ne 0) { Throw-UpdateError "ADB_COMMAND" "An ADB command failed for the selected target." }
+ return $result
+}
+
+function Get-TargetProperty {
+ param([string]$Name)
+ return (Invoke-TargetAdb -Arguments @("shell", "getprop", $Name)).Output.Trim()
+}
+
+function Add-HardwareIdentity {
+ param([Parameter(Mandatory)]$Target)
+ $manufacturer = Get-TargetProperty -Name "ro.product.manufacturer"
+ $model = Get-TargetProperty -Name "ro.product.model"
+ $Target | Add-Member Manufacturer $manufacturer -Force
+ $Target | Add-Member Model $model -Force
+ $Target | Add-Member Profile (Get-DeviceProfile -Manufacturer $manufacturer -Model $model) -Force
+ return $Target
+}
+
+function Get-Identity {
+ param([switch]$Legacy)
+ $action = if ($Legacy) { $LegacyIdentityReportAction } else { $ExistingIdentityReportAction }
+ $result = Invoke-TargetAdb -Arguments @("shell", "am", "broadcast", "-W", "-a", $action, "-n", $ProvisionReceiver)
+ $match = [regex]::Match($result.Output, 'data="?([A-Z0-9]{6})"?')
+ if (-not $match.Success) { Throw-UpdateError "IDENTITY_UNREADABLE" "Minimum did not return its existing six-character Device ID." }
+ return $match.Groups[1].Value
+}
+
+function Get-ProvisioningStatus {
+ $result = Invoke-TargetAdb -Arguments @("shell", "am", "broadcast", "-W", "-a", $ProvisionStatusAction, "-n", $ProvisionReceiver)
+ return Parse-ProvisioningStatus -Text $result.Output
+}
+
+function Get-InstalledPackageState {
+ $pathResult = Invoke-TargetAdb -Arguments @("shell", "pm", "path", $MinimumPackage) -AllowFailure
+ if ($pathResult.ExitCode -ne 0 -or $pathResult.Output -notmatch '(?m)^package:') {
+ Throw-UpdateError "PACKAGE_NOT_INSTALLED" "Minimum is not installed; use Provision Minimum Device instead."
+ }
+ $dump = Invoke-TargetAdb -Arguments @("shell", "dumpsys", "package", $MinimumPackage)
+ $state = Parse-PackageState -Text $dump.Output
+ if (-not $state) { Throw-UpdateError "PACKAGE_VERSION_UNREADABLE" "The installed Minimum version could not be verified." }
+ $base = @($pathResult.Output -split "`r?`n" | Where-Object { $_ -match '^package:.*/base\.apk$' } | Select-Object -First 1)
+ if ($base.Count -ne 1) { Throw-UpdateError "PACKAGE_PATH_UNREADABLE" "The installed Minimum base APK path could not be verified." }
+ $state | Add-Member BaseApkPath ($base[0].Substring(8)) -Force
+ return $state
+}
+
+function Get-InstalledSignerDigests {
+ param([string]$RemoteApkPath)
+ $temporary = Join-Path ([IO.Path]::GetTempPath()) ("minimum-installed-{0}.apk" -f [guid]::NewGuid().ToString("N"))
+ try {
+ $pull = Invoke-TargetAdb -Arguments @("pull", $RemoteApkPath, $temporary) -AllowFailure
+ if ($pull.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $temporary -PathType Leaf)) {
+ Throw-UpdateError "INSTALLED_SIGNER_UNREADABLE" "The installed APK signer could not be read safely."
+ }
+ return @(Get-ApkSignerDigests -ApkPath $temporary)
+ } finally {
+ if (Test-Path -LiteralPath $temporary -PathType Leaf) { Remove-Item -LiteralPath $temporary -Force }
+ }
+}
+
+function Get-BatteryState {
+ $dump = (Invoke-TargetAdb -Arguments @("shell", "dumpsys", "battery")).Output
+ $level = [regex]::Match($dump, '(?m)^\s*level:\s*(\d+)\s*$')
+ $powered = $dump -match '(?m)^\s*(?:AC|USB|Wireless) powered:\s*true\s*$'
+ if (-not $level.Success) { Throw-UpdateError "BATTERY_UNREADABLE" "Battery state could not be verified." }
+ return [pscustomobject]@{ Level = [int]$level.Groups[1].Value; Powered = $powered }
+}
+
+function Get-ReadyState {
+ $remote = "/sdcard/minimum-update-ready-$PID.xml"
+ try {
+ $dump = Invoke-TargetAdb -Arguments @("shell", "uiautomator", "dump", $remote) -AllowFailure
+ if ($dump.ExitCode -ne 0) { return $false }
+ $read = Invoke-TargetAdb -Arguments @("shell", "cat", $remote) -AllowFailure
+ return $read.ExitCode -eq 0 -and $read.Output -match 'content-desc="minimum-state-ready"'
+ } finally {
+ Invoke-TargetAdb -Arguments @("shell", "rm", "-f", $remote) -AllowFailure | Out-Null
+ }
+}
+
+function Wait-MinimumReady {
+ param([string]$ExpectedDeviceId, [int]$TimeoutSeconds)
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ if (Get-ReadyState) {
+ $status = Get-ProvisioningStatus
+ if ($status -and $status.DeviceId -ceq $ExpectedDeviceId -and
+ $status.ActiveDeviceId -ceq $ExpectedDeviceId -and -not $status.Pending -and
+ $status.ConfigVersion -gt 0 -and $status.LastSuccessMs -gt 0) { return $status }
+ }
+ Start-Sleep -Seconds 5
+ }
+ Throw-UpdateError "READY_TIMEOUT" "Minimum did not reach same-ID Ready within the bounded timeout."
+}
+
+function Install-InPlace {
+ param([string]$ApkPath, [switch]$Downgrade)
+ $arguments = @("install", "-r")
+ if ($Downgrade) { $arguments += "-d" }
+ $arguments += $ApkPath
+ $result = Invoke-TargetAdb -Arguments $arguments -AllowFailure
+ if ($result.ExitCode -ne 0 -or $result.Output -notmatch '(?im)^Success\s*$') {
+ if ($result.Output -match 'INSTALL_FAILED_UPDATE_INCOMPATIBLE') {
+ Throw-UpdateError "SIGNER_MISMATCH" "Android rejected the in-place update because the APK signers differ. No uninstall or data clear was attempted."
+ }
+ if ($result.Output -match 'INSTALL_FAILED_INSUFFICIENT_STORAGE') {
+ Throw-UpdateError "INSUFFICIENT_STORAGE" "Android rejected the update because storage is insufficient; no app data was cleared."
+ }
+ Throw-UpdateError "INSTALL_FAILED" "The in-place APK update failed; the existing app data was not cleared."
+ }
+}
+
+function Ensure-RyksInstallPolicy {
+ if ($script:CurrentTarget.Profile -ne "RYKS") { return New-MigrationResult -Id "RYKS_INSTALL_POLICY" -Outcome "SKIPPED" }
+ if ((Get-TargetProperty -Name "ro.build.install") -eq "1") { return New-MigrationResult -Id "RYKS_INSTALL_POLICY" -Outcome "ALREADY_OK" }
+ Invoke-TargetAdb -Arguments @("shell", "setprop", "ro.build.install", "1") | Out-Null
+ if ((Get-TargetProperty -Name "ro.build.install") -ne "1") {
+ Throw-UpdateError "RYKS_INSTALL_POLICY" "RYKS firmware did not enable its model-gated APK install policy for this boot."
+ }
+ return New-MigrationResult -Id "RYKS_INSTALL_POLICY" -Outcome "APPLIED"
+}
+
+function Wait-ReturningTarget {
+ param($OriginalTarget, [string]$ExpectedDeviceId, [int]$TimeoutSeconds)
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ try {
+ while ((Get-Date) -lt $deadline) {
+ Start-Sleep -Seconds 2
+ $candidates = @()
+ foreach ($record in @(Get-ReturningAdbRecords | Where-Object { $_.State -eq "device" })) {
+ $script:CurrentTarget = $record
+ Set-TargetServerArguments -Target $record
+ try { $candidates += Add-HardwareIdentity -Target $record } catch { }
+ }
+ # A serial or model match is only a candidate. Recovery commands are permitted only
+ # after the installed app reports the expected existing Device ID on that candidate.
+ foreach ($record in @($candidates | Where-Object {
+ $_.Manufacturer -ieq $OriginalTarget.Manufacturer -and $_.Model -ieq $OriginalTarget.Model
+ })) {
+ $script:CurrentTarget = $record
+ Set-TargetServerArguments -Target $record
+ try {
+ $identity = Get-Identity
+ $record | Add-Member DeviceId $identity -Force
+ if ($identity -ceq $ExpectedDeviceId) {
+ $record | Add-Member CorrelatedDeviceId $identity -Force
+ }
+ } catch { }
+ }
+ $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer `
+ -Model $OriginalTarget.Model -OriginalSerial "" -ExpectedDeviceId $ExpectedDeviceId
+ if ($candidate) {
+ Set-TargetServerArguments -Target $candidate
+ $script:CurrentTarget = $candidate
+ return $candidate
+ }
+ }
+ Throw-UpdateError "REBOOT_TARGET_AMBIGUOUS" "The same supported profile and Device ID could not be correlated uniquely after reboot."
+ } catch {
+ $script:CurrentTarget = $null
+ throw
+ }
+}
+
+function Get-LegacyExistingIdentityViaRunAs {
+ # PreferenceManager's default file and the public identity key are stable in 3070300. The
+ # remote shell uses only built-ins and emits only the public value, never the surrounding XML.
+ $preferenceFile = "shared_prefs/$($MinimumPackage)_preferences.xml"
+ $probeScript = 'while IFS= read -r line; do case "$line" in *''''*) value=${line#*>}; value=${value%%<*}; printf ''%s\n'' "$value"; exit 0;; esac; done < "$1"; exit 3'
+ $probe = Invoke-TargetAdb -Arguments @("shell", "run-as", $MinimumPackage, "sh", "-c", $probeScript, "minimum-probe", $preferenceFile) -AllowFailure
+ if ($probe.ExitCode -ne 0) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "Legacy Minimum does not expose a non-creating identity probe on this signing/build channel; no receiver was called and no update was attempted."
+ }
+ $identity = $probe.Output.Trim()
+ if ($identity -notmatch '^(?=.*[A-Z])(?=.*[0-9])[A-Z0-9]{6}$') {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "No valid existing legacy identity was found by the non-creating app-private probe; no receiver was called and no update was attempted."
+ }
+ return $identity
+}
+
+function Parse-LegacyReadyUiEvidence {
+ param([string]$WindowDump, [string]$UiXml)
+ $focusedShell = $WindowDump -match '(?m)^\s*mCurrentFocus=.*\bse\.lublin\.mumla/(?:\.radio\.RadioShellActivity|se\.lublin\.mumla\.radio\.RadioShellActivity)\b'
+ if (-not $focusedShell) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "Legacy Minimum was not already focused on RadioShell. Wake/unlock the radio and open the existing app manually, then retry; the updater did not start it or call a receiver."
+ }
+ $ready = $false
+ $selectedChannel = ""
+ foreach ($match in [regex]::Matches($UiXml, '(?is)]*>')) {
+ $node = $match.Value
+ if ($node -notmatch '(?:^|\s)package="se\.lublin\.mumla"(?:\s|/?>)') { continue }
+ if ($node -match '(?:^|\s)content-desc="minimum-state-ready"(?:\s|/?>)') { $ready = $true }
+ $channel = [regex]::Match($node, '(?:^|\s)content-desc="Channel ([A-Za-z0-9._-]{1,64})"(?:\s|/?>)')
+ if ($channel.Success) { $selectedChannel = $channel.Groups[1].Value }
+ }
+ if (-not $ready) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "The already-focused legacy app did not expose package-bound Ready evidence. Open the existing Ready screen manually and retry; no receiver was called."
+ }
+ return [pscustomobject]@{ Mode = "LEGACY_READY_UI"; Identity = ""; SelectedChannel = $selectedChannel }
+}
+
+function Get-LegacyReadyUiEvidence {
+ $window = Invoke-TargetAdb -Arguments @("shell", "dumpsys", "window", "windows") -AllowFailure
+ if ($window.ExitCode -ne 0) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "The focused legacy app could not be verified. Wake/unlock it and open the existing app manually; no receiver was called."
+ }
+ $remote = "/data/local/tmp/minimum-legacy-ready-$([guid]::NewGuid().ToString('N')).xml"
+ try {
+ $dump = Invoke-TargetAdb -Arguments @("shell", "uiautomator", "dump", $remote) -AllowFailure
+ if ($dump.ExitCode -ne 0) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "A fresh legacy Ready UI snapshot could not be obtained; no receiver was called."
+ }
+ $ui = Invoke-TargetAdb -Arguments @("shell", "cat", $remote) -AllowFailure
+ if ($ui.ExitCode -ne 0) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "The fresh legacy Ready UI snapshot could not be read; no receiver was called."
+ }
+ return Parse-LegacyReadyUiEvidence -WindowDump $window.Output -UiXml $ui.Output
+ } finally {
+ Invoke-TargetAdb -Arguments @("shell", "rm", "-f", $remote) -AllowFailure | Out-Null
+ }
+}
+
+function Get-LegacyNonCreatingEvidence {
+ try {
+ $identity = Get-LegacyExistingIdentityViaRunAs
+ return [pscustomobject]@{ Mode = "LEGACY_RUN_AS_ID"; Identity = $identity; SelectedChannel = "" }
+ } catch {
+ if ((Get-ErrorCategory -Message $_.Exception.Message) -cne "LEGACY_NONCREATING_PROBE_UNAVAILABLE") { throw }
+ }
+ return Get-LegacyReadyUiEvidence
+}
+
+function Wait-BootCompleted {
+ param([int]$TimeoutSeconds)
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ if ((Get-TargetProperty -Name "sys.boot_completed") -eq "1") { return }
+ Start-Sleep -Seconds 2
+ }
+ Throw-UpdateError "BOOT_TIMEOUT" "Android did not finish booting within the bounded timeout."
+}
+
+function Write-SanitizedReports {
+ param([Parameter(Mandatory)]$Result, [string]$Directory)
+ if (-not $Directory) {
+ $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { [IO.Path]::GetTempPath() }
+ $Directory = Join-Path $base "Minimum\UpdateReports"
+ }
+ New-Item -ItemType Directory -Path $Directory -Force | Out-Null
+ $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
+ $baseName = "minimum-update-$($Result.SessionId)-$stamp"
+ $jsonPath = Join-Path $Directory "$baseName.json"
+ $textPath = Join-Path $Directory "$baseName.txt"
+ $Result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
+ $migrationSummary = if (@($Result.Migrations).Count -eq 0) {
+ "none"
+ } else {
+ (@($Result.Migrations) | ForEach-Object {
+ $suffix = if ($_.Detail) { "/$($_.Detail)" } else { "" }
+ "$($_.Id)=$($_.Outcome)$suffix"
+ }) -join "; "
+ }
+ @(
+ "Minimum update report",
+ "Session: $($Result.SessionId)",
+ "Profile: $($Result.Profile)",
+ "Device ID: $($Result.DeviceId)",
+ "Previous version: $($Result.PreviousVersion)",
+ "Target version: $($Result.TargetVersion)",
+ "Artifact verification: $($Result.ArtifactVerification)",
+ "Migrations: $migrationSummary",
+ "Preservation evidence: $($Result.PreservationEvidence)",
+ "Legacy proof mode: $($Result.LegacyProofMode)",
+ "Legacy selected channel baseline: $($Result.LegacySelectedChannelBefore)",
+ "Pre-update Ready: $($Result.PreReady)",
+ "Post-update Ready: $($Result.PostReady)",
+ "Reboot acceptance: $($Result.RebootAcceptance)",
+ "Rollback assessment: $($Result.RollbackAssessment)",
+ "Result: $($Result.Result)",
+ "Error category: $($Result.ErrorCategory)",
+ "Detail: $($Result.Detail)",
+ "",
+ "When requesting help, attach this .txt and matching .json report. They intentionally exclude hardware serials and secrets."
+ ) | Set-Content -LiteralPath $textPath -Encoding UTF8
+ Write-Host "Sanitized report: $textPath"
+}
+
+function Write-SessionSummaryReport {
+ param([string]$Summary, [string]$SessionId, [string]$Directory)
+ if (-not $Directory) {
+ $base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { [IO.Path]::GetTempPath() }
+ $Directory = Join-Path $base "Minimum\UpdateReports"
+ }
+ New-Item -ItemType Directory -Path $Directory -Force | Out-Null
+ $path = Join-Path $Directory ("minimum-update-session-{0}.txt" -f $SessionId)
+ $Summary | Set-Content -LiteralPath $path -Encoding UTF8
+ Write-Host "Sanitized session summary: $path"
+}
+
+function Invoke-OneUpdate {
+ param($Bundle, [string]$SessionId, [hashtable]$CompletedDeviceIds = @{})
+ $result = [ordered]@{
+ SessionId = $SessionId; Profile = ""; DeviceId = ""; PreviousVersion = "unknown"
+ TargetVersion = [string]$Bundle.Manifest.versionName; ArtifactVerification = "VERIFIED"
+ Migrations = @(); PreReady = $false; PostReady = $false; RebootAcceptance = "NOT_REQUIRED"
+ RollbackAssessment = "NOT_AUTOMATED; old APK is not included and no data migration is declared"
+ PreservationEvidence = "NOT_CAPTURED"; LegacyProofMode = "NOT_APPLICABLE"
+ LegacySelectedChannelBefore = ""
+ ConfigVersionBefore = -1; ConfigVersionAfter = -1; Result = "FAIL"
+ ErrorCategory = ""; Detail = ""
+ }
+ $mutationStarted = $false
+ $before = $null
+ $deviceId = ""
+ try {
+ $records = @(Get-AdbRecords)
+ $target = Select-TargetRecord -Records $records -RequestedSerial $Serial -RequestedTransportId $TransportId
+ # Hardware inventory uses target-scoped ADB commands, so pin the selected transport before
+ # reading manufacturer/model. This must happen before Add-HardwareIdentity calls getprop.
+ $script:CurrentTarget = $target
+ $script:CurrentTarget = Add-HardwareIdentity -Target $target
+ if (-not $script:CurrentTarget.Profile) {
+ Throw-UpdateError "UNSUPPORTED_HARDWARE" "Unknown hardware was inventory-checked and rejected before mutation."
+ }
+ $result.Profile = $script:CurrentTarget.Profile
+ Write-Host "Target: $($script:CurrentTarget.Manufacturer)/$($script:CurrentTarget.Model) ($($result.Profile))"
+ $battery = Get-BatteryState
+ if ($battery.Level -lt 20 -and -not $battery.Powered) {
+ Throw-UpdateError "POWER_TOO_LOW" "Battery is below 20 percent and external power was not detected."
+ }
+ $installed = Get-InstalledPackageState
+ $result.PreviousVersion = $installed.VersionName
+ $comparison = Compare-VersionCode -Installed $installed.VersionCode -Target ([long]$Bundle.Manifest.versionCode)
+ if ($comparison -gt 0 -and -not $AllowDowngrade) {
+ Throw-UpdateError "DOWNGRADE_REFUSED" "Installed Minimum is newer than this bundle. Use a newer reviewed bundle; downgrade is refused by default."
+ }
+ $requiredMigrations = @(Get-RequiredMigrations -ManifestMigrations @($Bundle.Manifest.migrations) `
+ -InstalledVersionCode $installed.VersionCode -TargetVersionCode ([long]$Bundle.Manifest.versionCode) `
+ -Profile $script:CurrentTarget.Profile)
+ # Signer compatibility and the non-creating legacy probe occur before any receiver action.
+ # This lets unsupported/non-debuggable legacy channels fail with zero app-state mutation.
+ $installedSigner = @(Get-InstalledSignerDigests -RemoteApkPath $installed.BaseApkPath)
+ Assert-SignerCompatibility -InstalledDigests $installedSigner -TargetDigest ([string]$Bundle.Manifest.signerSha256).ToUpperInvariant()
+ $legacyEvidence = $null
+ if ($installed.VersionCode -le 3070300) {
+ $legacyEvidence = Get-LegacyNonCreatingEvidence
+ $result.LegacyProofMode = $legacyEvidence.Mode
+ $result.LegacySelectedChannelBefore = $legacyEvidence.SelectedChannel
+ }
+ # Status is deliberately queried before either identity action. A legacy identity action
+ # may call getOrCreate, so it is permitted only after the app-private run-as probe has
+ # already proved a persisted identity without invoking application code.
+ $before = Get-ProvisioningStatus
+ if (-not $before) {
+ Throw-UpdateError "CONFIG_UNVERIFIED" "Minimum did not return a recognized provisioning status."
+ }
+ if ($before.SnapshotLevel -ceq "LEGACY") {
+ Assert-LegacyBridgeEligible -Status $before -InstalledVersionCode $installed.VersionCode `
+ -TargetVersionCode ([long]$Bundle.Manifest.versionCode)
+ $deviceId = Get-Identity -Legacy
+ $privateIdMismatch = $legacyEvidence.Mode -ceq "LEGACY_RUN_AS_ID" -and
+ $deviceId -cne $legacyEvidence.Identity
+ if (-not $legacyEvidence -or $privateIdMismatch -or $deviceId -cne $before.ActiveDeviceId -or
+ $before.DeviceId -cne $deviceId) {
+ Throw-UpdateError "LEGACY_IDENTITY_MISMATCH" "Legacy receiver identity/configuration did not match the non-creating app-private identity proof."
+ }
+ $result.PreservationEvidence = "LEGACY_LIMITED_BASELINE"
+ } else {
+ $deviceId = Get-Identity
+ if ($before.DeviceId -cne $deviceId -or $before.ActiveDeviceId -cne $deviceId -or
+ $before.Pending -or $before.ConfigVersion -le 0 -or $before.LastSuccessMs -le 0) {
+ Throw-UpdateError "CONFIG_UNVERIFIED" "Existing identity, active configuration or Last Known Good state could not be verified."
+ }
+ $result.PreservationEvidence = "EXTENDED_BASELINE"
+ }
+ $script:CurrentTarget | Add-Member CorrelatedDeviceId $deviceId -Force
+ $result.DeviceId = $deviceId
+ if ($CompletedDeviceIds.ContainsKey($deviceId)) {
+ if ($NonInteractive) { Throw-UpdateError "SESSION_DUPLICATE" "This Device ID was already completed in the current session." }
+ $answer = (Read-Host "Device ID $deviceId was already completed in this session. Type RECHECK to verify it again").Trim()
+ if ($answer -cne "RECHECK") { Throw-UpdateError "SESSION_DUPLICATE" "Operator declined to recheck an already-completed Device ID." }
+ }
+ $result.ConfigVersionBefore = $before.ConfigVersion
+ $result.PreReady = [bool](Get-ReadyState)
+ if (-not $ReportOnly -and -not $WhatIfPreference -and -not $ConfirmNotTransmitting) {
+ if ($NonInteractive) {
+ Throw-UpdateError "TX_CONFIRMATION_REQUIRED" "Non-interactive mutation requires -ConfirmNotTransmitting."
+ }
+ $answer = (Read-Host "Confirm this radio is not transmitting, then type UPDATE").Trim()
+ if ($answer -cne "UPDATE") { Throw-UpdateError "OPERATOR_CANCELLED" "Operator did not confirm the non-transmitting update boundary." }
+ }
+ if ($ReportOnly -or $WhatIfPreference) {
+ $result.Result = if ($before.SnapshotLevel -ceq "LEGACY") { "WARN" } else { "PASS" }
+ $result.Detail = if ($before.SnapshotLevel -ceq "LEGACY") {
+ "REPORT_ONLY; compatible, no mutation performed; legacy preservation baseline is limited"
+ } else { "REPORT_ONLY; compatible, no mutation performed" }
+ $result.PostReady = $result.PreReady
+ return [pscustomobject]$result
+ }
+ foreach ($migration in $requiredMigrations) {
+ $mutationStarted = $true
+ $result.Migrations += Invoke-RequiredMigration -Migration $migration
+ }
+ if ($comparison -eq 0) {
+ $result.RollbackAssessment = "NOT_NEEDED"
+ $result.Migrations += New-MigrationResult -Id "APK_VERSION" -Outcome "ALREADY_OK"
+ } else {
+ if ($comparison -gt 0) {
+ Write-Warning "EXPLICIT DOWNGRADE: Android will receive install -r -d. Signer and identity checks remain enforced; rollback is not automated."
+ }
+ $result.Migrations += Ensure-RyksInstallPolicy
+ Write-Host "Installing verified Minimum $($Bundle.Manifest.versionName) in place..."
+ $mutationStarted = $true
+ Install-InPlace -ApkPath $Bundle.ApkPath -Downgrade:($comparison -gt 0)
+ $result.Migrations += New-MigrationResult -Id "APK_VERSION" -Outcome "APPLIED"
+ }
+ $postPackage = Get-InstalledPackageState
+ if ($postPackage.VersionCode -ne [long]$Bundle.Manifest.versionCode -or
+ $postPackage.VersionName -cne [string]$Bundle.Manifest.versionName) {
+ Throw-UpdateError "POST_VERSION_MISMATCH" "Installed package identity/version does not match the exact release manifest."
+ }
+ Invoke-TargetAdb -Arguments @("shell", "am", "start", "-n", $MinimumActivity) | Out-Null
+ $after = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds
+ $result.PostReady = $true
+ $result.ConfigVersionAfter = $after.ConfigVersion
+ $result.PreservationEvidence = Assert-PreservedState -Before $before -After $after -Phase "after the in-place update"
+ $needsReboot = [bool]$Bundle.Manifest.rebootRequired -or $FullRebootAcceptance -or
+ @($requiredMigrations | Where-Object { [bool]$_.rebootRequired }).Count -gt 0
+ if ($needsReboot) {
+ $original = $script:CurrentTarget
+ Invoke-TargetAdb -Arguments @("reboot") | Out-Null
+ $script:CurrentTarget = Wait-ReturningTarget -OriginalTarget $original -ExpectedDeviceId $deviceId -TimeoutSeconds $BootTimeoutSeconds
+ Wait-BootCompleted -TimeoutSeconds $BootTimeoutSeconds
+ $afterReboot = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds
+ $rebootEvidence = Assert-PreservedState -Before $before -After $afterReboot -Phase "after reboot"
+ if ($result.PreservationEvidence -cne "BOOTSTRAPPED_POST_UPDATE") {
+ $result.PreservationEvidence = $rebootEvidence
+ }
+ foreach ($migration in @($requiredMigrations | Where-Object { [bool]$_.rebootRequired })) {
+ $result.Migrations += Invoke-RequiredMigration -Migration $migration -VerifyOnly
+ }
+ $result.RebootAcceptance = "READY_SAME_ID"
+ }
+ $hasMigrationWarning = @($result.Migrations | Where-Object { $_.Detail -ceq "READINESS_WARN" }).Count -gt 0
+ $result.Result = if ($hasMigrationWarning) { "WARN" } else { "PASS" }
+ $result.Detail = if ($comparison -eq 0) {
+ "ALREADY_OK; same-ID Ready verified"
+ } elseif ($hasMigrationWarning) {
+ "UPDATED; same-ID Ready verified; cellular policy persisted with documented readiness warning"
+ } else {
+ "UPDATED; same-ID Ready verified"
+ }
+ return [pscustomobject]$result
+ } catch {
+ $message = ConvertTo-SafeMessage -Text $_.Exception.Message
+ $result.ErrorCategory = Get-ErrorCategory -Message $message
+ $result.Detail = [regex]::Replace($message, '^\[[A-Z0-9_]+\]\s*', '')
+ if ($mutationStarted -and $deviceId -and $before -and $script:CurrentTarget -and
+ $script:CurrentTarget.PSObject.Properties.Name -contains "CorrelatedDeviceId" -and
+ $script:CurrentTarget.CorrelatedDeviceId -ceq $deviceId) {
+ try {
+ $recoveryPackage = Get-InstalledPackageState
+ Invoke-TargetAdb -Arguments @("shell", "am", "start", "-n", $MinimumActivity) | Out-Null
+ $recovered = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds ([Math]::Min($ReadyTimeoutSeconds, 90))
+ $result.PreservationEvidence = Assert-PreservedState -Before $before -After $recovered -Phase "during failure recovery"
+ $result.PostReady = $true
+ $result.ConfigVersionAfter = $recovered.ConfigVersion
+ $result.Detail += "; RECOVERY_VERIFIED: installed $($recoveryPackage.VersionName) returned to same-ID Ready with preserved state"
+ } catch {
+ $recoveryMessage = ConvertTo-SafeMessage -Text $_.Exception.Message
+ $result.Detail += "; RECOVERY_UNVERIFIED: $([regex]::Replace($recoveryMessage, '^\[[A-Z0-9_]+\]\s*', ''))"
+ }
+ }
+ return [pscustomobject]$result
+ }
+}
+
+if ($LibraryOnly) { return }
+
+$Host.UI.RawUI.WindowTitle = "Minimum One-Shot Updater"
+$sessionId = ([guid]::NewGuid().ToString("N").Substring(0, 12)).ToUpperInvariant()
+try {
+ if (-not $BundleRoot) { $BundleRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path }
+ $bundle = Read-ReleaseBundle -Root $BundleRoot
+ $apkIdentity = Get-ApkManifestIdentity -ApkPath $bundle.ApkPath
+ if ($apkIdentity.ApplicationId -cne [string]$bundle.Manifest.applicationId -or
+ $apkIdentity.VersionCode -ne [long]$bundle.Manifest.versionCode -or
+ $apkIdentity.VersionName -cne [string]$bundle.Manifest.versionName) {
+ Throw-UpdateError "APK_IDENTITY_BINDING" "The APK package/version does not match the exact release manifest."
+ }
+ $targetSigners = @(Get-ApkSignerDigests -ApkPath $bundle.ApkPath)
+ $reviewedSigner = ([string]$bundle.Manifest.signerSha256).ToUpperInvariant()
+ if ($targetSigners.Count -ne 1 -or $targetSigners[0] -cne $reviewedSigner) {
+ Throw-UpdateError "APK_SIGNER_BINDING" "The APK must contain exactly the one reviewed signer from the release manifest; missing or extra signers are refused."
+ }
+ try { $script:AdbExecutable = (Get-Command adb -ErrorAction Stop).Source } catch {
+ Throw-UpdateError "ADB_MISSING" "ADB was not found. Install Android Platform Tools or add adb.exe to PATH."
+ }
+ $AdbPort = Select-AdbPort
+ $script:ServerArguments = @("-P", "$AdbPort")
+} catch {
+ $safe = ConvertTo-SafeMessage -Text $_.Exception.Message
+ $preflight = [pscustomobject][ordered]@{
+ SessionId = $sessionId; Profile = ""; DeviceId = ""; PreviousVersion = "unknown"
+ TargetVersion = "unknown"; ArtifactVerification = "FAILED"; Migrations = @()
+ PreReady = $false; PostReady = $false; RebootAcceptance = "NOT_RUN"
+ RollbackAssessment = "NO_MUTATION"; PreservationEvidence = "NOT_CAPTURED"
+ LegacyProofMode = "NOT_CAPTURED"; LegacySelectedChannelBefore = ""
+ ConfigVersionBefore = -1; ConfigVersionAfter = -1
+ Result = "FAIL"; ErrorCategory = Get-ErrorCategory -Message $safe
+ Detail = [regex]::Replace($safe, '^\[[A-Z0-9_]+\]\s*', '')
+ }
+ Write-SanitizedReports -Result $preflight -Directory $ReportDirectory
+ Write-Error "FAIL: $($preflight.ErrorCategory) - $($preflight.Detail)" -ErrorAction Continue
+ exit 1
+}
+$results = New-Object System.Collections.Generic.List[object]
+$completedIds = @{}
+do {
+ $one = Invoke-OneUpdate -Bundle $bundle -SessionId $sessionId -CompletedDeviceIds $completedIds
+ if ($one.DeviceId -and $completedIds.ContainsKey($one.DeviceId) -and -not $NonInteractive) {
+ Write-Warning "Device ID $($one.DeviceId) was already processed in this session. This run was retained as a recheck."
+ }
+ if ($one.DeviceId) { $completedIds[$one.DeviceId] = $true }
+ $results.Add($one)
+ Write-SanitizedReports -Result $one -Directory $ReportDirectory
+ Write-Host ("{0}: {1} / {2} - {3}" -f $one.Result, $one.Profile, $one.DeviceId, $one.Detail)
+ if (-not $UpdateSession) { break }
+ if ($NonInteractive) { break }
+ Write-Host "Disconnect the completed radio. The updater will not accept another until no authorized device remains."
+ while (@(Get-AdbRecords | Where-Object { $_.State -eq "device" }).Count -gt 0) { Start-Sleep -Seconds 2 }
+ $choice = (Read-Host "Connect the next radio and press Enter, or type Q to finish").Trim()
+ if ($choice -ieq "Q") { break }
+} while ($true)
+
+# Windows PowerShell 5.1 can throw "Argument types do not match" when array-subexpressing a
+# generic List[object]. ToArray preserves the completed sequential results without binder coercion.
+$summary = Format-SessionSummary -Results $results.ToArray() -TargetVersion ([string]$bundle.Manifest.versionName)
+Write-Host ""
+Write-Host $summary
+Write-SessionSummaryReport -Summary $summary -SessionId $sessionId -Directory $ReportDirectory
+if (@($results | Where-Object { $_.Result -eq "FAIL" }).Count -gt 0) { exit 1 }
+if (@($results | Where-Object { $_.Result -eq "WARN" }).Count -gt 0) { exit 2 }
+exit 0
diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1
new file mode 100644
index 00000000..acf697da
--- /dev/null
+++ b/tests/update-minimum-device.Tests.ps1
@@ -0,0 +1,492 @@
+$ErrorActionPreference = "Stop"
+. (Join-Path $PSScriptRoot "..\scripts\update-minimum-device.ps1") -LibraryOnly
+
+$script:Passed = 0
+$script:Failed = 0
+
+function Assert-Equal {
+ param($Expected, $Actual, [string]$Name)
+ if (($Expected -is [array]) -or ($Actual -is [array])) {
+ if ((@($Expected) -join "|") -cne (@($Actual) -join "|")) { throw "$Name expected '$(@($Expected) -join '|')' but got '$(@($Actual) -join '|')'." }
+ } elseif ($Expected -cne $Actual) { throw "$Name expected '$Expected' but got '$Actual'." }
+}
+
+function Assert-True { param([bool]$Value, [string]$Name); if (-not $Value) { throw "$Name expected true." } }
+
+function Assert-ThrowsCode {
+ param([scriptblock]$Action, [string]$Code, [string]$Name)
+ try { & $Action; throw "$Name did not throw." } catch {
+ if ($_.Exception.Message -notmatch "^\[$([regex]::Escape($Code))\]") { throw "$Name threw unexpected error: $($_.Exception.Message)" }
+ }
+}
+
+function Test-Case {
+ param([string]$Name, [scriptblock]$Action)
+ try { & $Action; $script:Passed++; Write-Host "PASS $Name" } catch { $script:Failed++; Write-Host "FAIL $Name - $($_.Exception.Message)" }
+}
+
+Test-Case "supported model source of truth" {
+ Assert-Equal "T56" (Get-DeviceProfile "UNIPRO" "ZX") "T56"
+ Assert-Equal "T99" (Get-DeviceProfile "Youdotech" "QM011") "T99"
+ Assert-Equal "RYKS" (Get-DeviceProfile "ELINK" "ym_258") "RYKS"
+ Assert-Equal "" (Get-DeviceProfile "Other" "ZX") "unknown"
+}
+
+Test-Case "version comparison and downgrade gate primitive" {
+ Assert-Equal -1 (Compare-VersionCode 10 11) "upgrade"
+ Assert-Equal 0 (Compare-VersionCode 11 11) "same"
+ Assert-Equal 1 (Compare-VersionCode 12 11) "downgrade"
+}
+
+Test-Case "device transcript selection" {
+ $one = Convert-AdbDeviceLines @("List of devices attached", "abc device product:x transport_id:7")
+ Assert-Equal 1 @($one).Count "one count"
+ Assert-Equal 7 $one[0].TransportId "transport"
+ Assert-Equal "abc" (Select-TargetRecord $one).Serial "selected"
+ $multiple = Convert-AdbDeviceLines @("a device transport_id:1", "b device transport_id:2")
+ Assert-ThrowsCode { Select-TargetRecord $multiple } "TARGET_COUNT" "multiple"
+ Assert-Equal "b" (Select-TargetRecord $multiple -RequestedTransportId 2).Serial "explicit transport"
+ $bad = Convert-AdbDeviceLines @("a unauthorized transport_id:1")
+ Assert-ThrowsCode { Select-TargetRecord $bad } "TARGET_NOT_AUTHORIZED" "unauthorized"
+}
+
+Test-Case "duplicate serial safely refused" {
+ $records = Convert-AdbDeviceLines @("same device transport_id:1", "same device transport_id:2")
+ Assert-ThrowsCode { Select-TargetRecord $records -RequestedSerial "same" } "SERIAL_AMBIGUOUS" "duplicate serial"
+}
+
+Test-Case "reboot transport change chooses unique same profile" {
+ $records = @(
+ [pscustomobject]@{ Serial="new"; State="device"; TransportId=9; Manufacturer="UNIPRO"; Model="ZX" },
+ [pscustomobject]@{ Serial="other"; State="device"; TransportId=10; Manufacturer="Other"; Model="Other" }
+ )
+ Assert-Equal $null (Find-ReturningCandidate $records "UNIPRO" "ZX" "old") "model-only refused"
+ $records[0] | Add-Member DeviceId "A1B2C3"
+ Assert-Equal "new" (Find-ReturningCandidate $records "UNIPRO" "ZX" "old" "A1B2C3").Serial "identity-correlated returning"
+ $ambiguous = @($records[0], [pscustomobject]@{ Serial="new2"; State="device"; TransportId=11; Manufacturer="UNIPRO"; Model="ZX" })
+ Assert-Equal $null (Find-ReturningCandidate $ambiguous "UNIPRO" "ZX" "old") "ambiguous return"
+}
+
+Test-Case "package and preservation snapshot transcripts" {
+ $package = Parse-PackageState "Packages:`n versionCode=3070300 minSdk=21 targetSdk=36`n versionName=3.7.3-minimum.1-debug"
+ Assert-Equal ([long]3070300) $package.VersionCode "package code"
+ Assert-Equal "3.7.3-minimum.1-debug" $package.VersionName "package name"
+ $status = Parse-ProvisioningStatus ('Broadcast completed: result=0, data="deviceId=A1B2C3;activeDeviceId=A1B2C3;' +
+ 'configVersion=14;pending=false;lastSuccessMs=123;selectedChannel=ops;' +
+ 'activeConfigSha256=' + ('A' * 64) + ';safeSettingsSha256=' + ('B' * 64) + '"')
+ Assert-Equal "A1B2C3" $status.DeviceId "device id"
+ Assert-Equal 14 $status.ConfigVersion "config"
+ Assert-True (-not $status.Pending) "pending false"
+ Assert-Equal "ops" $status.SelectedChannel "selected channel"
+ Assert-Equal ("A" * 64) $status.ActiveConfigSha256 "LKG digest"
+ Assert-Equal ("B" * 64) $status.SafeSettingsSha256 "safe settings digest"
+ $same = $status.PSObject.Copy()
+ Assert-PreservedState $status $same "after update"
+ $changed = $status.PSObject.Copy()
+ $changed.SelectedChannel = "other"
+ Assert-ThrowsCode { Assert-PreservedState $status $changed "after update" } "STATE_PRESERVATION_FAILED" "channel mutation"
+}
+
+Test-Case "debug to release signer mismatch is refused before install" {
+ $debugSigner = "168F42ED412DA80ADAF27BED0984DBEE191168E9DF04F08AFA240A3F9DE45972"
+ $releaseSigner = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ Assert-ThrowsCode { Assert-SignerCompatibility @($debugSigner) $releaseSigner } "SIGNER_MISMATCH" "debug release mismatch"
+}
+
+Test-Case "matching signer accepted" {
+ $signer = "168F42ED412DA80ADAF27BED0984DBEE191168E9DF04F08AFA240A3F9DE45972"
+ Assert-SignerCompatibility @($signer) $signer
+ Assert-ThrowsCode { Assert-SignerCompatibility @($signer, $signer) $signer } "SIGNER_MISMATCH" "duplicate signer refused"
+}
+
+Test-Case "returning target switches to its correlated ADB port" {
+ $old = $script:ServerArguments
+ try {
+ $target = [pscustomobject]@{ Serial="same"; State="device"; TransportId=7; AdbPort=5041 }
+ Set-TargetServerArguments $target
+ Assert-Equal @("-P", "5041") $script:ServerArguments "returning ADB port"
+ } finally { $script:ServerArguments = $old }
+}
+
+Test-Case "apksigner output parser requires verified signer digest" {
+ $digest = "168F42ED412DA80ADAF27BED0984DBEE191168E9DF04F08AFA240A3F9DE45972"
+ Assert-Equal $digest (Parse-ApkSignerOutput "Signer #1 certificate SHA-256 digest: $digest") "apksigner digest"
+ Assert-ThrowsCode { Parse-ApkSignerOutput "DOES NOT VERIFY" } "APK_SIGNATURE_INVALID" "missing signer digest"
+}
+
+Test-Case "migration dispatch is exact, versioned, and T56-only" {
+ $migration = [pscustomobject]@{ id="CELLULAR_POLICY_V1_T56"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false }
+ Assert-Equal 1 @(Get-RequiredMigrations @($migration) 3070300 3070301 "T56").Count "T56 migration"
+ Assert-Equal 0 @(Get-RequiredMigrations @($migration) 3070300 3070301 "T99").Count "T99 skip"
+ Assert-Equal 0 @(Get-RequiredMigrations @($migration) 3070301 3070301 "T56").Count "already migrated"
+ $unknown = [pscustomobject]@{ id="UNKNOWN_POLICY"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false }
+ Assert-ThrowsCode { Get-RequiredMigrations @($unknown) 3070300 3070301 "T56" } "MIGRATION_NOT_IMPLEMENTED" "unapproved migration"
+ Assert-ThrowsCode { Get-RequiredMigrations @($migration) 3070300 3070302 "T56" } "MIGRATION_CONTRACT" "wrong target"
+}
+
+Test-Case "unsafe relative bundle paths are refused" {
+ Assert-ThrowsCode { Assert-SafeRelativePath "../minimum-foss.apk" } "BUNDLE_PATH_UNSAFE" "parent traversal"
+ Assert-ThrowsCode { Assert-SafeRelativePath "scripts\update-minimum-device.ps1" } "BUNDLE_PATH_UNSAFE" "backslash"
+ Assert-ThrowsCode { Assert-SafeRelativePath "/minimum-foss.apk" } "BUNDLE_PATH_UNSAFE" "rooted path"
+}
+
+Test-Case "idempotent outcome and summary" {
+ Assert-Equal "ALREADY_OK" (New-MigrationResult "APK_VERSION" "ALREADY_OK").Outcome "already ok"
+ $results = @(
+ [pscustomobject]@{ Profile="T56"; DeviceId="A1B2C3"; Result="PASS"; Detail="ALREADY_OK" },
+ [pscustomobject]@{ Profile="T99"; DeviceId="D4E5F6"; Result="FAIL"; Detail="signer mismatch" }
+ )
+ $summary = Format-SessionSummary $results "3.7.4"
+ Assert-True ($summary -match 'Totals: 1 PASS, 0 WARN, 1 FAIL') "summary totals"
+ Assert-True ($summary -notmatch 'serial') "summary privacy"
+}
+
+Test-Case "secret and identifier redaction" {
+ $safe = ConvertTo-SafeMessage "serial=usb123 token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ123456 password=hunter2 Bearer abc.def.ghi"
+ Assert-True ($safe -notmatch 'usb123|ABCDEFGHIJKLMNOPQRSTUVWXYZ|hunter2|abc\.def') "redacted values"
+ Assert-True ($safe -match 'serial=') "redaction marker"
+}
+
+Test-Case "bundle allowlist and checksum reject tampering" {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ("minimum-updater-test-{0}" -f [guid]::NewGuid().ToString("N"))
+ try {
+ New-Item -ItemType Directory -Path $root | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $root "scripts") | Out-Null
+ New-Item -ItemType Directory -Path (Join-Path $root "assets") | Out-Null
+ $approved = @(
+ "Provision Minimum Device.cmd", "README.txt", "UPDATER-README.md", "Update Minimum Device.cmd",
+ "VERSION.txt", "CELLULAR-README.md", "assets/t99-wifi-provisioner.apk", "minimum-foss.apk", "minimum-foss.apk.sha256",
+ "scripts/manage-cellular.ps1",
+ "scripts/prepare-ryks.ps1", "scripts/prepare-t56.ps1", "scripts/prepare-t99.ps1",
+ "scripts/provision-minimum-device.ps1", "scripts/update-minimum-device.ps1"
+ )
+ foreach ($relative in $approved) {
+ Set-Content -LiteralPath (Join-Path $root $relative.Replace('/', '\')) -Value "fixture-$relative" -NoNewline -Encoding ASCII
+ }
+ Set-Content -LiteralPath (Join-Path $root "VERSION.txt") -Value "3.7.3-minimum.2" -NoNewline -Encoding ASCII
+ Set-Content -LiteralPath (Join-Path $root "minimum-foss.apk") -Value "fixture" -NoNewline -Encoding ASCII
+ $apkHash = Get-FileSha256 (Join-Path $root "minimum-foss.apk")
+ Set-Content -LiteralPath (Join-Path $root "minimum-foss.apk.sha256") -Value "$apkHash minimum-foss.apk" -NoNewline -Encoding ASCII
+ $files = $approved | ForEach-Object {
+ [ordered]@{ path=$_; sha256=Get-FileSha256 (Join-Path $root $_) }
+ }
+ $manifest = [ordered]@{
+ schemaVersion=1; releaseTag="3.7.3-minimum.2"; applicationId="se.lublin.mumla"; versionCode=3070301
+ versionName="3.7.3-minimum.2"; apkFile="minimum-foss.apk"; apkSha256=$apkHash
+ signerSha256=("A" * 64); rebootRequired=$false
+ migrations=@([ordered]@{ id="CELLULAR_POLICY_V1_T56"; fromVersionCodeMax=3070300; toVersionCode=3070301; profiles=@("T56"); rebootRequired=$true; irreversible=$false })
+ files=$files
+ }
+ $manifest | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $root "RELEASE-MANIFEST.json") -Encoding UTF8
+ $bundle = Read-ReleaseBundle $root
+ Assert-Equal "3.7.3-minimum.2" $bundle.Manifest.releaseTag "valid bundle"
+ Add-Content -LiteralPath (Join-Path $root "minimum-foss.apk") -Value "tamper"
+ Assert-ThrowsCode { Read-ReleaseBundle $root } "BUNDLE_CHECKSUM" "tampered file"
+ Set-Content -LiteralPath (Join-Path $root "extra.txt") -Value "extra"
+ Assert-ThrowsCode { Read-ReleaseBundle $root } "BUNDLE_ALLOWLIST" "extra file"
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+$realApkPath = Join-Path $PSScriptRoot "..\app\build\outputs\apk\foss\debug\mumla-foss-debug.apk"
+if (Test-Path -LiteralPath $realApkPath -PathType Leaf) {
+ Test-Case "real built APK identity and signer parsing" {
+ $identity = Get-ApkManifestIdentity -ApkPath $realApkPath
+ Assert-Equal "se.lublin.mumla" $identity.ApplicationId "real APK package"
+ Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code"
+ Assert-True ($identity.VersionName -match '-debug$') "real APK debug version"
+ $signers = @(Get-ApkSignerDigests -ApkPath $realApkPath)
+ Assert-True ($signers.Count -ge 1) "real APK signer count"
+ Assert-True (@($signers | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -eq 0) "real APK signer format"
+ }
+}
+
+function New-UpdaterStatus {
+ param([string]$Level = "EXTENDED", [string]$DeviceId = "A1B2C3", [int]$ConfigVersion = 14,
+ [bool]$Pending = $false, [long]$LastSuccessMs = 123)
+ [pscustomobject]@{
+ SnapshotLevel = $Level; DeviceId = $DeviceId; ActiveDeviceId = $DeviceId
+ ConfigVersion = $ConfigVersion; Pending = $Pending; LastSuccessMs = $LastSuccessMs
+ SelectedChannel = if ($Level -ceq "EXTENDED") { "ops" } else { "UNAVAILABLE_LEGACY" }
+ ActiveConfigSha256 = if ($Level -ceq "EXTENDED") { "A" * 64 } else { "UNAVAILABLE_LEGACY" }
+ SafeSettingsSha256 = if ($Level -ceq "EXTENDED") { "B" * 64 } else { "UNAVAILABLE_LEGACY" }
+ }
+}
+
+function New-UpdaterBundle {
+ [pscustomobject]@{
+ ApkPath = "fixture.apk"
+ Manifest = [pscustomobject]@{
+ versionName = "3.7.3-minimum.2"; versionCode = 3070301; signerSha256 = "A" * 64
+ rebootRequired = $false
+ migrations = @([pscustomobject]@{
+ id = "CELLULAR_POLICY_V1_T56"; fromVersionCodeMax = 3070300
+ toVersionCode = 3070301; profiles = @("T56"); rebootRequired = $true; irreversible = $false
+ })
+ }
+ }
+}
+
+function Set-UpdaterScenarioMocks {
+ param([hashtable]$Scenario)
+ $global:UpdaterScenario = $Scenario
+ Set-Item Function:\Get-AdbRecords { @([pscustomobject]@{ Serial="usb"; State="device"; TransportId=7 }) }
+ Set-Item Function:\Add-HardwareIdentity {
+ param($Target)
+ if (-not $script:CurrentTarget -or $script:CurrentTarget.TransportId -ne $Target.TransportId) {
+ throw "target transport was not pinned before hardware inventory"
+ }
+ [pscustomobject]@{ Serial=$Target.Serial; State="device"; TransportId=7; Manufacturer=$global:UpdaterScenario.Manufacturer; Model=$global:UpdaterScenario.Model; Profile=$global:UpdaterScenario.Profile }
+ }
+ Set-Item Function:\Get-BatteryState { [pscustomobject]@{ Level=90; Powered=$true } }
+ Set-Item Function:\Get-InstalledPackageState {
+ $global:UpdaterScenario.PackageReads++
+ $code = if ($global:UpdaterScenario.Installed -and -not $global:UpdaterScenario.PostVersionMismatch) { 3070301 } else { $global:UpdaterScenario.InstalledCode }
+ $name = if ($code -eq 3070301) { "3.7.3-minimum.2" } else { "3.7.3-minimum.1" }
+ [pscustomobject]@{ VersionCode=[long]$code; VersionName=$name; BaseApkPath="/data/app/base.apk" }
+ }
+ Set-Item Function:\Get-ProvisioningStatus {
+ $global:UpdaterScenario.Transcript.Add("STATUS")
+ return $global:UpdaterScenario.Before
+ }
+ Set-Item Function:\Get-LegacyExistingIdentityViaRunAs {
+ $global:UpdaterScenario.Transcript.Add("RUN_AS_IDENTITY")
+ if (-not $global:UpdaterScenario.LegacyProbeAvailable) {
+ Throw-UpdateError "LEGACY_NONCREATING_PROBE_UNAVAILABLE" "mocked unavailable probe"
+ }
+ return $global:UpdaterScenario.Before.DeviceId
+ }
+ Set-Item Function:\Get-LegacyReadyUiEvidence {
+ $global:UpdaterScenario.Transcript.Add("READY_UI_PROBE")
+ return Parse-LegacyReadyUiEvidence -WindowDump $global:UpdaterScenario.WindowDump -UiXml $global:UpdaterScenario.UiXml
+ }
+ Set-Item Function:\Get-Identity {
+ param([switch]$Legacy)
+ $global:UpdaterScenario.IdentityCalls++
+ $global:UpdaterScenario.Transcript.Add($(if ($Legacy) { "IDENTITY_LEGACY" } else { "IDENTITY_EXISTING" }))
+ return $global:UpdaterScenario.Before.DeviceId
+ }
+ Set-Item Function:\Get-ReadyState { $true }
+ Set-Item Function:\Get-InstalledSignerDigests { param([string]$RemoteApkPath); @($global:UpdaterScenario.InstalledSigners) }
+ Set-Item Function:\Invoke-RequiredMigration {
+ param($Migration, [switch]$VerifyOnly)
+ $global:UpdaterScenario.MigrationCalls++
+ New-MigrationResult -Id $Migration.id -Outcome $(if ($VerifyOnly) { "ALREADY_OK" } else { "APPLIED" })
+ }
+ Set-Item Function:\Ensure-RyksInstallPolicy {
+ if ($global:UpdaterScenario.Profile -ceq "RYKS") { $global:UpdaterScenario.RyksCalls++; New-MigrationResult -Id "RYKS_INSTALL_POLICY" -Outcome "APPLIED" }
+ }
+ Set-Item Function:\Install-InPlace {
+ param([string]$ApkPath, [switch]$Downgrade)
+ $global:UpdaterScenario.InstallCalls++; $global:UpdaterScenario.Installed = $true
+ }
+ Set-Item Function:\Invoke-TargetAdb {
+ param([string[]]$Arguments, [switch]$AllowFailure)
+ if ((@($Arguments) -join " ") -match 'am start') { $global:UpdaterScenario.StartCalls++ }
+ [pscustomobject]@{ ExitCode=0; Output="Success" }
+ }
+ Set-Item Function:\Wait-MinimumReady { param([string]$ExpectedDeviceId, [int]$TimeoutSeconds); return $global:UpdaterScenario.After }
+ Set-Item Function:\Wait-ReturningTarget {
+ param($OriginalTarget, [string]$ExpectedDeviceId, [int]$TimeoutSeconds)
+ if ($global:UpdaterScenario.RebootFailure) { $script:CurrentTarget = $null; Throw-UpdateError "REBOOT_TARGET_AMBIGUOUS" "mocked timeout" }
+ $OriginalTarget | Add-Member CorrelatedDeviceId $ExpectedDeviceId -Force
+ return $OriginalTarget
+ }
+ Set-Item Function:\Wait-BootCompleted { param([int]$TimeoutSeconds) }
+}
+
+function New-UpdaterScenario {
+ param([string]$Profile = "T99", [string]$Level = "EXTENDED")
+ $hardware = switch ($Profile) {
+ "T56" { @("UNIPRO", "ZX") }
+ "T99" { @("Youdotech", "QM011") }
+ "RYKS" { @("ELINK", "ym_258") }
+ }
+ @{
+ Profile=$Profile; Manufacturer=$hardware[0]; Model=$hardware[1]; InstalledCode=3070300
+ Before=(New-UpdaterStatus -Level $Level); After=(New-UpdaterStatus -Level "EXTENDED")
+ InstalledSigners=@("A" * 64); Installed=$false; PostVersionMismatch=$false; RebootFailure=$false; LegacyProbeAvailable=$true
+ WindowDump="mCurrentFocus=Window{42 u0 se.lublin.mumla/.radio.RadioShellActivity}"
+ UiXml=''
+ InstallCalls=0; IdentityCalls=0; MigrationCalls=0; RyksCalls=0; StartCalls=0; PackageReads=0
+ Transcript=[Collections.Generic.List[string]]::new()
+ }
+}
+
+$ReportOnly = $false
+$NonInteractive = $true
+$ConfirmNotTransmitting = $true
+$AllowDowngrade = $false
+$FullRebootAcceptance = $false
+$Serial = ""
+$TransportId = 0
+$ReadyTimeoutSeconds = 30
+$BootTimeoutSeconds = 30
+$WhatIfPreference = $false
+
+Test-Case "legacy 3070300 to 3070301 state-machine bootstraps expanded evidence" {
+ $scenario = New-UpdaterScenario -Profile "T56" -Level "LEGACY"
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYOK"
+ Assert-Equal "PASS" $result.Result "result ($($result.ErrorCategory): $($result.Detail))"
+ Assert-Equal "BOOTSTRAPPED_POST_UPDATE" $result.PreservationEvidence "bootstrap evidence"
+ Assert-Equal "LEGACY_RUN_AS_ID" $result.LegacyProofMode "proof mode"
+ Assert-Equal @("RUN_AS_IDENTITY", "STATUS", "IDENTITY_LEGACY") @($scenario.Transcript) "noncreating proof before receiver transcript"
+ Assert-Equal 1 $scenario.InstallCalls "install count"
+ Assert-Equal 2 $scenario.MigrationCalls "apply plus post-reboot verify"
+}
+
+Test-Case "legacy unprovisioned state is rejected without identity or install" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"
+ $scenario.LegacyProbeAvailable = $false
+ $scenario.UiXml = ''
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYNO"
+ Assert-Equal "LEGACY_NONCREATING_PROBE_UNAVAILABLE" $result.ErrorCategory "category"
+ Assert-Equal 0 $scenario.IdentityCalls "identity calls"
+ Assert-Equal 0 $scenario.InstallCalls "install count"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "no receiver transcript"
+}
+
+Test-Case "legacy signer mismatch is reached preinstall without mutation" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"
+ $scenario.InstalledSigners = @("B" * 64)
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "SIGNERNO"
+ Assert-Equal "SIGNER_MISMATCH" $result.ErrorCategory "category"
+ Assert-Equal 0 $scenario.IdentityCalls "no receiver identity before signer refusal"
+ Assert-Equal 0 $scenario.InstallCalls "install count"
+ Assert-Equal @() @($scenario.Transcript) "no receiver or legacy probe needed after signer refusal"
+}
+
+Test-Case "legacy ReportOnly without run-as proof invokes no receiver" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"; $scenario.LegacyProbeAvailable = $false
+ $scenario.UiXml = ''
+ Set-UpdaterScenarioMocks $scenario
+ $ReportOnly = $true
+ try { $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYREPORT" } finally { $ReportOnly = $false }
+ Assert-Equal "LEGACY_NONCREATING_PROBE_UNAVAILABLE" $result.ErrorCategory "category"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "noncreating probes only"
+ Assert-Equal 0 $scenario.IdentityCalls "receiver identity calls"
+ Assert-Equal 0 $scenario.InstallCalls "install count"
+}
+
+Test-Case "legacy Ready UI fallback permits receiver only after focused package proof" {
+ $scenario = New-UpdaterScenario -Profile "T56" -Level "LEGACY"; $scenario.LegacyProbeAvailable = $false
+ Set-UpdaterScenarioMocks $scenario
+ $ReportOnly = $true
+ try { $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYUI" } finally { $ReportOnly = $false }
+ Assert-Equal "WARN" $result.Result "legacy report result"
+ Assert-Equal "LEGACY_READY_UI" $result.LegacyProofMode "proof mode"
+ Assert-Equal "E21AS" $result.LegacySelectedChannelBefore "channel baseline"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE", "STATUS", "IDENTITY_LEGACY") @($scenario.Transcript) "proof-before-receiver transcript"
+ Assert-Equal 0 $scenario.InstallCalls "install count"
+}
+
+Test-Case "legacy Ready UI fallback rejects wrong package before receiver" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"; $scenario.LegacyProbeAvailable = $false
+ $scenario.UiXml = ''
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "WRONGPKG"
+ Assert-Equal "LEGACY_NONCREATING_PROBE_UNAVAILABLE" $result.ErrorCategory "category"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "no receiver transcript"
+ Assert-Equal 0 $scenario.IdentityCalls "identity receiver calls"
+}
+
+Test-Case "legacy Ready UI fallback rejects not-Ready screen before receiver" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"; $scenario.LegacyProbeAvailable = $false
+ $scenario.UiXml = ''
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "NOTREADY"
+ Assert-Equal "LEGACY_NONCREATING_PROBE_UNAVAILABLE" $result.ErrorCategory "category"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "no receiver transcript"
+ Assert-Equal 0 $scenario.IdentityCalls "identity receiver calls"
+}
+
+Test-Case "legacy Ready UI fallback rejects unfocused app before receiver" {
+ $scenario = New-UpdaterScenario -Level "LEGACY"; $scenario.LegacyProbeAvailable = $false
+ $scenario.WindowDump = "mCurrentFocus=Window{42 u0 com.android.settings/.Settings}"
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "UNFOCUSED"
+ Assert-Equal "LEGACY_NONCREATING_PROBE_UNAVAILABLE" $result.ErrorCategory "category"
+ Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "no receiver transcript"
+ Assert-Equal 0 $scenario.IdentityCalls "identity receiver calls"
+}
+
+Test-Case "ReportOnly uses existing identity and never installs" {
+ $scenario = New-UpdaterScenario
+ $scenario.InstalledCode = 3070301; $scenario.Installed = $true
+ Set-UpdaterScenarioMocks $scenario
+ $ReportOnly = $true
+ try { $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "REPORT" } finally { $ReportOnly = $false }
+ Assert-Equal "PASS" $result.Result "result"
+ Assert-Equal 0 $scenario.InstallCalls "install count"
+ Assert-Equal @("STATUS", "IDENTITY_EXISTING") @($scenario.Transcript) "read-only transcript"
+}
+
+Test-Case "post-install failure relaunches and reports verified recovery" {
+ $scenario = New-UpdaterScenario
+ $scenario.PostVersionMismatch = $true
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "RECOVER"
+ Assert-Equal "POST_VERSION_MISMATCH" $result.ErrorCategory "category"
+ Assert-True ($result.Detail -match 'RECOVERY_VERIFIED') "recovery evidence"
+ Assert-Equal 1 $scenario.InstallCalls "install count"
+ Assert-Equal 1 $scenario.StartCalls "recovery relaunch"
+}
+
+Test-Case "T99 and RYKS route only their approved state-machine paths" {
+ $t99 = New-UpdaterScenario -Profile "T99"; Set-UpdaterScenarioMocks $t99
+ $t99Result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "T99"
+ Assert-Equal "PASS" $t99Result.Result "T99 result"; Assert-Equal 0 $t99.MigrationCalls "T99 cellular skip"; Assert-Equal 0 $t99.RyksCalls "T99 RYKS skip"
+ $ryks = New-UpdaterScenario -Profile "RYKS"; Set-UpdaterScenarioMocks $ryks
+ $ryksResult = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "RYKS"
+ Assert-Equal "PASS" $ryksResult.Result "RYKS result"; Assert-Equal 0 $ryks.MigrationCalls "RYKS cellular skip"; Assert-Equal 1 $ryks.RyksCalls "RYKS policy"
+}
+
+Test-Case "reboot timeout clears correlation and forbids wrong-target recovery" {
+ $scenario = New-UpdaterScenario -Profile "T56" -Level "LEGACY"
+ $scenario.RebootFailure = $true
+ Set-UpdaterScenarioMocks $scenario
+ $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "REBOOTNO"
+ Assert-Equal "REBOOT_TARGET_AMBIGUOUS" $result.ErrorCategory "category"
+ Assert-Equal $null $script:CurrentTarget "cleared target"
+ Assert-Equal 1 $scenario.StartCalls "only pre-reboot launch"
+ Assert-True ($result.Detail -notmatch 'RECOVERY_') "no wrong-target recovery"
+}
+
+Test-Case "partial sequential session continues after a failed device" {
+ $first = New-UpdaterScenario; $first.InstalledSigners = @("B" * 64); Set-UpdaterScenarioMocks $first
+ $failed = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "BATCH"
+ $second = New-UpdaterScenario -Profile "RYKS"; Set-UpdaterScenarioMocks $second
+ $passed = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "BATCH"
+ $summary = Format-SessionSummary @($failed, $passed) "3.7.3-minimum.2"
+ Assert-Equal "FAIL" $failed.Result "first result"; Assert-Equal "PASS" $passed.Result "second result"
+ Assert-True ($summary -match 'Totals: 1 PASS, 0 WARN, 1 FAIL') "partial session summary"
+}
+
+Test-Case "Resolve-ApkSigner tolerates unset Linux-style SDK environment" {
+ $oldLocal = $env:LOCALAPPDATA; $oldHome = $env:ANDROID_HOME; $oldRoot = $env:ANDROID_SDK_ROOT
+ try {
+ $env:LOCALAPPDATA = $null; $env:ANDROID_HOME = $null; $env:ANDROID_SDK_ROOT = $null
+ try { $resolved = Resolve-ApkSigner; Assert-True ([bool]$resolved) "resolved signer" }
+ catch { if ($_.Exception.Message -notmatch '^\[APKSIGNER_MISSING\]') { throw } }
+ } finally { $env:LOCALAPPDATA = $oldLocal; $env:ANDROID_HOME = $oldHome; $env:ANDROID_SDK_ROOT = $oldRoot }
+}
+
+Test-Case "SDK discovery accepts extensionless Linux apksigner" {
+ $root = Join-Path ([IO.Path]::GetTempPath()) ("minimum-sdk-test-{0}" -f [guid]::NewGuid().ToString("N"))
+ try {
+ $directory = Join-Path $root "build-tools\99.0.0"
+ New-Item -ItemType Directory -Path $directory -Force | Out-Null
+ $expected = Join-Path $directory "apksigner"
+ Set-Content -LiteralPath $expected -Value "#!/bin/sh" -NoNewline -Encoding ASCII
+ Assert-Equal $expected (Find-ApkSignerInSdkRoots @($root)) "Linux apksigner path"
+ } finally {
+ if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
+ }
+}
+
+Write-Host "Updater tests: $script:Passed passed, $script:Failed failed"
+if ($script:Failed -gt 0) { exit 1 }
diff --git a/tools/verify-cellular-policy.ps1 b/tools/verify-cellular-policy.ps1
new file mode 100644
index 00000000..7dadf431
--- /dev/null
+++ b/tools/verify-cellular-policy.ps1
@@ -0,0 +1,68 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = "Stop"
+$root = Split-Path -Parent $PSScriptRoot
+$scriptPath = Join-Path $root "scripts\manage-cellular.ps1"
+$source = Get-Content -LiteralPath $scriptPath -Raw
+$tokens = $null
+$errors = $null
+$ast = [System.Management.Automation.Language.Parser]::ParseFile(
+ $scriptPath, [ref]$tokens, [ref]$errors)
+if ($errors.Count -gt 0) { throw "Cellular script parse error: $($errors[0].Message)" }
+
+foreach ($name in @("Convert-PreferredNetworkMode", "Convert-ServiceState", "Convert-SignalStrength")) {
+ $function = $ast.Find({
+ param($node)
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $node.Name -ceq $name
+ }, $true)
+ if ($null -eq $function) { throw "Missing cellular parser '$name'." }
+ Invoke-Expression $function.Extent.Text
+}
+
+$automatic = Convert-PreferredNetworkMode "22"
+if (-not $automatic.Lte -or -not $automatic.Fallback -or $automatic.Name -notmatch 'automatic') {
+ throw "Verified T56 mode 22 must be LTE capable with legacy fallback."
+}
+$lteOnly = Convert-PreferredNetworkMode "11"
+if ($lteOnly.Fallback) { throw "LTE-only must never be treated as a safe fallback mode." }
+$unknown = Convert-PreferredNetworkMode "999"
+if ($unknown.Lte -or $unknown.Fallback) { throw "Unknown modes must fail closed." }
+
+$service = Convert-ServiceState "0 0 home Carrier Carrier 00000 LTE LTE CSS not supported"
+if (-not $service.InService -or $service.DataRat -ne "LTE" -or $service.VoiceRat -ne "LTE") {
+ throw "Sanitized API-22 service-state parsing failed."
+}
+$signal = Convert-SignalStrength "99 0 -120 -160 -120 -1 -1 26 -98 -19 -54 2147483647 2147483647 gsm|lte"
+if ($signal -ne "LTE RSRP -98 dBm (Android telephony registry)") {
+ throw "LTE RSRP parsing failed: $signal"
+}
+if ((Convert-SignalStrength "99 0") -ne "unavailable") {
+ throw "Unknown GSM ASU must remain unavailable."
+}
+
+foreach ($required in @(
+ 'CELLULAR COST WARNING',
+ '[switch]$DisableDataRoaming',
+ 'if ((Get-GlobalSetting "data_roaming") -ne $desiredRoaming)',
+ 'API-22 Settings.Global writes do not prove the modem accepted a preferred mode',
+ 'if ((Get-GlobalSetting "mobile_data") -ne "1")',
+ '$originalMode.Lte -and $originalMode.Fallback',
+ 'subscriber identifiers suppressed')) {
+ if (-not $source.Contains($required)) { throw "Missing cellular safety contract: $required" }
+}
+if ($source -match '(?i)(imsi|iccid|imei|line1number|subscriberid)') {
+ throw "Cellular script must not query or print subscriber/device identifiers."
+}
+if ($source -match 'content://telephony/carriers/preferapn"\)') {
+ throw "Cellular script must never request a full preferred-APN row."
+}
+if ($source -notmatch '"--projection", "_id"') {
+ throw "Cellular script must restrict APN inspection to the non-secret row identifier."
+}
+if ($source -match 'ExpectedManufacturer|ExpectedModel') {
+ throw "Cellular mutation identity must not be caller-overridable."
+}
+
+Write-Host "All managed-cellular parser and policy checks passed."