From 6261aa1fd2d71f897135e16b1316b8608ab8ab0b Mon Sep 17 00:00:00 2001 From: "A.Watchara" Date: Mon, 10 Aug 2026 00:13:10 +0700 Subject: [PATCH] Add guided one-shot device provisioning --- AGENTS.md | 4 + Provision Minimum Device.cmd | 17 + app/src/main/AndroidManifest.xml | 1 + .../mumla/radio/RadioConfigUpdater.java | 21 +- .../mumla/radio/RadioProvisionReceiver.java | 32 + docs/DEVELOPMENT_RUNBOOK.md | 60 ++ scripts/provision-minimum-device.ps1 | 722 ++++++++++++++++++ 7 files changed, 855 insertions(+), 2 deletions(-) create mode 100644 Provision Minimum Device.cmd create mode 100644 scripts/provision-minimum-device.ps1 diff --git a/AGENTS.md b/AGENTS.md index 78285cdd..8e0ffe91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,10 @@ TOML and smoke-test `luna_worker`. Never rewrite agent configuration silently. - Preserve unrelated user changes and do not stage `DEV_ENVIRONMENT_REQUIREMENTS.md` unless the user explicitly requests it. +- Vercel builds and deploys the Minimum portal only from the production branch `main`. A request to + change the Minimum WebUI is not complete at branch/PR validation alone unless the user explicitly + asks for local-only work: run the `web/` checks, merge the reviewed change into `main`, monitor the + resulting Vercel production deployment, and smoke-check `https://minimum.vra.or.th/`. - Never commit Mumble access tokens, private certificate fingerprints, credentials or unsanitized device data. - Do not claim T99/T56 screen-off hardware PTT support without a real device trace. diff --git a/Provision Minimum Device.cmd b/Provision Minimum Device.cmd new file mode 100644 index 00000000..ea01cd93 --- /dev/null +++ b/Provision Minimum Device.cmd @@ -0,0 +1,17 @@ +@echo off +setlocal +title Minimum One-Shot Provisioning +echo Starting Minimum device setup... +echo. +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\provision-minimum-device.ps1" +set "MINIMUM_PROVISION_EXIT=%ERRORLEVEL%" +echo. +if "%MINIMUM_PROVISION_EXIT%"=="0" ( + echo Setup window finished. +) else ( + echo SETUP FAILED with exit code %MINIMUM_PROVISION_EXIT%. + echo Read the error above, correct it, then double-click this file again. +) +echo. +pause +exit /b %MINIMUM_PROVISION_EXIT% diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 544b070c..d7caf080 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/RadioConfigUpdater.java b/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java index 97a9f5af..5a51af0b 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java @@ -34,6 +34,7 @@ public final class RadioConfigUpdater { private static final String PREF_LAST_SUCCESS = "radio_config_last_success_ms"; private static final long REFRESH_INTERVAL_MS = 6L * 60L * 60L * 1000L; private static final AtomicBoolean REFRESH_IN_FLIGHT = new AtomicBoolean(false); + private static final AtomicBoolean FORCE_REFRESH_PENDING = new AtomicBoolean(false); private static final Object NETWORK_MONITOR_LOCK = new Object(); private static boolean networkMonitorRegistered; @@ -55,9 +56,17 @@ public static void schedule(Context context) { /** Forces a refresh after a protected device credential is installed or rotated. */ static void scheduleNow(Context context) { + PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()) + .edit().remove(PREF_LAST_SUCCESS).apply(); schedule(context, true); } + /** Returns only the last successful managed refresh time for protected provisioning status. */ + static long getLastSuccess(Context context) { + return PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()) + .getLong(PREF_LAST_SUCCESS, 0L); + } + static boolean shouldRefresh(long now, long lastSuccess, boolean force) { return force || lastSuccess <= 0L || now - lastSuccess >= REFRESH_INTERVAL_MS; } @@ -67,8 +76,13 @@ private static void schedule(Context context, boolean force) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(applicationContext); long now = System.currentTimeMillis(); long lastSuccess = preferences.getLong(PREF_LAST_SUCCESS, 0L); - if (!shouldRefresh(now, lastSuccess, force) - || !REFRESH_IN_FLIGHT.compareAndSet(false, true)) { + if (!shouldRefresh(now, lastSuccess, force)) { + return; + } + if (!REFRESH_IN_FLIGHT.compareAndSet(false, true)) { + if (force) { + FORCE_REFRESH_PENDING.set(true); + } return; } @@ -96,6 +110,9 @@ private static void schedule(Context context, boolean force) { + exception.getClass().getSimpleName() + ")"); } finally { REFRESH_IN_FLIGHT.set(false); + if (FORCE_REFRESH_PENDING.getAndSet(false)) { + schedule(applicationContext, true); + } } }, "minimum-radio-config").start(); } 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 7d47e510..731da224 100644 --- a/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java +++ b/app/src/main/java/se/lublin/mumla/radio/RadioProvisionReceiver.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.util.Locale; import se.lublin.mumla.service.MumlaService; @@ -27,6 +28,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_STATUS = + "se.lublin.mumla.action.PROVISION_REPORT_STATUS"; public static final String ACTION_INSTALL_RADIO_CONFIG = "se.lublin.mumla.action.PROVISION_RADIO_CONFIG"; public static final String ACTION_INSTALL_DEVICE_CONFIG_CREDENTIAL = @@ -61,6 +64,8 @@ public void onReceive(Context context, Intent intent) { PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId(); setResultCode(-1); setResultData(deviceId); + } else if (ACTION_REPORT_STATUS.equals(intent.getAction())) { + reportProvisioningStatus(context); } else if (ACTION_INSTALL_RADIO_CONFIG.equals(intent.getAction())) { String credential = intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL); String credentialPath = intent.getStringExtra(EXTRA_DEVICE_CONFIG_CREDENTIAL_PATH); @@ -78,6 +83,33 @@ public void onReceive(Context context, Intent intent) { } } + /** Reports only non-secret state needed by the one-shot provisioning acceptance check. */ + private void reportProvisioningStatus(Context context) { + setResultCode(0); + setResultData("unavailable"); + try { + String deviceId = new DeviceIdentityManager( + PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId(); + DeviceConfigCredentialStore credentialStore = new DeviceConfigCredentialStore(context); + boolean credentialPresent = credentialStore.getCredential() != null; + RadioConfigRepository repository = new RadioConfigRepository(context); + org.json.JSONObject active = repository.loadActiveOrDefault(); + String activeDeviceId = active.optString("deviceId", ""); + int configVersion = active.optInt("configVersion", -1); + setResultCode(-1); + setResultData(String.format(Locale.US, + "deviceId=%s;credential=%s;activeDeviceId=%s;configVersion=%d;pending=%s;lastSuccessMs=%d", + deviceId, + credentialPresent ? "present" : "missing", + activeDeviceId, + configVersion, + repository.hasPending() ? "true" : "false", + RadioConfigUpdater.getLastSuccess(context))); + } catch (IOException | RuntimeException | org.json.JSONException ignored) { + // Status intentionally contains no config fields, credentials, endpoints or room data. + } + } + private void updateAprsObjectName(Context context, String objectName) { setResultCode(0); setResultData("rejected"); diff --git a/docs/DEVELOPMENT_RUNBOOK.md b/docs/DEVELOPMENT_RUNBOOK.md index 3e45d421..af9e7fe5 100644 --- a/docs/DEVELOPMENT_RUNBOOK.md +++ b/docs/DEVELOPMENT_RUNBOOK.md @@ -32,6 +32,13 @@ The portal source is `web/`, a Next.js application deployed at `https://minimum.vra.or.th/` with Vercel's **Next.js framework preset**. Keep the deployment on the normal Next.js build output; do not add a standalone trace workaround for the Windows junction. +Vercel's project-level **Ignored Build Step** is set to **Only build production**. The production +branch is `main`; pushes to other branches are not expected to create Preview deployments. For a +requested Minimum WebUI change, branch/PR checks are an intermediate gate rather than delivery: +merge the reviewed change into `main`, wait for the resulting Vercel production deployment, and +smoke-check `https://minimum.vra.or.th/`. Skip merge/deployment only when the user explicitly asks +for local-only work. + Run the web checks from the build-safe junction: ```powershell @@ -155,6 +162,59 @@ Android may refuse the Activity launch on newer OEM builds. That is a platform l that the receiver is missing; add a foreground-service/notification fallback before claiming broad new-device support. +## One-shot provisioning for a known radio + +Use the repository-root `Provision Minimum Device.cmd` for a factory-reset or newly received +T99/T56. The normal operator double-clicks this file and does not enter PowerShell parameters. The +guided flow detects the active ADB port, explains how to authorize USB debugging, offers a numbered +device menu when several radios are attached, and shows recommended/custom setup choices. It keeps +the window open on PASS or failure so the result is not lost. + +Connect only one unit of a given model for the final reboot check. The workflow verifies the exact hardware, +builds the FOSS debug APK when requested or when the default APK is missing, installs it without +clearing app data, runs the guarded model preparation, opens the Portal, installs the one-time device +credential through the `DUMP`-protected receiver, waits for `minimum-state-ready`, reboots, and waits +for Ready again: + +Double-click: + +```text +Provision Minimum Device.cmd +``` + +Port `5037` is the Android standard and is selected when no ADB server is running. Port `5041` is +the existing Minimum lab alternative. The guided flow chooses the only port with an authorized +device automatically; if both servers are active or no device is visible, it presents a menu and +the USB-debugging/authorization checklist. Advanced automation may still call the underlying +PowerShell script with parameters, but field operators should use the launcher. + +The script displays only the six-character Device ID and detected Portal model (`t99` or `t56`). +Register that ID under **Devices** at `https://minimum.vra.or.th/` with the displayed model, issue +its one-time token, and paste the token into the hidden prompt in the same running script. The +transient token file is removed from both Windows and +`/data/local/tmp` immediately after the protected receiver returns. The token is never placed in an +ADB argument or printed. + +For an unattended operator station, create a tightly protected temporary token file outside the +repository and pass it explicitly. Delete that source file after the command succeeds: + +```powershell +.\scripts\provision-minimum-device.ps1 -AdbPort 5041 -BuildApk ` + -DeviceProfile ABC123 ` + -DeviceConfigCredentialPath C:\private\minimum-device-token.txt ` + -NonInteractive +``` + +Use `-Serial` or `-TransportId` when more than one authorized ADB device is attached. Pass +`-SkipLabWifi` only when the unit has another verified network path; otherwise the existing ignored +DPAPI lab Wi-Fi credential is used. T56 network-assisted location remains an explicit operator +consent flow through `-RequestNetworkLocationConsent`. + +Unknown manufacturer/model pairs are inventory-reported and rejected before APK installation or +provisioning changes. Complete physical button/PTT capture, add a guarded hardware profile and its +model preparation wrapper, and pass real-device acceptance before adding that model to this +one-shot path. Never treat the app's `generic-radio` fallback as hardware acceptance. + ## T99 preparation and Zello removal The canonical provisioning script is `scripts/prepare-t99.ps1`. The old diff --git a/scripts/provision-minimum-device.ps1 b/scripts/provision-minimum-device.ps1 new file mode 100644 index 00000000..12c60d82 --- /dev/null +++ b/scripts/provision-minimum-device.ps1 @@ -0,0 +1,722 @@ +<# +.SYNOPSIS + Provisions one supported Minimum radio from APK installation through reboot acceptance. + +.DESCRIPTION + This is the operator-facing one-shot workflow for known T99 and T56 hardware. It selects one + authorized ADB target, verifies the hardware model, optionally builds the FOSS debug APK, + installs the APK without clearing app data, runs the guarded model preparation, installs the + portal-issued device bearer credential without putting it on the command line, waits for Ready, + reboots the radio, and waits for Ready again. + + When no credential is supplied, the script opens the Minimum portal and securely prompts for + the one-time token after displaying the six-character Device ID. Unknown hardware is reported + and rejected before any APK installation or provisioning change. + + Connect only one unit of a given model while using the reboot acceptance step. ADB transport IDs + can change across reboot, so the script must be able to identify exactly one returning unit. +#> + +[CmdletBinding()] +param( + [string]$Serial = "", + [int]$TransportId = 0, + [ValidateRange(0, 65535)][int]$AdbPort = 0, + [string]$ApkPath = "", + [switch]$BuildApk, + [Alias("DeviceId")] + [string]$DeviceProfile = "", + [System.Security.SecureString]$DeviceConfigCredential, + [string]$DeviceConfigCredentialPath = "", + [string]$PortalUrl = "https://minimum.vra.or.th/", + [switch]$SkipOpenPortal, + [switch]$NonInteractive, + [switch]$SkipZello, + [switch]$SkipMinimumHome, + [switch]$SkipLabWifi, + [switch]$SkipLocation, + [switch]$RequestNetworkLocationConsent, + [switch]$RefreshLabWifi, + [string]$LabWifiSsid = "..@EmergencyTU", + [string]$LabWifiCredentialPath = "", + [ValidateRange(30, 900)][int]$ReadyTimeoutSeconds = 180, + [ValidateRange(30, 900)][int]$BootTimeoutSeconds = 180, + [switch]$SkipReboot +) + +$ErrorActionPreference = "Stop" +$GuidedMode = $PSBoundParameters.Count -eq 0 +$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" +$CredentialProvisionAction = "se.lublin.mumla.action.PROVISION_DEVICE_CONFIG_CREDENTIAL" +$RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$DefaultApkPath = Join-Path $RepositoryRoot "app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +try { + $adbPath = (Get-Command adb -ErrorAction Stop).Source +} catch { + throw "ADB was not found. Install Android Platform Tools or add adb.exe to PATH, then double-click the launcher again." +} +$serverArgs = @() +$script:targetArgs = @() +$script:targetLabel = "" +$script:targetRecord = $null + +if ($DeviceProfile -and (($DeviceProfile -cnotmatch '^[A-Z0-9]{6}$') -or + ($DeviceProfile -notmatch '[A-Z]') -or ($DeviceProfile -notmatch '\d'))) { + throw "DeviceProfile must be six uppercase A-Z/0-9 characters with a letter and digit." +} +if ($DeviceConfigCredential -and $DeviceConfigCredentialPath) { + throw "Pass either -DeviceConfigCredential or -DeviceConfigCredentialPath, not both." +} +if ($SkipLocation -and $RequestNetworkLocationConsent) { + throw "-SkipLocation and -RequestNetworkLocationConsent cannot be used together." +} + +function Get-ListeningAdbPorts { + $ports = @() + try { + $ports = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { $_.LocalPort -in @(5037, 5041) } | + Select-Object -ExpandProperty LocalPort -Unique) + } catch { + $netstat = & netstat.exe -ano -p TCP 2>$null + foreach ($line in $netstat) { + 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-AuthorizedDeviceCount { + param([Parameter(Mandatory)][int]$Port) + $lines = & $adbPath -P $Port devices 2>$null + if ($LASTEXITCODE -ne 0) { + return 0 + } + return @($lines | Where-Object { $_ -match '^[^\s]+\s+device(?:\s|$)' }).Count +} + +function Select-AdbServerPort { + if ($AdbPort -gt 0) { + Write-Host "ADB port: $AdbPort (selected by advanced command-line option)." + return $AdbPort + } + + $listening = @(Get-ListeningAdbPorts) + if ($listening.Count -eq 0) { + Write-Host "ADB port: 5037 (standard port; the ADB server will start automatically)." + return 5037 + } + + $withDevices = foreach ($port in $listening) { + $count = Get-AuthorizedDeviceCount -Port $port + if ($count -gt 0) { + [pscustomobject]@{ Port = $port; Count = $count } + } + } + $withDevices = @($withDevices) + if ($withDevices.Count -eq 1) { + Write-Host "ADB port: $($withDevices[0].Port) (auto-detected $($withDevices[0].Count) authorized device(s))." + return $withDevices[0].Port + } + if ($listening.Count -eq 1) { + Write-Host "ADB port: $($listening[0]) (existing ADB server; connect and authorize the device if it is not listed yet)." + return $listening[0] + } + if ($NonInteractive) { + throw "More than one ADB server is active. Pass -AdbPort 5037 or -AdbPort 5041." + } + + Write-Host "" + Write-Host "More than one ADB server is active. Choose the server that owns the device:" + Write-Host " [1] Port 5037 - Android standard (recommended for a new workstation)" + Write-Host " [2] Port 5041 - Existing Minimum lab setup" + while ($true) { + $choice = (Read-Host "Select 1 or 2").Trim() + if ($choice -eq "1") { return 5037 } + if ($choice -eq "2") { return 5041 } + Write-Host "Please enter 1 or 2." + } +} + +function Show-GuidedSetupMenu { + param([Parameter(Mandatory)][string]$Profile) + + Write-Host "" + Write-Host "Recommended setup will:" + Write-Host " - build and install the latest Minimum test APK" + Write-Host " - configure lab Wi-Fi and managed Location" + Write-Host " - remove Zello for Android user 0" + Write-Host " - open the Portal for registration and a hidden token prompt" + Write-Host " - verify Ready, reboot, and verify Ready again" + Write-Host "" + while ($true) { + $mode = (Read-Host "Press Enter to start, C for custom choices, or Q to quit").Trim() + if (-not $mode -or $mode -ieq "C" -or $mode -ieq "Q") { break } + Write-Host "Please press Enter, C or Q." + } + if ($mode -ieq "Q") { + Write-Host "Cancelled. No APK was installed and no provisioning change was made." + exit 0 + } + $script:BuildApk = $true + if (-not $mode) { + return + } + + $answer = (Read-Host "Build the latest APK? [Y/n]").Trim() + if ($answer -ieq "N") { $script:BuildApk = $false } + + $answer = (Read-Host "Configure/verify lab Wi-Fi? [Y/n]").Trim() + if ($answer -ieq "N") { $script:SkipLabWifi = $true } + + if ($Profile -eq "T56") { + Write-Host "Location: [1] GPS only (recommended) [2] GPS + network consent [3] Skip" + while ($true) { + $answer = (Read-Host "Select 1, 2 or 3 [1]").Trim() + if (-not $answer -or $answer -eq "1") { break } + if ($answer -eq "2") { + $script:RequestNetworkLocationConsent = $true + break + } + if ($answer -eq "3") { + $script:SkipLocation = $true + break + } + Write-Host "Please enter 1, 2 or 3." + } + } else { + $answer = (Read-Host "Configure managed Location? [Y/n]").Trim() + if ($answer -ieq "N") { $script:SkipLocation = $true } + } + + $answer = (Read-Host "Remove Zello for Android user 0? [Y/n]").Trim() + if ($answer -ieq "N") { $script:SkipZello = $true } + + $answer = (Read-Host "Reboot and verify unattended startup? [Y/n]").Trim() + if ($answer -ieq "N") { $script:SkipReboot = $true } +} + +function Get-ConnectedDevices { + $lines = & $adbPath @serverArgs devices -l + if ($LASTEXITCODE -ne 0) { + throw "Could not query ADB on port $AdbPort." + } + $records = foreach ($line in $lines) { + if ($line -match '^([^\s]+)\s+device(?:\s|$)') { + $deviceSerial = $Matches[1] + $transport = 0 + if ($line -match '\btransport_id:(\d+)\b') { + $transport = [int]$Matches[1] + } + [pscustomobject]@{ + Serial = $deviceSerial + TransportId = $transport + Line = $line + } + } + } + return @($records) +} + +function Get-RecordAdbArguments { + param([Parameter(Mandatory)]$Record) + if ($Record.TransportId -gt 0) { + return $serverArgs + @("-t", "$($Record.TransportId)") + } + return $serverArgs + @("-s", $Record.Serial) +} + +function Invoke-AdbForRecord { + param( + [Parameter(Mandatory)]$Record, + [Parameter(Mandatory)][string[]]$Arguments + ) + $recordArgs = Get-RecordAdbArguments -Record $Record + $commandArgs = $recordArgs + $Arguments + $output = & $adbPath @commandArgs + if ($LASTEXITCODE -ne 0) { + throw "ADB command failed for connected device '$($Record.Serial)' with exit code $LASTEXITCODE." + } + return $output +} + +function Get-RecordProperty { + param( + [Parameter(Mandatory)]$Record, + [Parameter(Mandatory)][string]$Name + ) + return ((Invoke-AdbForRecord -Record $Record -Arguments @("shell", "getprop", $Name)) -join "").Trim() +} + +function Add-RecordHardwareIdentity { + param([Parameter(Mandatory)]$Record) + $manufacturer = Get-RecordProperty -Record $Record -Name "ro.product.manufacturer" + $model = Get-RecordProperty -Record $Record -Name "ro.product.model" + $profile = if ($manufacturer -ieq "Youdotech" -and $model -ieq "QM011") { + "T99" + } elseif ($manufacturer -ieq "UNIPRO" -and $model -ieq "ZX") { + "T56" + } else { + "" + } + $Record | Add-Member -NotePropertyName Manufacturer -NotePropertyValue $manufacturer -Force + $Record | Add-Member -NotePropertyName Model -NotePropertyValue $model -Force + $Record | Add-Member -NotePropertyName Profile -NotePropertyValue $profile -Force + return $Record +} + +function Select-InitialTarget { + $devices = @(Get-ConnectedDevices) + while ($devices.Count -eq 0 -and -not $NonInteractive) { + Write-Host "" + Write-Host "No authorized Android device was found on ADB port $AdbPort." + Write-Host "1. Connect USB and unlock the radio." + Write-Host "2. Enable USB debugging." + Write-Host "3. Accept the 'Allow USB debugging' message on the radio." + $choice = (Read-Host "Press Enter to check again, P to change ADB port, or Q to quit").Trim() + if ($choice -ieq "Q") { + Write-Host "Cancelled. No device change was made." + exit 0 + } + if ($choice -ieq "P") { + Write-Host " [1] Port 5037 - Android standard" + Write-Host " [2] Port 5041 - Minimum lab setup" + $portChoice = (Read-Host "Select 1 or 2").Trim() + if ($portChoice -eq "1") { $script:AdbPort = 5037 } + if ($portChoice -eq "2") { $script:AdbPort = 5041 } + $script:serverArgs = @("-P", "$AdbPort") + } + $devices = @(Get-ConnectedDevices) + } + if ($devices.Count -eq 0) { + throw "No authorized Android device was found on ADB port $AdbPort." + } + if ($TransportId -gt 0) { + $matches = @($devices | Where-Object { $_.TransportId -eq $TransportId }) + if ($matches.Count -ne 1) { + throw "Authorized ADB transport id $TransportId was not found on port $AdbPort." + } + return Add-RecordHardwareIdentity -Record $matches[0] + } + if ($Serial) { + $pattern = "^$([regex]::Escape($Serial))$" + $matches = @($devices | Where-Object { $_.Serial -match $pattern }) + if ($matches.Count -ne 1) { + throw "Expected exactly one authorized device with serial '$Serial'; use -TransportId when duplicated." + } + return Add-RecordHardwareIdentity -Record $matches[0] + } + + $identifiedDevices = @($devices | ForEach-Object { Add-RecordHardwareIdentity -Record $_ }) + if ($identifiedDevices.Count -eq 1) { + return $identifiedDevices[0] + } + if ($NonInteractive) { + throw "More than one authorized device is connected; pass -Serial or -TransportId." + } + + Write-Host "" + Write-Host "More than one Android device is connected. Select the radio to provision:" + for ($index = 0; $index -lt $identifiedDevices.Count; $index++) { + $device = $identifiedDevices[$index] + $support = if ($device.Profile) { $device.Profile } else { "unsupported - inventory only" } + Write-Host (" [{0}] {1}/{2} - {3}" -f ($index + 1), + $device.Manufacturer, $device.Model, $support) + } + while ($true) { + $choice = (Read-Host "Enter device number or Q to quit").Trim() + if ($choice -ieq "Q") { + Write-Host "Cancelled. No device change was made." + exit 0 + } + $number = 0 + if ([int]::TryParse($choice, [ref]$number) -and + $number -ge 1 -and $number -le $identifiedDevices.Count) { + return $identifiedDevices[$number - 1] + } + Write-Host "Please enter a number shown in the list." + } +} + +function Set-Target { + param([Parameter(Mandatory)]$Record) + $script:targetRecord = $Record + $script:targetArgs = Get-RecordAdbArguments -Record $Record + $script:targetLabel = if ($Record.TransportId -gt 0) { + "transport id $($Record.TransportId)" + } else { + "ADB serial $($Record.Serial)" + } +} + +function Invoke-TargetAdb { + param([Parameter(Mandatory)][string[]]$Arguments) + $commandArgs = $script:targetArgs + $Arguments + $output = & $adbPath @commandArgs + if ($LASTEXITCODE -ne 0) { + throw "ADB command failed for $script:targetLabel with exit code $LASTEXITCODE." + } + return $output +} + +function Get-MinimumDeviceId { + $output = Invoke-TargetAdb -Arguments @( + "shell", "am", "broadcast", "-W", + "-a", $IdentityReportAction, + "-n", $ProvisionReceiver + ) + $match = [regex]::Match(($output -join "`n"), 'data="?([A-Z0-9]{6})"?') + if (-not $match.Success) { + throw "Minimum did not return a six-character Device ID." + } + return $match.Groups[1].Value +} + +function Get-MinimumProvisioningStatus { + $output = Invoke-TargetAdb -Arguments @( + "shell", "am", "broadcast", "-W", + "-a", $ProvisionStatusAction, + "-n", $ProvisionReceiver + ) + $text = $output -join "`n" + $match = [regex]::Match($text, + 'data="?deviceId=([A-Z0-9]{6});credential=(present|missing);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 + CredentialPresent = $match.Groups[2].Value -eq "present" + ActiveDeviceId = $match.Groups[3].Value + ConfigVersion = [int]$match.Groups[4].Value + Pending = $match.Groups[5].Value -eq "true" + LastSuccessMs = [long]$match.Groups[6].Value + } +} + +function Build-MinimumApk { + $buildRoot = $RepositoryRoot + $temporaryJunction = "" + try { + if ($RepositoryRoot -match '\s') { + $temporaryJunction = Join-Path ([IO.Path]::GetTempPath()) ( + "minimum-build-{0}" -f [guid]::NewGuid().ToString("N")) + New-Item -ItemType Junction -Path $temporaryJunction ` + -Target $RepositoryRoot -ErrorAction Stop | Out-Null + $buildRoot = $temporaryJunction + Write-Host "Using a temporary path without spaces for the Android NDK build." + } + $gradleWrapper = Join-Path $buildRoot "gradlew.bat" + if (-not (Test-Path -LiteralPath $gradleWrapper -PathType Leaf)) { + throw "Gradle wrapper is missing: $gradleWrapper" + } + Write-Host "Building the FOSS debug APK from the current source..." + Push-Location $buildRoot + try { + & $gradleWrapper :app:assembleFossDebug --no-daemon + if ($LASTEXITCODE -ne 0) { + throw "FOSS debug APK build failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } + } finally { + if ($temporaryJunction -and (Test-Path -LiteralPath $temporaryJunction)) { + $junction = Get-Item -LiteralPath $temporaryJunction -Force + if ($junction.LinkType -ne "Junction") { + throw "Refusing to remove unexpected temporary build path: $temporaryJunction" + } + $junctionTarget = (Resolve-Path -LiteralPath @($junction.Target)[0]).Path + if ($junctionTarget -ne $RepositoryRoot) { + throw "Refusing to remove temporary junction with unexpected target: $junctionTarget" + } + [IO.Directory]::Delete($temporaryJunction) + } + } +} + +function Invoke-ModelPreparation { + param([Parameter(Mandatory)][string]$Profile) + $prepareScript = if ($Profile -eq "T56") { + Join-Path $PSScriptRoot "prepare-t56.ps1" + } else { + Join-Path $PSScriptRoot "prepare-t99.ps1" + } + $arguments = @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $prepareScript, + "-AdbPort", "$AdbPort", "-Force", "-LabWifiSsid", $LabWifiSsid + ) + if ($script:targetRecord.TransportId -gt 0) { + $arguments += @("-TransportId", "$($script:targetRecord.TransportId)") + } else { + $arguments += @("-Serial", $script:targetRecord.Serial) + } + if ($DeviceProfile) { $arguments += @("-DeviceProfile", $DeviceProfile) } + if ($SkipZello) { $arguments += "-SkipZello" } + if ($SkipMinimumHome) { $arguments += "-SkipMinimumHome" } + if ($SkipLabWifi) { $arguments += "-SkipLabWifi" } + if ($SkipLocation) { $arguments += "-SkipLocation" } + if ($RequestNetworkLocationConsent) { $arguments += "-RequestNetworkLocationConsent" } + if ($RefreshLabWifi) { $arguments += "-RefreshLabWifi" } + if ($LabWifiCredentialPath) { + $arguments += @("-LabWifiCredentialPath", $LabWifiCredentialPath) + } + + Write-Host "Running guarded $Profile preparation..." + & powershell.exe @arguments + if ($LASTEXITCODE -ne 0) { + throw "$Profile preparation failed with exit code $LASTEXITCODE." + } +} + +function New-CredentialTemporaryFile { + param([Parameter(Mandatory)][System.Security.SecureString]$Credential) + $temporaryPath = Join-Path ([IO.Path]::GetTempPath()) ( + "minimum-device-credential-{0}.txt" -f [guid]::NewGuid().ToString("N")) + $bstr = [IntPtr]::Zero + $plainText = $null + try { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Credential) + $plainText = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + if ([string]::IsNullOrWhiteSpace($plainText) -or $plainText.Length -gt 4096) { + throw "The device credential is empty or too large." + } + foreach ($character in $plainText.ToCharArray()) { + $code = [int][char]$character + if ($code -lt 0x20 -or $code -gt 0x7e) { + throw "The device credential contains unsupported characters." + } + } + [IO.File]::WriteAllText($temporaryPath, $plainText, [Text.Encoding]::ASCII) + return $temporaryPath + } catch { + if (Test-Path -LiteralPath $temporaryPath) { + Remove-Item -LiteralPath $temporaryPath -Force + } + throw + } finally { + $plainText = $null + if ($bstr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +function Install-DeviceCredential { + param([Parameter(Mandatory)][string]$LocalPath) + $resolvedPath = (Resolve-Path -LiteralPath $LocalPath).Path + $credentialFile = Get-Item -LiteralPath $resolvedPath + if ($credentialFile.Length -le 0 -or $credentialFile.Length -gt 4096) { + throw "The device credential file must contain 1-4096 bytes." + } + $remotePath = "/data/local/tmp/minimum-device-credential-$PID.txt" + try { + Invoke-TargetAdb -Arguments @("push", $resolvedPath, $remotePath) | Out-Null + Invoke-TargetAdb -Arguments @("shell", "chmod", "644", $remotePath) | Out-Null + $result = Invoke-TargetAdb -Arguments @( + "shell", "am", "broadcast", "-W", + "-a", $CredentialProvisionAction, + "-n", $ProvisionReceiver, + "--es", "deviceConfigCredentialPath", $remotePath + ) + if (($result -join "`n") -notmatch '(?s)result=-1.*data="?credential-installed"?') { + throw "Minimum rejected the device credential." + } + } finally { + $cleanupArgs = $script:targetArgs + @("shell", "rm", "-f", $remotePath) + & $adbPath @cleanupArgs 1>$null 2>$null + } + Write-Host "Portal credential installed; no token value was displayed." +} + +function Ensure-DisplayAwake { + $powerState = (Invoke-TargetAdb -Arguments @("shell", "dumpsys", "power")) -join "`n" + if ($powerState -notmatch 'Display Power: state=OFF' -and + $powerState -notmatch 'mWakefulness=(Asleep|Dozing)') { + return + } + Invoke-TargetAdb -Arguments @("shell", "input", "keyevent", "224") | Out-Null + Start-Sleep -Seconds 1 + $powerState = (Invoke-TargetAdb -Arguments @("shell", "dumpsys", "power")) -join "`n" + if ($powerState -match 'Display Power: state=OFF' -or + $powerState -match 'mWakefulness=(Asleep|Dozing)') { + Invoke-TargetAdb -Arguments @("shell", "input", "keyevent", "26") | Out-Null + Start-Sleep -Seconds 2 + } +} + +function Wait-MinimumReady { + param( + [Parameter(Mandatory)][string]$Phase, + [Parameter(Mandatory)][string]$ExpectedDeviceId, + [Parameter(Mandatory)][int]$TimeoutSeconds + ) + $remoteUi = "/sdcard/minimum-provision-ready-$PID.xml" + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + Ensure-DisplayAwake + try { + Invoke-TargetAdb -Arguments @("shell", "uiautomator", "dump", $remoteUi) | Out-Null + $ui = (Invoke-TargetAdb -Arguments @("shell", "cat", $remoteUi)) -join "`n" + if ($ui -match 'content-desc="minimum-state-ready"') { + $status = Get-MinimumProvisioningStatus + if ($status -and $status.DeviceId -eq $ExpectedDeviceId -and + $status.CredentialPresent -and + $status.ActiveDeviceId -eq $ExpectedDeviceId -and + -not $status.Pending -and $status.ConfigVersion -gt 0 -and + $status.LastSuccessMs -gt 0) { + Write-Host "PASS: Minimum reached Ready $Phase with managed config v$($status.ConfigVersion)." + return + } + } + } finally { + $cleanupArgs = $script:targetArgs + @("shell", "rm", "-f", $remoteUi) + & $adbPath @cleanupArgs 1>$null 2>$null + } + Start-Sleep -Seconds 5 + } + throw "Minimum did not reach Ready $Phase within $TimeoutSeconds seconds. Check network, Portal registration, token and device config." +} + +function Wait-ForReturningTarget { + param( + [Parameter(Mandatory)][string]$Manufacturer, + [Parameter(Mandatory)][string]$Model, + [Parameter(Mandatory)][string]$OriginalSerial, + [Parameter(Mandatory)][int]$TimeoutSeconds + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + $devices = @(Get-ConnectedDevices) + $serialMatches = @($devices | Where-Object { $_.Serial -eq $OriginalSerial }) + if ($serialMatches.Count -eq 1) { + $candidate = Add-RecordHardwareIdentity -Record $serialMatches[0] + if ($candidate.Manufacturer -ieq $Manufacturer -and $candidate.Model -ieq $Model) { + return $candidate + } + } + $modelMatches = foreach ($device in $devices) { + $candidate = Add-RecordHardwareIdentity -Record $device + if ($candidate.Manufacturer -ieq $Manufacturer -and $candidate.Model -ieq $Model) { + $candidate + } + } + $modelMatches = @($modelMatches) + if ($modelMatches.Count -eq 1) { + return $modelMatches[0] + } + } + throw "Could not identify exactly one returning $Manufacturer/$Model after reboot. Connect only one unit of this model and rerun Ready verification." +} + +function Wait-AndroidBootCompleted { + param([Parameter(Mandatory)][int]$TimeoutSeconds) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $completed = ((Invoke-TargetAdb -Arguments @( + "shell", "getprop", "sys.boot_completed")) -join "").Trim() + if ($completed -eq "1") { + return + } + Start-Sleep -Seconds 2 + } + throw "Android did not finish booting within $TimeoutSeconds seconds." +} + +$Host.UI.RawUI.WindowTitle = "Minimum One-Shot Provisioning" +if ($GuidedMode) { + Write-Host "============================================================" + Write-Host " Minimum radio - one-shot setup" + Write-Host " No command-line parameters are required in guided mode." + Write-Host "============================================================" +} +$AdbPort = Select-AdbServerPort +$serverArgs = @("-P", "$AdbPort") +$target = Select-InitialTarget +Set-Target -Record $target +Write-Host "Target: $($target.Manufacturer)/$($target.Model) via $script:targetLabel" +if (-not $target.Profile) { + $apiLevel = Get-RecordProperty -Record $target -Name "ro.build.version.sdk" + $buildId = Get-RecordProperty -Record $target -Name "ro.build.display.id" + Write-Host "Unsupported hardware inventory: manufacturer='$($target.Manufacturer)' model='$($target.Model)' API='$apiLevel' build='$buildId'." + throw "Unknown hardware is not provisioned automatically. Complete physical key/PTT commissioning and add a guarded model profile first." +} +if ($RequestNetworkLocationConsent -and $target.Profile -ne "T56") { + throw "-RequestNetworkLocationConsent is supported only for T56." +} +if ($GuidedMode) { + Show-GuidedSetupMenu -Profile $target.Profile +} + +if (-not $ApkPath) { $ApkPath = $DefaultApkPath } +if ($BuildApk -or -not (Test-Path -LiteralPath $ApkPath -PathType Leaf)) { + Build-MinimumApk +} +$resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path +Write-Host "Installing Minimum APK without clearing app data..." +Invoke-TargetAdb -Arguments @("install", "-r", $resolvedApkPath) | Out-Null +$installed = Invoke-TargetAdb -Arguments @("shell", "pm", "path", $MinimumPackage) +if (-not $installed) { + throw "Minimum package verification failed after APK installation." +} + +Invoke-ModelPreparation -Profile $target.Profile +$deviceId = Get-MinimumDeviceId +Write-Host "Minimum Device ID: $deviceId" + +$temporaryCredentialPath = "" +try { + if (-not $DeviceConfigCredential -and -not $DeviceConfigCredentialPath) { + if ($NonInteractive) { + throw "-NonInteractive requires -DeviceConfigCredential or -DeviceConfigCredentialPath." + } + $portalModel = $target.Profile.ToLowerInvariant() + Write-Host "Register Device ID $deviceId as model '$portalModel' in the Minimum Portal and issue its one-time token." + if (-not $SkipOpenPortal) { + try { + Start-Process $PortalUrl + } catch { + Write-Warning "Could not open the Portal automatically. Open $PortalUrl manually." + } + } + $DeviceConfigCredential = Read-Host "Paste the one-time device token (input is hidden)" -AsSecureString + } + if ($DeviceConfigCredential) { + $temporaryCredentialPath = New-CredentialTemporaryFile -Credential $DeviceConfigCredential + Install-DeviceCredential -LocalPath $temporaryCredentialPath + } else { + Install-DeviceCredential -LocalPath $DeviceConfigCredentialPath + } +} finally { + $DeviceConfigCredential = $null + if ($temporaryCredentialPath -and (Test-Path -LiteralPath $temporaryCredentialPath)) { + Remove-Item -LiteralPath $temporaryCredentialPath -Force + } +} + +Invoke-TargetAdb -Arguments @("shell", "am", "start", "-n", $MinimumActivity) | Out-Null +Wait-MinimumReady -Phase "before reboot" -ExpectedDeviceId $deviceId ` + -TimeoutSeconds $ReadyTimeoutSeconds + +if (-not $SkipReboot) { + $originalSerial = $target.Serial + $manufacturer = $target.Manufacturer + $model = $target.Model + Write-Host "Rebooting for unattended startup acceptance..." + Invoke-TargetAdb -Arguments @("reboot") | Out-Null + $returningTarget = Wait-ForReturningTarget -Manufacturer $manufacturer -Model $model ` + -OriginalSerial $originalSerial -TimeoutSeconds $BootTimeoutSeconds + Set-Target -Record $returningTarget + Wait-AndroidBootCompleted -TimeoutSeconds $BootTimeoutSeconds + Wait-MinimumReady -Phase "after reboot" -ExpectedDeviceId $deviceId ` + -TimeoutSeconds $ReadyTimeoutSeconds +} + +Write-Host "PASS: $($target.Profile) Device ID $deviceId is provisioned and Ready."