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."