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