From a6abcb510b8de80a99daed5d0792d34722235752 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 22:40:05 +0700 Subject: [PATCH 1/9] Provision and verify T56 cellular readiness --- .../radio/tracking/AprsTrackingManager.java | 29 ++- .../AprsTrackingManagerSignalTest.java | 25 ++ docs/CELLULAR_PROVISIONING.md | 65 ++++++ docs/T56_DEVICE_PROFILE.md | 4 + scripts/manage-cellular.ps1 | 214 ++++++++++++++++++ scripts/prepare-t56.ps1 | 2 + scripts/prepare-t99.ps1 | 25 ++ scripts/provision-minimum-device.ps1 | 21 ++ tools/verify-cellular-policy.ps1 | 59 +++++ 9 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/se/lublin/mumla/radio/tracking/AprsTrackingManagerSignalTest.java create mode 100644 docs/CELLULAR_PROVISIONING.md create mode 100644 scripts/manage-cellular.ps1 create mode 100644 tools/verify-cellular-policy.ps1 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/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..aa55bffa --- /dev/null +++ b/docs/CELLULAR_PROVISIONING.md @@ -0,0 +1,65 @@ +# 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 a numeric preferred-network value can be written. + +## 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. +- An existing LTE-capable automatic mode with legacy fallback is preserved. LTE-only and unknown + modes are unsafe. On the verified T56 firmware only, an unsafe mode is replaced with symbolic + `LTE/TDSCDMA/CDMA/EVDO/GSM/WCDMA automatic` (OEM value 22). Never copy that numeric value to a + different Android/OEM 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 shell cannot read the selected APN on the commissioned firmware, so this is reported +as a distinct warning rather than guessed. + +## 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, or OEM-restricted APN inspection. `FAIL` covers a +SIM that is not ready, a setting mismatch, unsafe preferred mode, disabled mobile data, missing +service, or a verified missing APN. + +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/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/scripts/manage-cellular.ps1 b/scripts/manage-cellular.ps1 new file mode 100644 index 00000000..f3727eec --- /dev/null +++ b/scripts/manage-cellular.ps1 @@ -0,0 +1,214 @@ +<#[ +.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, + [string]$ExpectedManufacturer = "UNIPRO", + [string]$ExpectedModel = "ZX" +) + +$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 $ExpectedManufacturer -or $model -ine $ExpectedModel) { + throw "Unsupported hardware '$manufacturer/$model'; cellular mutation is gated to $ExpectedManufacturer/$ExpectedModel." +} +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") +$desiredRoaming = if ($DisableDataRoaming) { "0" } else { "1" } + +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)." + +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 + } + # 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 + } + if ((Get-GlobalSetting "mobile_data") -ne "1") { + throw "Mobile data could not be verified enabled." + } + if (-not ($originalMode.Lte -and $originalMode.Fallback)) { + # 22 is verified only by the exact model/firmware gate above. It is automatic, never LTE-only. + Set-GlobalSetting "preferred_network_mode" "22" + } + } +} + +$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") -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)) { $failures += "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*') { $failures += "no selected APN was detected" } +if ($apnStatus -like 'unverifiable*') { $warnings += $apnStatus } +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." +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..e39a5ac4 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, @@ -641,6 +642,30 @@ 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) { + 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 diff --git a/scripts/provision-minimum-device.ps1 b/scripts/provision-minimum-device.ps1 index 91c9a4b5..c2386f8d 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", @@ -511,6 +512,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) { @@ -735,6 +737,25 @@ $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" } + Write-Host "Reapplying idempotent T56 cellular policy and checking readbacks after reboot..." + & powershell.exe @cellularArguments + $cellularExit = $LASTEXITCODE + if ($cellularExit -eq 2) { + Write-Warning "Post-reboot cellular settings persisted; readiness remains WARN." + } elseif ($cellularExit -ne 0) { + throw "Post-reboot T56 cellular verification failed with exit code $cellularExit." + } +} Wait-MinimumReady -Phase "after reboot" -ExpectedDeviceId $deviceId ` -TimeoutSeconds $ReadyTimeoutSeconds diff --git a/tools/verify-cellular-policy.ps1 b/tools/verify-cellular-policy.ps1 new file mode 100644 index 00000000..07106a18 --- /dev/null +++ b/tools/verify-cellular-policy.ps1 @@ -0,0 +1,59 @@ +[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)', + 'Set-GlobalSetting "preferred_network_mode" "22"', + '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." +} + +Write-Host "All managed-cellular parser and policy checks passed." From c04f4c1eeef265df6136391a407385107a6d7462 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 22:40:11 +0700 Subject: [PATCH 2/9] Add secure existing-device updater --- .github/workflows/release-apk.yml | 55 +- Update Minimum Device.cmd | 17 + docs/PROVISIONING_BUNDLE_README.txt | 25 +- docs/UPDATER_RUNBOOK.md | 96 +++ scripts/update-minimum-device.ps1 | 884 ++++++++++++++++++++++++++ tests/update-minimum-device.Tests.ps1 | 171 +++++ 6 files changed, 1244 insertions(+), 4 deletions(-) create mode 100644 Update Minimum Device.cmd create mode 100644 docs/UPDATER_RUNBOOK.md create mode 100644 scripts/update-minimum-device.ps1 create mode 100644 tests/update-minimum-device.Tests.ps1 diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 87689763..bca14c49 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -118,14 +118,35 @@ 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" + signer_sha=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' "$RUNNER_TEMP/apk-signature.txt") + if [[ ! "$signer_sha" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "Could not bind exactly one APK signing certificate SHA-256 digest." >&2 + exit 1 + fi + 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." + } + ./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 +165,50 @@ 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/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" 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:[],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/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/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 +239,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 +288,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-ApkV1SignerDigests -ApkPath '$extracted_root/minimum-foss.apk'); if ('$MINIMUM_APK_SIGNER_SHA256' -notin \$signers) { throw 'Extracted updater APK signer 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 +336,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/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt index d7c51d50..8d9b8e1d 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -1,7 +1,20 @@ 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/signer, installed signer compatibility, +identity/config preservation and Ready. Read "UPDATER-README.md" in this bundle for advanced +modes and recovery guidance. Supported hardware identities ----------------------------- @@ -68,12 +81,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/UPDATER_RUNBOOK.md b/docs/UPDATER_RUNBOOK.md new file mode 100644 index 00000000..813012ed --- /dev/null +++ b/docs/UPDATER_RUNBOOK.md @@ -0,0 +1,96 @@ +# 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 and Android Platform Tools (`adb.exe`) in `PATH`. +- 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 compares the installed APK certificate with the bundled APK +certificate before any installation. 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. + +## 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. +- `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 current extension point intentionally has no +cellular migration. Issue #11 behavior may be added only after its policy and device acceptance are +reviewed, with an idempotent model-gated handler and tests. + +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/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 new file mode 100644 index 00000000..f1f80704 --- /dev/null +++ b/scripts/update-minimum-device.ps1 @@ -0,0 +1,884 @@ +<# +.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" +$IdentityReportAction = "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", + "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 Get-ApkV1SignerDigests { + param([Parameter(Mandatory)][string]$ApkPath) + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.Security + $archive = [IO.Compression.ZipFile]::OpenRead($ApkPath) + try { + $signatureEntries = @($archive.Entries | Where-Object { + $_.FullName -match '^META-INF/[^/]+\.(RSA|DSA|EC)$' + }) + if ($signatureEntries.Count -eq 0) { + Throw-UpdateError "APK_V1_SIGNATURE_REQUIRED" "The APK has no JAR signing block; this standalone updater cannot verify its signer." + } + $digests = New-Object System.Collections.Generic.List[string] + foreach ($entry in $signatureEntries) { + $stream = $entry.Open() + $memory = New-Object IO.MemoryStream + try { + $stream.CopyTo($memory) + $cms = New-Object System.Security.Cryptography.Pkcs.SignedCms + $cms.Decode($memory.ToArray()) + foreach ($certificate in $cms.Certificates) { + $sha = [Security.Cryptography.SHA256]::Create() + try { + $digest = ([BitConverter]::ToString($sha.ComputeHash($certificate.RawData))).Replace('-', '') + if (-not $digests.Contains($digest)) { $digests.Add($digest) } + } finally { $sha.Dispose() } + } + } finally { + $stream.Dispose() + $memory.Dispose() + } + } + return @($digests) + } catch { + if ($_.Exception.Message -match '^\[[A-Z0-9_]+\]') { throw } + Throw-UpdateError "APK_SIGNATURE_INVALID" "The APK signing certificate could not be verified." + } finally { + $archive.Dispose() + } +} + +function Assert-SignerCompatibility { + param([string[]]$InstalledDigests, [Parameter(Mandatory)][string]$TargetDigest) + if (@($InstalledDigests | Where-Object { $_ -ceq $TargetDigest.ToUpperInvariant() }).Count -ne 1) { + 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) + $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 ($sameModel.Count -eq 1) { return $sameModel[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) + $match = [regex]::Match($Text, 'data="?deviceId=([A-Z0-9]{6});activeDeviceId=([A-Z0-9*]{1,6});configVersion=(-?\d+);pending=(true|false);lastSuccessMs=(\d+)"?') + if (-not $match.Success) { return $null } + return [pscustomobject]@{ + DeviceId = $match.Groups[1].Value + ActiveDeviceId = $match.Groups[2].Value + ConfigVersion = [int]$match.Groups[3].Value + Pending = $match.Groups[4].Value -eq "true" + LastSuccessMs = [long]$match.Groups[5].Value + } +} + +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." + } + # No migrations are approved in this updater revision. Future integrations (including #11) + # must add a reviewed handler here and tests before a manifest may name the migration. + Throw-UpdateError "MIGRATION_NOT_IMPLEMENTED" "Release requests an updater migration that this reviewed script does not implement." + } + return @($required) +} + +function New-MigrationResult { + param([string]$Id, [ValidateSet("APPLIED", "ALREADY_OK", "SKIPPED", "FAILED")][string]$Outcome) + return [pscustomobject]@{ Id = $Id; Outcome = $Outcome } +} + +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 ($result in @($Results)) { + $profile = if ($result.Profile) { $result.Profile } else { "UNKNOWN" } + $deviceId = if ($result.DeviceId) { $result.DeviceId } else { "------" } + $lines.Add(("{0} / {1} {2} {3}" -f $profile, $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 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 { + $result = Invoke-TargetAdb -Arguments @("shell", "am", "broadcast", "-W", "-a", $IdentityReportAction, "-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-ApkV1SignerDigests -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, [int]$TimeoutSeconds) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + $candidates = @() + foreach ($record in @(Get-AdbRecords | Where-Object { $_.State -eq "device" })) { + $script:CurrentTarget = $record + try { $candidates += Add-HardwareIdentity -Target $record } catch { } + } + $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer ` + -Model $OriginalTarget.Model -OriginalSerial $OriginalTarget.Serial + if ($candidate) { return $candidate } + } + Throw-UpdateError "REBOOT_TARGET_AMBIGUOUS" "The same supported profile could not be re-identified uniquely after reboot." +} + +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 + @( + "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)", + "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 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" + ConfigVersionBefore = -1; ConfigVersionAfter = -1; Result = "FAIL" + ErrorCategory = ""; Detail = "" + } + try { + $records = @(Get-AdbRecords) + $target = Select-TargetRecord -Records $records -RequestedSerial $Serial -RequestedTransportId $TransportId + $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 + $deviceId = Get-Identity + $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." } + } + $before = Get-ProvisioningStatus + if (-not $before -or $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.ConfigVersionBefore = $before.ConfigVersion + $result.PreReady = [bool](Get-ReadyState) + $installedSigner = @(Get-InstalledSignerDigests -RemoteApkPath $installed.BaseApkPath) + Assert-SignerCompatibility -InstalledDigests $installedSigner -TargetDigest ([string]$Bundle.Manifest.signerSha256).ToUpperInvariant() + $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) + 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 = "PASS" + $result.Detail = "REPORT_ONLY; compatible, no mutation performed" + $result.PostReady = $result.PreReady + return [pscustomobject]$result + } + 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..." + Install-InPlace -ApkPath $Bundle.ApkPath -Downgrade:($comparison -gt 0) + $result.Migrations += New-MigrationResult -Id "APK_VERSION" -Outcome "APPLIED" + } + foreach ($migration in $requiredMigrations) { $result.Migrations += $migration } + $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 + if ($after.ConfigVersion -lt $before.ConfigVersion -or $after.LastSuccessMs -le 0) { + Throw-UpdateError "CONFIG_REGRESSION" "Managed configuration or last-known-good verification regressed after update." + } + $needsReboot = [bool]$Bundle.Manifest.rebootRequired -or $FullRebootAcceptance + if ($needsReboot) { + $original = $script:CurrentTarget + Invoke-TargetAdb -Arguments @("reboot") | Out-Null + $script:CurrentTarget = Wait-ReturningTarget -OriginalTarget $original -TimeoutSeconds $BootTimeoutSeconds + Wait-BootCompleted -TimeoutSeconds $BootTimeoutSeconds + $afterReboot = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds + if ($afterReboot.ConfigVersion -lt $before.ConfigVersion) { + Throw-UpdateError "REBOOT_CONFIG_REGRESSION" "Managed configuration regressed after reboot." + } + $result.RebootAcceptance = "READY_SAME_ID" + } + $result.Result = "PASS" + $result.Detail = if ($comparison -eq 0) { "ALREADY_OK; same-ID Ready verified" } 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*', '') + return [pscustomobject]$result + } +} + +if ($LibraryOnly) { return } + +$Host.UI.RawUI.WindowTitle = "Minimum One-Shot Updater" +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-ApkV1SignerDigests -ApkPath $bundle.ApkPath) +if (@($targetSigners | Where-Object { $_ -ceq ([string]$bundle.Manifest.signerSha256).ToUpperInvariant() }).Count -ne 1) { + Throw-UpdateError "APK_SIGNER_BINDING" "The APK signer does not match the exact release manifest." +} +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") +$sessionId = ([guid]::NewGuid().ToString("N").Substring(0, 12)).ToUpperInvariant() +$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) + +$summary = Format-SessionSummary -Results @($results) -TargetVersion ([string]$bundle.Manifest.versionName) +Write-Host "" +Write-Host $summary +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..2f838661 --- /dev/null +++ b/tests/update-minimum-device.Tests.ps1 @@ -0,0 +1,171 @@ +$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 "new" (Find-ReturningCandidate $records "UNIPRO" "ZX" "old").Serial "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 managed-status 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"' + Assert-Equal "A1B2C3" $status.DeviceId "device id" + Assert-Equal 14 $status.ConfigVersion "config" + Assert-True (-not $status.Pending) "pending false" +} + +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 +} + +Test-Case "migration dispatch refuses unknown requested behavior" { + $migration = [pscustomobject]@{ id="issue-11-cellular"; fromVersionCodeMax=3070300; toVersionCode=3070400; profiles=@("T56"); rebootRequired=$true; irreversible=$false } + Assert-ThrowsCode { Get-RequiredMigrations @($migration) 3070300 3070400 "T56" } "MIGRATION_NOT_IMPLEMENTED" "unapproved migration" + Assert-ThrowsCode { Get-RequiredMigrations @($migration) 3070300 3070400 "T99" } "MIGRATION_NOT_IMPLEMENTED" "unknown migration on other model" +} + +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", "assets/t99-wifi-provisioner.apk", "minimum-foss.apk", "minimum-foss.apk.sha256", + "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.4" -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.4"; applicationId="se.lublin.mumla"; versionCode=3070400 + versionName="3.7.4"; apkFile="minimum-foss.apk"; apkSha256=$apkHash + signerSha256=("A" * 64); rebootRequired=$false; migrations=@(); 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.4" $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]3070300) $identity.VersionCode "real APK version code" + Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" + $signers = @(Get-ApkV1SignerDigests -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" + } +} + +Write-Host "Updater tests: $script:Passed passed, $script:Failed failed" +if ($script:Failed -gt 0) { exit 1 } From ca421b61f52206ddaeb34231ecfa78535e3bc9b0 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 23:17:14 +0700 Subject: [PATCH 3/9] Integrate secure updater with T56 cellular policy --- .github/workflows/release-apk.yml | 11 +- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../mumla/radio/DeviceIdentityManager.java | 6 + .../mumla/radio/RadioConfigRepository.java | 21 ++ .../mumla/radio/RadioProvisionReceiver.java | 54 ++- .../radio/RadioProvisionReceiverTest.java | 14 + docs/CELLULAR_PROVISIONING.md | 23 +- docs/GITHUB_RELEASE_WORKFLOW.md | 7 +- docs/PROJECT_STATUS.md | 12 +- docs/PROVISIONING_BUNDLE_README.txt | 9 + docs/TEST_MATRIX.md | 4 +- docs/UPDATER_RUNBOOK.md | 18 +- docs/WORK_LOG.md | 7 + scripts/manage-cellular.ps1 | 32 +- scripts/prepare-t99.ps1 | 3 + scripts/provision-minimum-device.ps1 | 16 +- scripts/update-minimum-device.ps1 | 321 +++++++++++++----- tests/update-minimum-device.Tests.ps1 | 55 ++- tools/verify-cellular-policy.ps1 | 11 +- 20 files changed, 487 insertions(+), 140 deletions(-) create mode 100644 app/src/test/java/se/lublin/mumla/radio/RadioProvisionReceiverTest.java diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index bca14c49..8ad8cc89 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: @@ -146,6 +146,7 @@ jobs: $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: | @@ -171,6 +172,7 @@ jobs: 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/" @@ -178,6 +180,7 @@ jobs: "$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" @@ -191,7 +194,7 @@ jobs: --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:[],files:$files[0]}' \ + '{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" @@ -199,12 +202,14 @@ jobs: "$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" \ @@ -290,7 +295,7 @@ jobs: fi 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-ApkV1SignerDigests -ApkPath '$extracted_root/minimum-foss.apk'); if ('$MINIMUM_APK_SIGNER_SHA256' -notin \$signers) { throw 'Extracted updater APK signer verification failed.' }" + ". '$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 ('$MINIMUM_APK_SIGNER_SHA256' -notin \$signers) { throw 'Extracted updater APK signer 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 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/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/docs/CELLULAR_PROVISIONING.md b/docs/CELLULAR_PROVISIONING.md index aa55bffa..d2924f48 100644 --- a/docs/CELLULAR_PROVISIONING.md +++ b/docs/CELLULAR_PROVISIONING.md @@ -3,7 +3,7 @@ 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 a numeric preferred-network value can be written. +hardware or firmware is rejected before any cellular setting can be changed. ## Policy and cost warning @@ -11,25 +11,28 @@ hardware or firmware is rejected before a numeric preferred-network value can be - 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. -- An existing LTE-capable automatic mode with legacy fallback is preserved. LTE-only and unknown - modes are unsafe. On the verified T56 firmware only, an unsafe mode is replaced with symbolic - `LTE/TDSCDMA/CDMA/EVDO/GSM/WCDMA automatic` (OEM value 22). Never copy that numeric value to a - different Android/OEM build. +- 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 shell cannot read the selected APN on the commissioned firmware, so this is reported -as a distinct warning rather than guessed. +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, or OEM-restricted APN inspection. `FAIL` covers a -SIM that is not ready, a setting mismatch, unsafe preferred mode, disabled mobile data, missing -service, or a verified missing APN. +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 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..37d847d4 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,16 @@ 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, preserves Device ID, selected channel, active + Last Known Good config digest and managed safe-settings digest, and identity-correlates a device + after reboot. 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 8d9b8e1d..6ab6f2a7 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -16,6 +16,13 @@ all bundle file hashes, APK checksum/package/version/signer, installed signer co identity/config preservation and Ready. Read "UPDATER-README.md" in this bundle for advanced modes and recovery guidance. +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 ----------------------------- @@ -30,6 +37,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 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index d84fbbf4..ac5c71a0 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 AST, 14 updater policy/fixture tests, cellular policy verifier, exact workflow allowlists and full `apksigner` contract; 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 index 813012ed..d546fa5b 100644 --- a/docs/UPDATER_RUNBOOK.md +++ b/docs/UPDATER_RUNBOOK.md @@ -7,7 +7,9 @@ removes OEM apps, reopens Location consent or reapplies unrelated device setting ## Requirements and trust boundary -- Windows 10 or 11 and Android Platform Tools (`adb.exe`) in `PATH`. +- 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 @@ -39,6 +41,12 @@ requires reboot, or `-FullRebootAcceptance` is requested, PASS also requires the 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. + ## Safe advanced modes From PowerShell, optional modes include: @@ -84,9 +92,11 @@ 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 current extension point intentionally has no -cellular migration. Issue #11 behavior may be added only after its policy and device acceptance are -reviewed, with an idempotent model-gated handler and tests. +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 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 index f3727eec..3ea26de7 100644 --- a/scripts/manage-cellular.ps1 +++ b/scripts/manage-cellular.ps1 @@ -16,9 +16,7 @@ param( [int]$AdbPort = 5037, [switch]$DisableDataRoaming, [switch]$VerifyOnly, - [ValidateRange(5, 180)][int]$TimeoutSeconds = 45, - [string]$ExpectedManufacturer = "UNIPRO", - [string]$ExpectedModel = "ZX" + [ValidateRange(5, 180)][int]$TimeoutSeconds = 45 ) $ErrorActionPreference = "Stop" @@ -128,8 +126,8 @@ $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 $ExpectedManufacturer -or $model -ine $ExpectedModel) { - throw "Unsupported hardware '$manufacturer/$model'; cellular mutation is gated to $ExpectedManufacturer/$ExpectedModel." +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." @@ -137,28 +135,31 @@ if ($api -ne "22" -or $build -ne "T56" -or $baseband -notlike "LANSUS1-L811*") { $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)." +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." } - if (-not ($originalMode.Lte -and $originalMode.Fallback)) { - # 22 is verified only by the exact model/firmware gate above. It is automatic, never LTE-only. - Set-GlobalSetting "preferred_network_mode" "22" - } + # 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. } } @@ -182,7 +183,8 @@ $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") -AllowFailure) -join "`n" +$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:') { @@ -193,11 +195,14 @@ $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)) { $failures += "preferred mode is not safe LTE automatic/fallback" } +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*') { $failures += "no selected APN was detected" } +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" } @@ -206,6 +211,7 @@ $outcome = if ($failures.Count) { "FAIL" } elseif ($warnings.Count) { "WARN" } e 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." diff --git a/scripts/prepare-t99.ps1 b/scripts/prepare-t99.ps1 index e39a5ac4..364ecdad 100644 --- a/scripts/prepare-t99.ps1 +++ b/scripts/prepare-t99.ps1 @@ -43,6 +43,7 @@ param( ) $ErrorActionPreference = "Stop" +$script:CellularReadinessWarning = $false $PackageName = "com.loudtalks" $MinimumPackage = "se.lublin.mumla" $MinimumActivity = "se.lublin.mumla/.radio.RadioShellActivity" @@ -661,6 +662,7 @@ if ($TargetName -eq "T56") { & 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." @@ -939,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 c2386f8d..e4872686 100644 --- a/scripts/provision-minimum-device.ps1 +++ b/scripts/provision-minimum-device.ps1 @@ -68,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'))) { @@ -521,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." } } @@ -747,11 +751,13 @@ if ($target.Profile -eq "T56") { $cellularArguments += @("-Serial", $returningTarget.Serial) } if ($DisableDataRoaming) { $cellularArguments += "-DisableDataRoaming" } - Write-Host "Reapplying idempotent T56 cellular policy and checking readbacks after reboot..." + $cellularArguments += "-VerifyOnly" + Write-Host "Verifying T56 cellular-policy persistence after reboot without rewriting settings..." & powershell.exe @cellularArguments $cellularExit = $LASTEXITCODE if ($cellularExit -eq 2) { - Write-Warning "Post-reboot cellular settings persisted; readiness remains WARN." + $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." } @@ -759,4 +765,8 @@ if ($target.Profile -eq "T56") { 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 index f1f80704..e884e8be 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -31,7 +31,7 @@ $ErrorActionPreference = "Stop" $MinimumPackage = "se.lublin.mumla" $MinimumActivity = "se.lublin.mumla/.radio.RadioShellActivity" $ProvisionReceiver = "se.lublin.mumla/.radio.RadioProvisionReceiver" -$IdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_IDENTITY" +$IdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_EXISTING_IDENTITY" $ProvisionStatusAction = "se.lublin.mumla.action.PROVISION_REPORT_STATUS" $script:AdbExecutable = "" $script:ServerArguments = @() @@ -276,6 +276,8 @@ function Read-ReleaseBundle { "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", @@ -324,45 +326,47 @@ function Read-ReleaseBundle { return [pscustomobject]@{ Root = $resolvedRoot; Manifest = $manifest; ApkPath = $apkPath } } -function Get-ApkV1SignerDigests { +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, + (Join-Path $env:LOCALAPPDATA "Android\Sdk")) | Where-Object { $_ } + foreach ($sdkRoot in $sdkRoots) { + $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.bat" } | + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + 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 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() } | Sort-Object -Unique) + 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) - Add-Type -AssemblyName System.IO.Compression.FileSystem - Add-Type -AssemblyName System.Security - $archive = [IO.Compression.ZipFile]::OpenRead($ApkPath) + $apksigner = Resolve-ApkSigner + $previous = $ErrorActionPreference try { - $signatureEntries = @($archive.Entries | Where-Object { - $_.FullName -match '^META-INF/[^/]+\.(RSA|DSA|EC)$' - }) - if ($signatureEntries.Count -eq 0) { - Throw-UpdateError "APK_V1_SIGNATURE_REQUIRED" "The APK has no JAR signing block; this standalone updater cannot verify its signer." - } - $digests = New-Object System.Collections.Generic.List[string] - foreach ($entry in $signatureEntries) { - $stream = $entry.Open() - $memory = New-Object IO.MemoryStream - try { - $stream.CopyTo($memory) - $cms = New-Object System.Security.Cryptography.Pkcs.SignedCms - $cms.Decode($memory.ToArray()) - foreach ($certificate in $cms.Certificates) { - $sha = [Security.Cryptography.SHA256]::Create() - try { - $digest = ([BitConverter]::ToString($sha.ComputeHash($certificate.RawData))).Replace('-', '') - if (-not $digests.Contains($digest)) { $digests.Add($digest) } - } finally { $sha.Dispose() } - } - } finally { - $stream.Dispose() - $memory.Dispose() - } - } - return @($digests) - } catch { - if ($_.Exception.Message -match '^\[[A-Z0-9_]+\]') { throw } - Throw-UpdateError "APK_SIGNATURE_INVALID" "The APK signing certificate could not be verified." - } finally { - $archive.Dispose() + $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 { @@ -416,7 +420,8 @@ function Select-TargetRecord { } function Find-ReturningCandidate { - param([object[]]$Records, [string]$Manufacturer, [string]$Model, [string]$OriginalSerial) + 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 @@ -425,7 +430,12 @@ function Find-ReturningCandidate { $sameModel = @($Records | Where-Object { $_.State -eq "device" -and $_.Manufacturer -ieq $Manufacturer -and $_.Model -ieq $Model }) - if ($sameModel.Count -eq 1) { return $sameModel[0] } + 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 } @@ -439,7 +449,7 @@ function Parse-PackageState { function Parse-ProvisioningStatus { param([string]$Text) - $match = [regex]::Match($Text, 'data="?deviceId=([A-Z0-9]{6});activeDeviceId=([A-Z0-9*]{1,6});configVersion=(-?\d+);pending=(true|false);lastSuccessMs=(\d+)"?') + $match = [regex]::Match($Text, 'data="?deviceId=([A-Z0-9]{6});activeDeviceId=([A-Z0-9*]{1,6});configVersion=(-?\d+);pending=(true|false);lastSuccessMs=(\d+);selectedChannel=([a-zA-Z0-9._-]{0,64});activeConfigSha256=([0-9A-F]{64});safeSettingsSha256=([0-9A-F]{64})"?') if (-not $match.Success) { return $null } return [pscustomobject]@{ DeviceId = $match.Groups[1].Value @@ -447,6 +457,22 @@ function Parse-ProvisioningStatus { ConfigVersion = [int]$match.Groups[3].Value Pending = $match.Groups[4].Value -eq "true" LastSuccessMs = [long]$match.Groups[5].Value + SelectedChannel = $match.Groups[6].Value + ActiveConfigSha256 = $match.Groups[7].Value + SafeSettingsSha256 = $match.Groups[8].Value + } +} + +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 -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." } } @@ -458,20 +484,78 @@ function Get-RequiredMigrations { $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 + [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." } - # No migrations are approved in this updater revision. Future integrations (including #11) - # must add a reviewed handler here and tests before a manifest may name the migration. - Throw-UpdateError "MIGRATION_NOT_IMPLEMENTED" "Release requests an updater migration that this reviewed script does not implement." + 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) - return [pscustomobject]@{ Id = $Id; Outcome = $Outcome } + 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 { @@ -479,10 +563,13 @@ function Format-SessionSummary { $lines = New-Object System.Collections.Generic.List[string] $lines.Add("Minimum update session") $lines.Add("Target version: $TargetVersion") - foreach ($result in @($Results)) { - $profile = if ($result.Profile) { $result.Profile } else { "UNKNOWN" } - $deviceId = if ($result.DeviceId) { $result.DeviceId } else { "------" } - $lines.Add(("{0} / {1} {2} {3}" -f $profile, $deviceId, $result.Result, $result.Detail)) + 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 @@ -599,7 +686,7 @@ function Get-InstalledSignerDigests { 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-ApkV1SignerDigests -ApkPath $temporary) + return @(Get-ApkSignerDigests -ApkPath $temporary) } finally { if (Test-Path -LiteralPath $temporary -PathType Leaf) { Remove-Item -LiteralPath $temporary -Force } } @@ -668,7 +755,7 @@ function Ensure-RyksInstallPolicy { } function Wait-ReturningTarget { - param($OriginalTarget, [int]$TimeoutSeconds) + param($OriginalTarget, [string]$ExpectedDeviceId, [int]$TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 2 @@ -680,6 +767,17 @@ function Wait-ReturningTarget { $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer ` -Model $OriginalTarget.Model -OriginalSerial $OriginalTarget.Serial if ($candidate) { return $candidate } + # A changed/ambiguous serial is never accepted from model identity alone. Query the narrow + # non-secret app identity on each same-model candidate and require exactly one Device-ID match. + foreach ($record in @($candidates | Where-Object { + $_.Manufacturer -ieq $OriginalTarget.Manufacturer -and $_.Model -ieq $OriginalTarget.Model + })) { + $script:CurrentTarget = $record + try { $record | Add-Member DeviceId (Get-Identity) -Force } catch { } + } + $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer ` + -Model $OriginalTarget.Model -OriginalSerial "" -ExpectedDeviceId $ExpectedDeviceId + if ($candidate) { return $candidate } } Throw-UpdateError "REBOOT_TARGET_AMBIGUOUS" "The same supported profile could not be re-identified uniquely after reboot." } @@ -706,6 +804,14 @@ function Write-SanitizedReports { $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)", @@ -714,6 +820,7 @@ function Write-SanitizedReports { "Previous version: $($Result.PreviousVersion)", "Target version: $($Result.TargetVersion)", "Artifact verification: $($Result.ArtifactVerification)", + "Migrations: $migrationSummary", "Pre-update Ready: $($Result.PreReady)", "Post-update Ready: $($Result.PostReady)", "Reboot acceptance: $($Result.RebootAcceptance)", @@ -727,6 +834,18 @@ function Write-SanitizedReports { 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]@{ @@ -737,6 +856,9 @@ function Invoke-OneUpdate { 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 @@ -788,6 +910,10 @@ function Invoke-OneUpdate { $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" @@ -797,10 +923,10 @@ function Invoke-OneUpdate { } $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" } - foreach ($migration in $requiredMigrations) { $result.Migrations += $migration } $postPackage = Get-InstalledPackageState if ($postPackage.VersionCode -ne [long]$Bundle.Manifest.versionCode -or $postPackage.VersionName -cne [string]$Bundle.Manifest.versionName) { @@ -810,28 +936,49 @@ function Invoke-OneUpdate { $after = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds $result.PostReady = $true $result.ConfigVersionAfter = $after.ConfigVersion - if ($after.ConfigVersion -lt $before.ConfigVersion -or $after.LastSuccessMs -le 0) { - Throw-UpdateError "CONFIG_REGRESSION" "Managed configuration or last-known-good verification regressed after update." - } - $needsReboot = [bool]$Bundle.Manifest.rebootRequired -or $FullRebootAcceptance + 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 -TimeoutSeconds $BootTimeoutSeconds + $script:CurrentTarget = Wait-ReturningTarget -OriginalTarget $original -ExpectedDeviceId $deviceId -TimeoutSeconds $BootTimeoutSeconds Wait-BootCompleted -TimeoutSeconds $BootTimeoutSeconds $afterReboot = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds - if ($afterReboot.ConfigVersion -lt $before.ConfigVersion) { - Throw-UpdateError "REBOOT_CONFIG_REGRESSION" "Managed configuration regressed after reboot." + Assert-PreservedState -Before $before -After $afterReboot -Phase "after reboot" + foreach ($migration in @($requiredMigrations | Where-Object { [bool]$_.rebootRequired })) { + $result.Migrations += Invoke-RequiredMigration -Migration $migration -VerifyOnly } $result.RebootAcceptance = "READY_SAME_ID" } - $result.Result = "PASS" - $result.Detail = if ($comparison -eq 0) { "ALREADY_OK; same-ID Ready verified" } else { "UPDATED; same-ID Ready verified" } + $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) { + try { + $recoveryPackage = Get-InstalledPackageState + Invoke-TargetAdb -Arguments @("shell", "am", "start", "-n", $MinimumActivity) | Out-Null + $recovered = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds ([Math]::Min($ReadyTimeoutSeconds, 90)) + 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 } } @@ -839,24 +986,39 @@ function Invoke-OneUpdate { if ($LibraryOnly) { return } $Host.UI.RawUI.WindowTitle = "Minimum One-Shot Updater" -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-ApkV1SignerDigests -ApkPath $bundle.ApkPath) -if (@($targetSigners | Where-Object { $_ -ceq ([string]$bundle.Manifest.signerSha256).ToUpperInvariant() }).Count -ne 1) { - Throw-UpdateError "APK_SIGNER_BINDING" "The APK signer does not match the exact release manifest." -} -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") $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) + if (@($targetSigners | Where-Object { $_ -ceq ([string]$bundle.Manifest.signerSha256).ToUpperInvariant() }).Count -ne 1) { + Throw-UpdateError "APK_SIGNER_BINDING" "The APK signer does not match the exact release manifest." + } + 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"; 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 { @@ -879,6 +1041,7 @@ do { $summary = Format-SessionSummary -Results @($results) -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 index 2f838661..e1541780 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -60,19 +60,31 @@ Test-Case "reboot transport change chooses unique same profile" { [pscustomobject]@{ Serial="new"; State="device"; TransportId=9; Manufacturer="UNIPRO"; Model="ZX" }, [pscustomobject]@{ Serial="other"; State="device"; TransportId=10; Manufacturer="Other"; Model="Other" } ) - Assert-Equal "new" (Find-ReturningCandidate $records "UNIPRO" "ZX" "old").Serial "returning" + 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 managed-status transcripts" { +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"' + $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" { @@ -86,10 +98,20 @@ Test-Case "matching signer accepted" { Assert-SignerCompatibility @($signer) $signer } -Test-Case "migration dispatch refuses unknown requested behavior" { - $migration = [pscustomobject]@{ id="issue-11-cellular"; fromVersionCodeMax=3070300; toVersionCode=3070400; profiles=@("T56"); rebootRequired=$true; irreversible=$false } - Assert-ThrowsCode { Get-RequiredMigrations @($migration) 3070300 3070400 "T56" } "MIGRATION_NOT_IMPLEMENTED" "unapproved migration" - Assert-ThrowsCode { Get-RequiredMigrations @($migration) 3070300 3070400 "T99" } "MIGRATION_NOT_IMPLEMENTED" "unknown migration on other model" +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" { @@ -123,14 +145,15 @@ Test-Case "bundle allowlist and checksum reject tampering" { 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", "assets/t99-wifi-provisioner.apk", "minimum-foss.apk", "minimum-foss.apk.sha256", + "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.4" -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 @@ -138,13 +161,15 @@ Test-Case "bundle allowlist and checksum reject tampering" { [ordered]@{ path=$_; sha256=Get-FileSha256 (Join-Path $root $_) } } $manifest = [ordered]@{ - schemaVersion=1; releaseTag="3.7.4"; applicationId="se.lublin.mumla"; versionCode=3070400 - versionName="3.7.4"; apkFile="minimum-foss.apk"; apkSha256=$apkHash - signerSha256=("A" * 64); rebootRequired=$false; migrations=@(); files=$files + 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.4" $bundle.Manifest.releaseTag "valid bundle" + 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" @@ -159,9 +184,9 @@ 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]3070300) $identity.VersionCode "real APK version code" + Assert-Equal ([long]3070301) $identity.VersionCode "real APK version code" Assert-True ($identity.VersionName -match '-debug$') "real APK debug version" - $signers = @(Get-ApkV1SignerDigests -ApkPath $realApkPath) + $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" } diff --git a/tools/verify-cellular-policy.ps1 b/tools/verify-cellular-policy.ps1 index 07106a18..7dadf431 100644 --- a/tools/verify-cellular-policy.ps1 +++ b/tools/verify-cellular-policy.ps1 @@ -46,7 +46,7 @@ foreach ($required in @( 'CELLULAR COST WARNING', '[switch]$DisableDataRoaming', 'if ((Get-GlobalSetting "data_roaming") -ne $desiredRoaming)', - 'Set-GlobalSetting "preferred_network_mode" "22"', + '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')) { @@ -55,5 +55,14 @@ foreach ($required in @( 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." From 7971b7c8480ac6783c8e9e7ab8294465707e6976 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 23:42:04 +0700 Subject: [PATCH 4/9] Harden legacy existing-device updater bridge --- .github/workflows/release-apk.yml | 9 +- docs/PROJECT_STATUS.md | 6 +- docs/PROVISIONING_BUNDLE_README.txt | 7 +- docs/TEST_MATRIX.md | 2 +- docs/UPDATER_RUNBOOK.md | 14 +- scripts/update-minimum-device.ps1 | 191 ++++++++++++++++-------- tests/update-minimum-device.Tests.ps1 | 200 ++++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 70 deletions(-) diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 8ad8cc89..11252ce3 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -121,11 +121,12 @@ jobs: 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" | tee "$RUNNER_TEMP/apk-signature.txt" - signer_sha=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' "$RUNNER_TEMP/apk-signature.txt") - if [[ ! "$signer_sha" =~ ^[0-9a-fA-F]{64}$ ]]; then - echo "Could not bind exactly one APK signing certificate SHA-256 digest." >&2 + 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" @@ -295,7 +296,7 @@ jobs: fi 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 ('$MINIMUM_APK_SIGNER_SHA256' -notin \$signers) { throw 'Extracted updater APK signer verification failed.' }" + ". '$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 diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 37d847d4..bdef7e77 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -20,9 +20,11 @@ document disagrees with this file, verify the code and update this file first. 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, preserves Device ID, selected channel, active + 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 versioned `CELLULAR_POLICY_V1_T56` migration applies only from versionCode + after reboot. The one-time legacy `3070300` bridge proves provisioned status before its legacy + identity read, 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. diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt index 6ab6f2a7..5d4d5743 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -12,10 +12,15 @@ Choose the correct workflow 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/signer, installed signer compatibility, +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, legacy status must first prove an +already-provisioned, active, non-pending configuration with Last Known Good evidence. Only then is +legacy identity read. 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 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index ac5c71a0..1b9fec0c 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -6,7 +6,7 @@ | FOSS debug APK build | PASS | `:app:assembleFossDebug` | | 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 AST, 14 updater policy/fixture tests, cellular policy verifier, exact workflow allowlists and full `apksigner` contract; E7ROW7 same-debug-signer update and T99/RYKS physical acceptance remain open. | +| Existing-device updater integration | PASS IN STATIC/AUTOMATED TESTS / PHYSICAL OPEN | PowerShell 5.1 AST and 24 policy/fixture/state-machine tests cover the legacy bridge, signer/no-mutation, 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 index d546fa5b..1c6f55ad 100644 --- a/docs/UPDATER_RUNBOOK.md +++ b/docs/UPDATER_RUNBOOK.md @@ -31,8 +31,8 @@ Do not use a bundle whose ZIP checksum does not match the checksum on its exact 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 compares the installed APK certificate with the bundled APK -certificate before any installation. It rejects an unintended downgrade. It uses only an in-place +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 @@ -47,6 +47,14 @@ applies the guarded roaming/mobile-data/automatic-LTE policy, then requires a re 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. For this single +`3070300`-or-older to `3070301` bridge, the updater queries status first and rejects an unprovisioned, +pending, inactive or missing-LKG radio without calling the legacy identity action. Only after that +proof may it read the legacy identity. 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: @@ -81,6 +89,8 @@ is not automated; the old APK is not included in the bundle. - `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_NOT_PROVISIONED` or `LEGACY_BRIDGE_UNSUPPORTED`: no identity read or install was allowed; + provision the radio through the reviewed provisioning path or use the exact approved bridge. - `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 diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index e884e8be..7295b726 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -31,7 +31,8 @@ $ErrorActionPreference = "Stop" $MinimumPackage = "se.lublin.mumla" $MinimumActivity = "se.lublin.mumla/.radio.RadioShellActivity" $ProvisionReceiver = "se.lublin.mumla/.radio.RadioProvisionReceiver" -$IdentityReportAction = "se.lublin.mumla.action.PROVISION_REPORT_EXISTING_IDENTITY" +$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 = @() @@ -329,8 +330,10 @@ function Read-ReleaseBundle { 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, - (Join-Path $env:LOCALAPPDATA "Android\Sdk")) | Where-Object { $_ } + $sdkRoots = @(@($env:ANDROID_HOME, $env:ANDROID_SDK_ROOT) | Where-Object { $_ }) + if ($env:LOCALAPPDATA) { + $sdkRoots += Join-Path $env:LOCALAPPDATA "Android\Sdk" + } foreach ($sdkRoot in $sdkRoots) { $buildTools = Join-Path $sdkRoot "build-tools" if (-not (Test-Path -LiteralPath $buildTools -PathType Container)) { continue } @@ -346,7 +349,7 @@ 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() } | Sort-Object -Unique) + ForEach-Object { $_.Groups[1].Value.ToUpperInvariant() }) if ($digests.Count -eq 0) { Throw-UpdateError "APK_SIGNATURE_INVALID" "apksigner did not report a verified signing certificate." } @@ -371,7 +374,8 @@ function Get-ApkSignerDigests { function Assert-SignerCompatibility { param([string[]]$InstalledDigests, [Parameter(Mandatory)][string]$TargetDigest) - if (@($InstalledDigests | Where-Object { $_ -ceq $TargetDigest.ToUpperInvariant() }).Count -ne 1) { + $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." } } @@ -449,17 +453,32 @@ function Parse-PackageState { function Parse-ProvisioningStatus { param([string]$Text) - $match = [regex]::Match($Text, 'data="?deviceId=([A-Z0-9]{6});activeDeviceId=([A-Z0-9*]{1,6});configVersion=(-?\d+);pending=(true|false);lastSuccessMs=(\d+);selectedChannel=([a-zA-Z0-9._-]{0,64});activeConfigSha256=([0-9A-F]{64});safeSettingsSha256=([0-9A-F]{64})"?') - if (-not $match.Success) { return $null } + $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]@{ - DeviceId = $match.Groups[1].Value - ActiveDeviceId = $match.Groups[2].Value - ConfigVersion = [int]$match.Groups[3].Value - Pending = $match.Groups[4].Value -eq "true" - LastSuccessMs = [long]$match.Groups[5].Value - SelectedChannel = $match.Groups[6].Value - ActiveConfigSha256 = $match.Groups[7].Value - SafeSettingsSha256 = $match.Groups[8].Value + 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." } } @@ -468,12 +487,22 @@ function Assert-PreservedState { 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 -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 { @@ -653,7 +682,9 @@ function Add-HardwareIdentity { } function Get-Identity { - $result = Invoke-TargetAdb -Arguments @("shell", "am", "broadcast", "-W", "-a", $IdentityReportAction, "-n", $ProvisionReceiver) + 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 @@ -757,29 +788,40 @@ function Ensure-RyksInstallPolicy { function Wait-ReturningTarget { param($OriginalTarget, [string]$ExpectedDeviceId, [int]$TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) - while ((Get-Date) -lt $deadline) { - Start-Sleep -Seconds 2 - $candidates = @() - foreach ($record in @(Get-AdbRecords | Where-Object { $_.State -eq "device" })) { - $script:CurrentTarget = $record - try { $candidates += Add-HardwareIdentity -Target $record } catch { } - } - $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer ` - -Model $OriginalTarget.Model -OriginalSerial $OriginalTarget.Serial - if ($candidate) { return $candidate } - # A changed/ambiguous serial is never accepted from model identity alone. Query the narrow - # non-secret app identity on each same-model candidate and require exactly one Device-ID match. - foreach ($record in @($candidates | Where-Object { - $_.Manufacturer -ieq $OriginalTarget.Manufacturer -and $_.Model -ieq $OriginalTarget.Model - })) { - $script:CurrentTarget = $record - try { $record | Add-Member DeviceId (Get-Identity) -Force } catch { } + try { + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + $candidates = @() + foreach ($record in @(Get-AdbRecords | Where-Object { $_.State -eq "device" })) { + $script:CurrentTarget = $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 + 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) { + $script:CurrentTarget = $candidate + return $candidate + } } - $candidate = Find-ReturningCandidate -Records $candidates -Manufacturer $OriginalTarget.Manufacturer ` - -Model $OriginalTarget.Model -OriginalSerial "" -ExpectedDeviceId $ExpectedDeviceId - if ($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 } - Throw-UpdateError "REBOOT_TARGET_AMBIGUOUS" "The same supported profile could not be re-identified uniquely after reboot." } function Wait-BootCompleted { @@ -821,6 +863,7 @@ function Write-SanitizedReports { "Target version: $($Result.TargetVersion)", "Artifact verification: $($Result.ArtifactVerification)", "Migrations: $migrationSummary", + "Preservation evidence: $($Result.PreservationEvidence)", "Pre-update Ready: $($Result.PreReady)", "Post-update Ready: $($Result.PostReady)", "Reboot acceptance: $($Result.RebootAcceptance)", @@ -853,6 +896,7 @@ function Invoke-OneUpdate { 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" ConfigVersionBefore = -1; ConfigVersionAfter = -1; Result = "FAIL" ErrorCategory = ""; Detail = "" } @@ -874,29 +918,47 @@ function Invoke-OneUpdate { } $installed = Get-InstalledPackageState $result.PreviousVersion = $installed.VersionName - $deviceId = Get-Identity + $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) + # Status is deliberately queried before either identity action. A legacy identity action + # may call getOrCreate, so it is permitted only after legacy status proves that this is an + # already-provisioned radio with an active LKG and stable active Device ID. + $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 + if ($deviceId -cne $before.ActiveDeviceId -or $before.DeviceId -cne $deviceId) { + Throw-UpdateError "LEGACY_IDENTITY_MISMATCH" "Legacy identity did not match the already-active managed configuration." + } + $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." } } - $before = Get-ProvisioningStatus - if (-not $before -or $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.ConfigVersionBefore = $before.ConfigVersion $result.PreReady = [bool](Get-ReadyState) $installedSigner = @(Get-InstalledSignerDigests -RemoteApkPath $installed.BaseApkPath) Assert-SignerCompatibility -InstalledDigests $installedSigner -TargetDigest ([string]$Bundle.Manifest.signerSha256).ToUpperInvariant() - $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) if (-not $ReportOnly -and -not $WhatIfPreference -and -not $ConfirmNotTransmitting) { if ($NonInteractive) { Throw-UpdateError "TX_CONFIRMATION_REQUIRED" "Non-interactive mutation requires -ConfirmNotTransmitting." @@ -905,8 +967,10 @@ function Invoke-OneUpdate { if ($answer -cne "UPDATE") { Throw-UpdateError "OPERATOR_CANCELLED" "Operator did not confirm the non-transmitting update boundary." } } if ($ReportOnly -or $WhatIfPreference) { - $result.Result = "PASS" - $result.Detail = "REPORT_ONLY; compatible, no mutation performed" + $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 } @@ -936,7 +1000,7 @@ function Invoke-OneUpdate { $after = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds $result.PostReady = $true $result.ConfigVersionAfter = $after.ConfigVersion - Assert-PreservedState -Before $before -After $after -Phase "after the in-place update" + $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) { @@ -945,7 +1009,10 @@ function Invoke-OneUpdate { $script:CurrentTarget = Wait-ReturningTarget -OriginalTarget $original -ExpectedDeviceId $deviceId -TimeoutSeconds $BootTimeoutSeconds Wait-BootCompleted -TimeoutSeconds $BootTimeoutSeconds $afterReboot = Wait-MinimumReady -ExpectedDeviceId $deviceId -TimeoutSeconds $ReadyTimeoutSeconds - Assert-PreservedState -Before $before -After $afterReboot -Phase "after reboot" + $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 } @@ -965,12 +1032,14 @@ function Invoke-OneUpdate { $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) { + 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)) - Assert-PreservedState -Before $before -After $recovered -Phase "during failure recovery" + $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" @@ -997,8 +1066,9 @@ try { Throw-UpdateError "APK_IDENTITY_BINDING" "The APK package/version does not match the exact release manifest." } $targetSigners = @(Get-ApkSignerDigests -ApkPath $bundle.ApkPath) - if (@($targetSigners | Where-Object { $_ -ceq ([string]$bundle.Manifest.signerSha256).ToUpperInvariant() }).Count -ne 1) { - Throw-UpdateError "APK_SIGNER_BINDING" "The APK signer does not match the exact release manifest." + $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." @@ -1011,7 +1081,8 @@ try { SessionId = $sessionId; Profile = ""; DeviceId = ""; PreviousVersion = "unknown" TargetVersion = "unknown"; ArtifactVerification = "FAILED"; Migrations = @() PreReady = $false; PostReady = $false; RebootAcceptance = "NOT_RUN" - RollbackAssessment = "NO_MUTATION"; ConfigVersionBefore = -1; ConfigVersionAfter = -1 + RollbackAssessment = "NO_MUTATION"; PreservationEvidence = "NOT_CAPTURED" + ConfigVersionBefore = -1; ConfigVersionAfter = -1 Result = "FAIL"; ErrorCategory = Get-ErrorCategory -Message $safe Detail = [regex]::Replace($safe, '^\[[A-Z0-9_]+\]\s*', '') } diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index e1541780..5b4723cc 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -96,6 +96,7 @@ Test-Case "debug to release signer mismatch is refused before install" { 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 "apksigner output parser requires verified signer digest" { @@ -192,5 +193,204 @@ if (Test-Path -LiteralPath $realApkPath -PathType Leaf) { } } +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) + [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-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 + 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 @("STATUS", "IDENTITY_LEGACY") @($scenario.Transcript) "status before legacy identity" + 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.Before.ActiveDeviceId = "*"; $scenario.Before.ConfigVersion = 0; $scenario.Before.LastSuccessMs = 0 + Set-UpdaterScenarioMocks $scenario + $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYNO" + Assert-Equal "LEGACY_NOT_PROVISIONED" $result.ErrorCategory "category" + Assert-Equal 0 $scenario.IdentityCalls "identity calls" + Assert-Equal 0 $scenario.InstallCalls "install count" + Assert-Equal @("STATUS") @($scenario.Transcript) "status-only 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 1 $scenario.IdentityCalls "identity after status proof" + Assert-Equal 0 $scenario.InstallCalls "install count" +} + +Test-Case "ReportOnly uses existing identity and never installs" { + $scenario = New-UpdaterScenario + 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 } +} + Write-Host "Updater tests: $script:Passed passed, $script:Failed failed" if ($script:Failed -gt 0) { exit 1 } From 2dfbf65159a60af6be03c0a5876f442eee970b23 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 23:48:39 +0700 Subject: [PATCH 5/9] Fail closed before legacy receiver probes --- docs/PROJECT_STATUS.md | 7 ++-- docs/PROVISIONING_BUNDLE_README.txt | 10 +++--- docs/TEST_MATRIX.md | 2 +- docs/UPDATER_RUNBOOK.md | 17 ++++++--- scripts/update-minimum-device.ps1 | 50 ++++++++++++++++++++++----- tests/update-minimum-device.Tests.ps1 | 45 ++++++++++++++++++++---- 6 files changed, 104 insertions(+), 27 deletions(-) diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index bdef7e77..6fabce66 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -22,9 +22,10 @@ document disagrees with this file, verify the code and update this file first. - 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 proves provisioned status before its legacy - identity read, then records newly available expanded fields as bootstrapped rather than falsely - preserved. The versioned `CELLULAR_POLICY_V1_T56` migration applies only from versionCode + after reboot. The one-time legacy `3070300` bridge first requires a non-creating `run-as` read of + the existing app-private public ID; non-debuggable or unprovisioned legacy builds fail before any + receiver action. 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. diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt index 5d4d5743..c5cac440 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -16,10 +16,12 @@ all bundle file hashes, APK checksum/package/version/exactly-one-reviewed-signer 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, legacy status must first prove an -already-provisioned, active, non-pending configuration with Last Known Good evidence. Only then is -legacy identity read. 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. +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. This supports the known +debuggable E7ROW7 acceptance build. If the app is non-debuggable or no identity exists, every mode +fails before any legacy receiver action. Status and identity are called only after that proof and +must match it. 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 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 1b9fec0c..3f2f6833 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -6,7 +6,7 @@ | FOSS debug APK build | PASS | `:app:assembleFossDebug` | | 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 24 policy/fixture/state-machine tests cover the legacy bridge, signer/no-mutation, 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. | +| Existing-device updater integration | PASS IN STATIC/AUTOMATED TESTS / PHYSICAL OPEN | PowerShell 5.1 AST and 26 policy/fixture/state-machine tests cover the non-creating legacy run-as bridge, 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 index 1c6f55ad..8c6380ed 100644 --- a/docs/UPDATER_RUNBOOK.md +++ b/docs/UPDATER_RUNBOOK.md @@ -47,10 +47,16 @@ applies the guarded roaming/mobile-data/automatic-LTE policy, then requires a re 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. For this single -`3070300`-or-older to `3070301` bridge, the updater queries status first and rejects an unprovisioned, -pending, inactive or missing-LKG radio without calling the legacy identity action. Only after that -proof may it read the legacy identity. Selected channel, LKG digest and safe-settings digest are +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 or no existing ID is present, `-ReportOnly`, `-WhatIf` and update +all fail with `LEGACY_NONCREATING_PROBE_UNAVAILABLE` before any receiver action. This currently +supports the known same-debug-signer E7ROW7 acceptance path; a non-debuggable legacy Release needs +a separately reviewed non-creating bridge before in-place updating. After the probe, status must +also prove a non-pending active LKG and both legacy receiver results must match the probed ID. +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. @@ -89,7 +95,8 @@ is not automated; the old APK is not included in the bundle. - `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_NOT_PROVISIONED` or `LEGACY_BRIDGE_UNSUPPORTED`: no identity read or install was allowed; +- `LEGACY_NONCREATING_PROBE_UNAVAILABLE`, `LEGACY_NOT_PROVISIONED` or `LEGACY_BRIDGE_UNSUPPORTED`: + no receiver or install was allowed when the non-creating probe failed; provision the radio through the reviewed provisioning path or use the exact approved bridge. - `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. diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index 7295b726..a9f7d60c 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -334,15 +334,26 @@ function Resolve-ApkSigner { 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.bat" } | + 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 } } - 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." + return $null } function Parse-ApkSignerOutput { @@ -824,6 +835,22 @@ function Wait-ReturningTarget { } } +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 Wait-BootCompleted { param([int]$TimeoutSeconds) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) @@ -925,9 +952,17 @@ function Invoke-OneUpdate { $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() + $legacyProbedIdentity = "" + if ($installed.VersionCode -le 3070300) { + $legacyProbedIdentity = Get-LegacyExistingIdentityViaRunAs + } # Status is deliberately queried before either identity action. A legacy identity action - # may call getOrCreate, so it is permitted only after legacy status proves that this is an - # already-provisioned radio with an active LKG and stable active Device ID. + # 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." @@ -936,8 +971,9 @@ function Invoke-OneUpdate { Assert-LegacyBridgeEligible -Status $before -InstalledVersionCode $installed.VersionCode ` -TargetVersionCode ([long]$Bundle.Manifest.versionCode) $deviceId = Get-Identity -Legacy - if ($deviceId -cne $before.ActiveDeviceId -or $before.DeviceId -cne $deviceId) { - Throw-UpdateError "LEGACY_IDENTITY_MISMATCH" "Legacy identity did not match the already-active managed configuration." + if (-not $legacyProbedIdentity -or $deviceId -cne $legacyProbedIdentity -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 { @@ -957,8 +993,6 @@ function Invoke-OneUpdate { } $result.ConfigVersionBefore = $before.ConfigVersion $result.PreReady = [bool](Get-ReadyState) - $installedSigner = @(Get-InstalledSignerDigests -RemoteApkPath $installed.BaseApkPath) - Assert-SignerCompatibility -InstalledDigests $installedSigner -TargetDigest ([string]$Bundle.Manifest.signerSha256).ToUpperInvariant() if (-not $ReportOnly -and -not $WhatIfPreference -and -not $ConfirmNotTransmitting) { if ($NonInteractive) { Throw-UpdateError "TX_CONFIRMATION_REQUIRED" "Non-interactive mutation requires -ConfirmNotTransmitting." diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 5b4723cc..761f37d3 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -238,6 +238,13 @@ function Set-UpdaterScenarioMocks { $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-Identity { param([switch]$Legacy) $global:UpdaterScenario.IdentityCalls++ @@ -283,7 +290,7 @@ function New-UpdaterScenario { @{ 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 + InstalledSigners=@("A" * 64); Installed=$false; PostVersionMismatch=$false; RebootFailure=$false; LegacyProbeAvailable=$true InstallCalls=0; IdentityCalls=0; MigrationCalls=0; RyksCalls=0; StartCalls=0; PackageReads=0 Transcript=[Collections.Generic.List[string]]::new() } @@ -306,20 +313,20 @@ Test-Case "legacy 3070300 to 3070301 state-machine bootstraps expanded evidence" $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 @("STATUS", "IDENTITY_LEGACY") @($scenario.Transcript) "status before legacy identity" + 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.Before.ActiveDeviceId = "*"; $scenario.Before.ConfigVersion = 0; $scenario.Before.LastSuccessMs = 0 + $scenario.LegacyProbeAvailable = $false Set-UpdaterScenarioMocks $scenario $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "LEGACYNO" - Assert-Equal "LEGACY_NOT_PROVISIONED" $result.ErrorCategory "category" + 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 @("STATUS") @($scenario.Transcript) "status-only transcript" + Assert-Equal @("RUN_AS_IDENTITY") @($scenario.Transcript) "no receiver transcript" } Test-Case "legacy signer mismatch is reached preinstall without mutation" { @@ -328,12 +335,25 @@ Test-Case "legacy signer mismatch is reached preinstall without mutation" { Set-UpdaterScenarioMocks $scenario $result = Invoke-OneUpdate -Bundle (New-UpdaterBundle) -SessionId "SIGNERNO" Assert-Equal "SIGNER_MISMATCH" $result.ErrorCategory "category" - Assert-Equal 1 $scenario.IdentityCalls "identity after status proof" + 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 + 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") @($scenario.Transcript) "noncreating probe only" + Assert-Equal 0 $scenario.IdentityCalls "receiver identity calls" Assert-Equal 0 $scenario.InstallCalls "install count" } 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 } @@ -392,5 +412,18 @@ Test-Case "Resolve-ApkSigner tolerates unset Linux-style SDK environment" { } 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 } From 1a5ed6aa77fc8b0a3bd4ae1243a4bf2f9405d532 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Thu, 13 Aug 2026 23:54:36 +0700 Subject: [PATCH 6/9] Add focused Ready UI legacy proof --- docs/PROJECT_STATUS.md | 7 +-- docs/PROVISIONING_BUNDLE_README.txt | 11 +++-- docs/TEST_MATRIX.md | 2 +- docs/UPDATER_RUNBOOK.md | 17 ++++--- scripts/update-minimum-device.ps1 | 70 +++++++++++++++++++++++++-- tests/update-minimum-device.Tests.ps1 | 55 ++++++++++++++++++++- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 6fabce66..d43329d2 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -22,9 +22,10 @@ document disagrees with this file, verify the code and update this file first. - 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 requires a non-creating `run-as` read of - the existing app-private public ID; non-debuggable or unprovisioned legacy builds fail before any - receiver action. It then records newly available expanded fields as bootstrapped rather than + 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 diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt index c5cac440..85b37e1b 100644 --- a/docs/PROVISIONING_BUNDLE_README.txt +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -17,10 +17,13 @@ identity/config preservation and Ready. Read "UPDATER-README.md" in this bundle 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. This supports the known -debuggable E7ROW7 acceptance build. If the app is non-debuggable or no identity exists, every mode -fails before any legacy receiver action. Status and identity are called only after that proof and -must match it. Fields unavailable in 3070300 are marked BOOTSTRAPPED_POST_UPDATE after the new +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 diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 3f2f6833..4c83d074 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -6,7 +6,7 @@ | FOSS debug APK build | PASS | `:app:assembleFossDebug` | | 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 26 policy/fixture/state-machine tests cover the non-creating legacy run-as bridge, 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. | +| 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 index 8c6380ed..617977d7 100644 --- a/docs/UPDATER_RUNBOOK.md +++ b/docs/UPDATER_RUNBOOK.md @@ -51,11 +51,15 @@ The `3070300` receiver exposes only the five-field legacy status and its legacy 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 or no existing ID is present, `-ReportOnly`, `-WhatIf` and update -all fail with `LEGACY_NONCREATING_PROBE_UNAVAILABLE` before any receiver action. This currently -supports the known same-debug-signer E7ROW7 acceptance path; a non-debuggable legacy Release needs -a separately reviewed non-creating bridge before in-place updating. After the probe, status must -also prove a non-pending active LKG and both legacy receiver results must match the probed ID. +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. @@ -97,7 +101,8 @@ is not automated; the old APK is not included in the bundle. 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; - provision the radio through the reviewed provisioning path or use the exact approved bridge. + 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 diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index a9f7d60c..c29fdc0f 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -851,6 +851,58 @@ function Get-LegacyExistingIdentityViaRunAs { 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) @@ -891,6 +943,8 @@ function Write-SanitizedReports { "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)", @@ -923,7 +977,8 @@ function Invoke-OneUpdate { 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" + PreservationEvidence = "NOT_CAPTURED"; LegacyProofMode = "NOT_APPLICABLE" + LegacySelectedChannelBefore = "" ConfigVersionBefore = -1; ConfigVersionAfter = -1; Result = "FAIL" ErrorCategory = ""; Detail = "" } @@ -956,9 +1011,11 @@ function Invoke-OneUpdate { # 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() - $legacyProbedIdentity = "" + $legacyEvidence = $null if ($installed.VersionCode -le 3070300) { - $legacyProbedIdentity = Get-LegacyExistingIdentityViaRunAs + $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 @@ -971,8 +1028,10 @@ function Invoke-OneUpdate { Assert-LegacyBridgeEligible -Status $before -InstalledVersionCode $installed.VersionCode ` -TargetVersionCode ([long]$Bundle.Manifest.versionCode) $deviceId = Get-Identity -Legacy - if (-not $legacyProbedIdentity -or $deviceId -cne $legacyProbedIdentity -or - $deviceId -cne $before.ActiveDeviceId -or $before.DeviceId -cne $deviceId) { + $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" @@ -1116,6 +1175,7 @@ try { 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*', '') diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 761f37d3..7b73053a 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -245,6 +245,10 @@ function Set-UpdaterScenarioMocks { } 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++ @@ -291,6 +295,8 @@ function New-UpdaterScenario { 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() } @@ -313,6 +319,7 @@ Test-Case "legacy 3070300 to 3070301 state-machine bootstraps expanded evidence" $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" @@ -321,12 +328,13 @@ Test-Case "legacy 3070300 to 3070301 state-machine bootstraps expanded evidence" 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") @($scenario.Transcript) "no receiver transcript" + Assert-Equal @("RUN_AS_IDENTITY", "READY_UI_PROBE") @($scenario.Transcript) "no receiver transcript" } Test-Case "legacy signer mismatch is reached preinstall without mutation" { @@ -342,15 +350,58 @@ Test-Case "legacy signer mismatch is reached preinstall without mutation" { 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") @($scenario.Transcript) "noncreating probe only" + 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 From 1e73c630514ea76a4bab7544be651b9bbadf8b11 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 00:21:38 +0700 Subject: [PATCH 7/9] Pin updater target before hardware inventory --- scripts/update-minimum-device.ps1 | 3 +++ tests/update-minimum-device.Tests.ps1 | 3 +++ 2 files changed, 6 insertions(+) diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index c29fdc0f..828444a2 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -988,6 +988,9 @@ function Invoke-OneUpdate { 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." diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 7b73053a..0094c892 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -225,6 +225,9 @@ function Set-UpdaterScenarioMocks { 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 } } From 284f0814218550adc541cc84f043babc45a3b7fc Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 00:22:41 +0700 Subject: [PATCH 8/9] Render updater summaries on PowerShell 5.1 --- scripts/update-minimum-device.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index 828444a2..db762267 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -1206,7 +1206,9 @@ do { if ($choice -ieq "Q") { break } } while ($true) -$summary = Format-SessionSummary -Results @($results) -TargetVersion ([string]$bundle.Manifest.versionName) +# 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 From fe871f257fc925d07a3d7794aadc55b1e1800377 Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Fri, 14 Aug 2026 00:31:10 +0700 Subject: [PATCH 9/9] Correlate reboot targets across ADB ports --- scripts/update-minimum-device.ps1 | 26 +++++++++++++++++++++++++- tests/update-minimum-device.Tests.ps1 | 9 +++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/update-minimum-device.ps1 b/scripts/update-minimum-device.ps1 index db762267..8a4f4e1c 100644 --- a/scripts/update-minimum-device.ps1 +++ b/scripts/update-minimum-device.ps1 @@ -650,6 +650,27 @@ function Get-AdbRecords { 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) @@ -803,8 +824,9 @@ function Wait-ReturningTarget { while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 2 $candidates = @() - foreach ($record in @(Get-AdbRecords | Where-Object { $_.State -eq "device" })) { + 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 @@ -813,6 +835,7 @@ function Wait-ReturningTarget { $_.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 @@ -824,6 +847,7 @@ function Wait-ReturningTarget { $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 } diff --git a/tests/update-minimum-device.Tests.ps1 b/tests/update-minimum-device.Tests.ps1 index 0094c892..acf697da 100644 --- a/tests/update-minimum-device.Tests.ps1 +++ b/tests/update-minimum-device.Tests.ps1 @@ -99,6 +99,15 @@ Test-Case "matching signer accepted" { 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"