Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions Provision Minimum Device.cmd
Original file line number Diff line number Diff line change
@@ -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%
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
<action android:name="se.lublin.mumla.action.PROVISION_LAUNCHER_SHORTCUT" />
<action android:name="se.lublin.mumla.action.PROVISION_DEVICE_PROFILE" />
<action android:name="se.lublin.mumla.action.PROVISION_REPORT_IDENTITY" />
<action android:name="se.lublin.mumla.action.PROVISION_REPORT_STATUS" />
<action android:name="se.lublin.mumla.action.PROVISION_RADIO_CONFIG" />
<action android:name="se.lublin.mumla.action.PROVISION_DEVICE_CONFIG_CREDENTIAL" />
<action android:name="se.lublin.mumla.action.PROVISION_APRS_OBJECT_NAME" />
Expand Down
21 changes: 19 additions & 2 deletions app/src/main/java/se/lublin/mumla/radio/RadioConfigUpdater.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 =
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down
60 changes: 60 additions & 0 deletions docs/DEVELOPMENT_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading