diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index bf0f29df..5831525c 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -126,6 +126,47 @@ jobs: 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: Build temporary Wi-Fi provisioner + run: | + ./gradlew -p tools/t99-wifi-provisioner :app:assembleDebug --no-daemon --stacktrace + test -f tools/t99-wifi-provisioner/app/build/outputs/apk/debug/app-debug.apk + - name: Package standalone Windows provisioning bundle + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + BUNDLE_NAME="minimum-provisioning-${RELEASE_TAG}" + BUNDLE_DIR="$RUNNER_TEMP/$BUNDLE_NAME" + mkdir -p "$BUNDLE_DIR/scripts" "$BUNDLE_DIR/assets" + cp "Provision Minimum Device.cmd" "$BUNDLE_DIR/" + cp "minimum-${RELEASE_TAG}-foss.apk" "$BUNDLE_DIR/minimum-foss.apk" + cp scripts/provision-minimum-device.ps1 "$BUNDLE_DIR/scripts/" + cp scripts/prepare-t99.ps1 "$BUNDLE_DIR/scripts/" + cp scripts/prepare-t56.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" + printf '%s\n' "$RELEASE_TAG" > "$BUNDLE_DIR/VERSION.txt" + ( + cd "$RUNNER_TEMP" + zip -r "$GITHUB_WORKSPACE/$BUNDLE_NAME.zip" "$BUNDLE_NAME" + ) + unzip -l "$BUNDLE_NAME.zip" | tee "$RUNNER_TEMP/provisioning-bundle-files.txt" + for required in \ + "$BUNDLE_NAME/Provision Minimum Device.cmd" \ + "$BUNDLE_NAME/minimum-foss.apk" \ + "$BUNDLE_NAME/scripts/provision-minimum-device.ps1" \ + "$BUNDLE_NAME/scripts/prepare-t99.ps1" \ + "$BUNDLE_NAME/scripts/prepare-t56.ps1" \ + "$BUNDLE_NAME/assets/t99-wifi-provisioner.apk" \ + "$BUNDLE_NAME/README.txt" \ + "$BUNDLE_NAME/VERSION.txt"; do + grep -F "$required" "$RUNNER_TEMP/provisioning-bundle-files.txt" + done + if unzip -l "$BUNDLE_NAME.zip" | grep -E '(^|/)(\.secrets|signing\.gradle|.*\.jks)(/|$)'; then + echo "Provisioning bundle contains a forbidden secret/signing path." >&2 + exit 1 + fi + sha256sum "$BUNDLE_NAME.zip" > "$BUNDLE_NAME.zip.sha256" - name: Publish GitHub release env: GH_TOKEN: ${{ github.token }} @@ -137,4 +178,6 @@ jobs: gh release create "$RELEASE_TAG" \ "minimum-${RELEASE_TAG}-foss.apk" \ "minimum-${RELEASE_TAG}-foss.apk.sha256" \ + "minimum-provisioning-${RELEASE_TAG}.zip" \ + "minimum-provisioning-${RELEASE_TAG}.zip.sha256" \ --verify-tag --generate-notes --title "Minimum $RELEASE_TAG" "${prerelease_flag[@]}" diff --git a/docs/DEVELOPMENT_RUNBOOK.md b/docs/DEVELOPMENT_RUNBOOK.md index af9e7fe5..b8b950a1 100644 --- a/docs/DEVELOPMENT_RUNBOOK.md +++ b/docs/DEVELOPMENT_RUNBOOK.md @@ -170,6 +170,11 @@ guided flow detects the active ADB port, explains how to authorize USB debugging 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. +For a field workstation without a source checkout, download +`minimum-provisioning-.zip` from the GitHub Release, verify its `.sha256`, extract the complete +folder and double-click the launcher there. That bundle includes the signed APK and temporary Wi-Fi +helper, so Gradle and the project source are not required on the operator workstation. + 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 diff --git a/docs/GITHUB_RELEASE_WORKFLOW.md b/docs/GITHUB_RELEASE_WORKFLOW.md index c20ee2c9..fc015c12 100644 --- a/docs/GITHUB_RELEASE_WORKFLOW.md +++ b/docs/GITHUB_RELEASE_WORKFLOW.md @@ -107,7 +107,11 @@ APRS passcodes and cached positions. The manual `.github/workflows/release-apk.yml` workflow checks out an existing numeric `x.y.z...` tag, requires the reviewed Android `versionCode`, builds `:app:assembleFossRelease`, verifies the -package/version/signature, creates a SHA-256 file and publishes the tagged GitHub Release. Its +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 protected `release` environment must provide: - `MINIMUM_RELEASE_KEYSTORE_BASE64` diff --git a/docs/PROVISIONING_BUNDLE_README.txt b/docs/PROVISIONING_BUNDLE_README.txt new file mode 100644 index 00000000..36db56db --- /dev/null +++ b/docs/PROVISIONING_BUNDLE_README.txt @@ -0,0 +1,48 @@ +Minimum device provisioning bundle +================================== + +This bundle prepares one supported T99 or T56 radio on Windows. + +Requirements +------------ + +- Windows 10 or Windows 11 +- Android Platform Tools (adb.exe) available in PATH +- Internet access to https://minimum.vra.or.th/ +- A Minimum Portal administrator account +- USB debugging enabled and authorized on the radio + +Use +--- + +1. Extract the complete ZIP. Do not run files from inside the ZIP preview. +2. Connect and unlock one radio, then authorize USB debugging. +3. Double-click "Provision Minimum Device.cmd". +4. Press Enter for the recommended setup, or choose the custom menu. +5. Register the displayed Device ID and model in the Minimum Portal. +6. Copy the one-time device token and paste it into the hidden prompt. +7. Wait for PASS before disconnecting the radio. + +ADB ports +--------- + +Port 5037 is the Android standard. Port 5041 is the Minimum lab alternative. The launcher detects +the port with an authorized device and presents a menu only when it cannot make a safe choice. + +Security and safety +------------------- + +- The device token is never printed or placed in an ADB command argument. +- Temporary token files are removed from Windows and the radio. +- The lab Wi-Fi credential is protected by Windows DPAPI for the current Windows account. +- Unknown hardware is inventory-reported and rejected before APK installation or provisioning. +- PASS requires managed config activation and Ready both before and after reboot. + +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. + +The included APK is signed and versioned by the GitHub Release workflow. Verify the SHA-256 files +on the Release page before use. This prerelease still requires physical acceptance on the target +T99/T56 hardware before it is promoted as a stable field release. diff --git a/scripts/prepare-t99.ps1 b/scripts/prepare-t99.ps1 index 80db2bbe..9f45e04e 100644 --- a/scripts/prepare-t99.ps1 +++ b/scripts/prepare-t99.ps1 @@ -328,16 +328,22 @@ function Invoke-LabWifiProvisioning { [Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential ) - $helperRoot = Join-Path $PSScriptRoot "..\tools\t99-wifi-provisioner" - $gradleWrapper = Join-Path $PSScriptRoot "..\gradlew.bat" - $helperApk = Join-Path $helperRoot "app\build\outputs\apk\debug\app-debug.apk" - if (-not (Test-Path -LiteralPath $gradleWrapper)) { - throw "Gradle wrapper is missing; cannot build the temporary Wi-Fi provisioner." - } + $bundledHelperApk = Join-Path $PSScriptRoot "..\assets\t99-wifi-provisioner.apk" + if (Test-Path -LiteralPath $bundledHelperApk -PathType Leaf) { + $helperApk = $bundledHelperApk + Write-Host "Using the Wi-Fi provisioner included in the Release bundle." + } else { + $helperRoot = Join-Path $PSScriptRoot "..\tools\t99-wifi-provisioner" + $gradleWrapper = Join-Path $PSScriptRoot "..\gradlew.bat" + $helperApk = Join-Path $helperRoot "app\build\outputs\apk\debug\app-debug.apk" + if (-not (Test-Path -LiteralPath $gradleWrapper)) { + throw "Neither the bundled Wi-Fi provisioner nor the Gradle wrapper is available." + } - & $gradleWrapper -p $helperRoot :app:assembleDebug - if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $helperApk)) { - throw "Temporary Wi-Fi provisioner build failed." + & $gradleWrapper -p $helperRoot :app:assembleDebug + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $helperApk)) { + throw "Temporary Wi-Fi provisioner build failed." + } } $temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ( diff --git a/scripts/provision-minimum-device.ps1 b/scripts/provision-minimum-device.ps1 index 12c60d82..3514e397 100644 --- a/scripts/provision-minimum-device.ps1 +++ b/scripts/provision-minimum-device.ps1 @@ -53,7 +53,14 @@ $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" +$BundledApkPath = Join-Path $RepositoryRoot "minimum-foss.apk" +$SourceBuildApkPath = Join-Path $RepositoryRoot "app\build\outputs\apk\foss\debug\mumla-foss-debug.apk" +$SourceBuildAvailable = Test-Path -LiteralPath (Join-Path $RepositoryRoot "gradlew.bat") -PathType Leaf +$DefaultApkPath = if (Test-Path -LiteralPath $BundledApkPath -PathType Leaf) { + $BundledApkPath +} else { + $SourceBuildApkPath +} try { $adbPath = (Get-Command adb -ErrorAction Stop).Source } catch { @@ -149,7 +156,11 @@ function Show-GuidedSetupMenu { Write-Host "" Write-Host "Recommended setup will:" - Write-Host " - build and install the latest Minimum test APK" + if ($SourceBuildAvailable) { + Write-Host " - build and install the latest Minimum test APK" + } else { + Write-Host " - install the signed Minimum APK included in this Release bundle" + } 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" @@ -164,13 +175,17 @@ function Show-GuidedSetupMenu { Write-Host "Cancelled. No APK was installed and no provisioning change was made." exit 0 } - $script:BuildApk = $true + $script:BuildApk = $SourceBuildAvailable if (-not $mode) { return } - $answer = (Read-Host "Build the latest APK? [Y/n]").Trim() - if ($answer -ieq "N") { $script:BuildApk = $false } + if ($SourceBuildAvailable) { + $answer = (Read-Host "Build the latest APK? [Y/n]").Trim() + if ($answer -ieq "N") { $script:BuildApk = $false } + } else { + Write-Host "Source build tools are not included; the bundled signed APK will be used." + } $answer = (Read-Host "Configure/verify lab Wi-Fi? [Y/n]").Trim() if ($answer -ieq "N") { $script:SkipLabWifi = $true } @@ -441,6 +456,42 @@ function Build-MinimumApk { } } +function Test-TargetLabWifiConnected { + $connectivity = (Invoke-TargetAdb -Arguments @("shell", "dumpsys", "connectivity")) -join "`n" + $escapedSsid = [regex]::Escape($LabWifiSsid) + return $connectivity -match + "(?s)type:\s*WIFI.*?state:\s*CONNECTED/CONNECTED.*?extra:\s*`"$escapedSsid`"" +} + +function Ensure-LabWifiCredential { + param([Parameter(Mandatory)][string]$Profile) + if ($SkipLabWifi -or (Test-TargetLabWifiConnected)) { + return + } + if (-not $LabWifiCredentialPath) { + $credentialName = "{0}-lab-wifi.credential.xml" -f $Profile.ToLowerInvariant() + $script:LabWifiCredentialPath = Join-Path $PSScriptRoot ".secrets\$credentialName" + } + if (Test-Path -LiteralPath $LabWifiCredentialPath) { + return + } + if ($NonInteractive) { + throw "Lab Wi-Fi is not connected. Create the DPAPI credential or pass -SkipLabWifi." + } + + Write-Host "Lab Wi-Fi '$LabWifiSsid' is not connected. Windows will request its password." + $credential = Get-Credential -UserName $LabWifiSsid ` + -Message "Enter the Minimum lab Wi-Fi password" + if (-not $credential) { + throw "Lab Wi-Fi credential entry was cancelled." + } + $credentialDirectory = Split-Path -Parent $LabWifiCredentialPath + New-Item -ItemType Directory -Path $credentialDirectory -Force | Out-Null + $credential | Export-Clixml -LiteralPath $LabWifiCredentialPath + $credential = $null + Write-Host "Lab Wi-Fi credential saved with Windows DPAPI for this account." +} + function Invoke-ModelPreparation { param([Parameter(Mandatory)][string]$Profile) $prepareScript = if ($Profile -eq "T56") { @@ -667,6 +718,7 @@ if (-not $installed) { throw "Minimum package verification failed after APK installation." } +Ensure-LabWifiCredential -Profile $target.Profile Invoke-ModelPreparation -Profile $target.Profile $deviceId = Get-MinimumDeviceId Write-Host "Minimum Device ID: $deviceId"