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
63 changes: 59 additions & 4 deletions .github/workflows/release-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.1)
description: Existing release tag pointing at the commit to build (for example 3.7.3-minimum.2)
required: true
type: string
prerelease:
Expand Down Expand Up @@ -118,14 +118,37 @@ jobs:
EXPECTED_VERSION_CODE: ${{ inputs.expected_version_code }}
EXPECTED_APPLICATION_ID: ${{ vars.MINIMUM_RELEASE_APPLICATION_ID }}
run: |
set -o pipefail
APK=app/build/outputs/apk/foss/release/mumla-foss-release.apk
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK"
"$ANDROID_HOME/build-tools/36.0.0/apksigner" verify --verbose --print-certs "$APK" | tee "$RUNNER_TEMP/apk-signature.txt"
mapfile -t signer_shas < <(sed -n 's/^Signer #[0-9][0-9]* certificate SHA-256 digest: //p' "$RUNNER_TEMP/apk-signature.txt")
if [[ ${#signer_shas[@]} -ne 1 || ! "${signer_shas[0]}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "Release APK must have exactly one reviewed signing certificate; missing or extra signers are refused." >&2
exit 1
fi
signer_sha=${signer_shas[0]}
echo "MINIMUM_APK_SIGNER_SHA256=${signer_sha^^}" >> "$GITHUB_ENV"
"$ANDROID_HOME/build-tools/36.0.0/aapt" dump badging "$APK" | tee "$RUNNER_TEMP/apk-badging.txt"
grep -F "package: name='$EXPECTED_APPLICATION_ID'" "$RUNNER_TEMP/apk-badging.txt"
grep -F "versionCode='$EXPECTED_VERSION_CODE'" "$RUNNER_TEMP/apk-badging.txt"
grep -F "versionName='$RELEASE_TAG'" "$RUNNER_TEMP/apk-badging.txt"
cp "$APK" "minimum-${RELEASE_TAG}-foss.apk"
sha256sum "minimum-${RELEASE_TAG}-foss.apk" > "minimum-${RELEASE_TAG}-foss.apk.sha256"
- name: Test Windows updater logic
shell: pwsh
run: |
$errors = $null
$tokens = $null
[System.Management.Automation.Language.Parser]::ParseFile(
(Resolve-Path 'scripts/update-minimum-device.ps1'),
[ref]$tokens,
[ref]$errors) | Out-Null
if ($errors.Count -gt 0) {
$errors | Format-List *
throw "Updater failed PowerShell AST parsing."
}
./tools/verify-cellular-policy.ps1
./tests/update-minimum-device.Tests.ps1
- name: Build temporary Wi-Fi provisioner
run: |
./gradlew -p tools/t99-wifi-provisioner :app:assembleDebug --no-daemon --stacktrace
Expand All @@ -144,26 +167,54 @@ jobs:
rm -f -- "$OUTPUT_ZIP" "$OUTPUT_ZIP.sha256"
mkdir -p "$BUNDLE_DIR/scripts" "$BUNDLE_DIR/assets"
cp "Provision Minimum Device.cmd" "$BUNDLE_DIR/"
cp "Update Minimum Device.cmd" "$BUNDLE_DIR/"
cp "minimum-${RELEASE_TAG}-foss.apk" "$BUNDLE_DIR/minimum-foss.apk"
cp "minimum-${RELEASE_TAG}-foss.apk.sha256" "$BUNDLE_DIR/minimum-foss.apk.sha256"
sed -i "s#minimum-${RELEASE_TAG}-foss.apk#minimum-foss.apk#" "$BUNDLE_DIR/minimum-foss.apk.sha256"
cp scripts/provision-minimum-device.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/update-minimum-device.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/manage-cellular.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-t99.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-t56.ps1 "$BUNDLE_DIR/scripts/"
cp scripts/prepare-ryks.ps1 "$BUNDLE_DIR/scripts/"
cp tools/t99-wifi-provisioner/app/build/outputs/apk/debug/app-debug.apk \
"$BUNDLE_DIR/assets/t99-wifi-provisioner.apk"
cp docs/PROVISIONING_BUNDLE_README.txt "$BUNDLE_DIR/README.txt"
cp docs/UPDATER_RUNBOOK.md "$BUNDLE_DIR/UPDATER-README.md"
cp docs/CELLULAR_PROVISIONING.md "$BUNDLE_DIR/CELLULAR-README.md"
printf '%s\n' "$RELEASE_TAG" > "$BUNDLE_DIR/VERSION.txt"
apk_sha=$(sha256sum "$BUNDLE_DIR/minimum-foss.apk" | cut -d' ' -f1)
file_entries="$RUNNER_TEMP/release-manifest-files.json"
find "$BUNDLE_DIR" -type f -printf '%P\n' | LC_ALL=C sort | while IFS= read -r relative; do
jq -cn --arg path "$relative" --arg sha256 "$(sha256sum "$BUNDLE_DIR/$relative" | cut -d' ' -f1 | tr '[:lower:]' '[:upper:]')" \
'{path:$path,sha256:$sha256}'
done | jq -s . > "$file_entries"
jq -n \
--arg releaseTag "$RELEASE_TAG" \
--argjson versionCode '${{ inputs.expected_version_code }}' \
--arg apkSha256 "${apk_sha^^}" \
--arg signerSha256 "$MINIMUM_APK_SIGNER_SHA256" \
--slurpfile files "$file_entries" \
'{schemaVersion:1,releaseTag:$releaseTag,applicationId:"se.lublin.mumla",versionCode:$versionCode,versionName:$releaseTag,apkFile:"minimum-foss.apk",apkSha256:$apkSha256,signerSha256:$signerSha256,rebootRequired:false,migrations:[{id:"CELLULAR_POLICY_V1_T56",fromVersionCodeMax:3070300,toVersionCode:3070301,profiles:["T56"],rebootRequired:true,irreversible:false}],files:$files[0]}' \
> "$BUNDLE_DIR/RELEASE-MANIFEST.json"
expected_files="$RUNNER_TEMP/provisioning-bundle-expected-files.txt"
expected_dirs="$RUNNER_TEMP/provisioning-bundle-expected-dirs.txt"
printf '%s\n' \
"$BUNDLE_NAME/Provision Minimum Device.cmd" \
"$BUNDLE_NAME/Update Minimum Device.cmd" \
"$BUNDLE_NAME/README.txt" \
"$BUNDLE_NAME/CELLULAR-README.md" \
"$BUNDLE_NAME/RELEASE-MANIFEST.json" \
"$BUNDLE_NAME/UPDATER-README.md" \
"$BUNDLE_NAME/VERSION.txt" \
"$BUNDLE_NAME/minimum-foss.apk" \
"$BUNDLE_NAME/minimum-foss.apk.sha256" \
"$BUNDLE_NAME/scripts/prepare-ryks.ps1" \
"$BUNDLE_NAME/scripts/manage-cellular.ps1" \
"$BUNDLE_NAME/scripts/prepare-t56.ps1" \
"$BUNDLE_NAME/scripts/prepare-t99.ps1" \
"$BUNDLE_NAME/scripts/provision-minimum-device.ps1" \
"$BUNDLE_NAME/scripts/update-minimum-device.ps1" \
"$BUNDLE_NAME/assets/t99-wifi-provisioner.apk" > "$expected_files"
sort -o "$expected_files" "$expected_files"
printf '%s\n' \
Expand Down Expand Up @@ -194,7 +245,7 @@ jobs:
echo "Credential-like content pattern found in staged text file (filename only): ${text_file#$BUNDLE_DIR/}"
exit 1
fi
done < <(find "$text_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' \) -print)
done < <(find "$text_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print)
done
(
cd "$RUNNER_TEMP"
Expand Down Expand Up @@ -243,7 +294,9 @@ jobs:
echo "Credential-like content pattern found in extracted text file (filename only): ${text_file#$extracted_root/}"
exit 1
fi
done < <(find "$extracted_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' \) -print)
done < <(find "$extracted_root" -type f \( -name '*.cmd' -o -name '*.ps1' -o -name '*.txt' -o -name '*.md' -o -name '*.json' \) -print)
pwsh -NoLogo -NoProfile -Command \
". '$extracted_root/scripts/update-minimum-device.ps1' -LibraryOnly; Read-ReleaseBundle -Root '$extracted_root' | Out-Null; \$identity = Get-ApkManifestIdentity -ApkPath '$extracted_root/minimum-foss.apk'; if (\$identity.ApplicationId -cne 'se.lublin.mumla' -or \$identity.VersionName -cne '$RELEASE_TAG') { throw 'Extracted updater APK identity verification failed.' }; \$signers = @(Get-ApkSignerDigests -ApkPath '$extracted_root/minimum-foss.apk'); if (\$signers.Count -ne 1 -or \$signers[0] -cne '$MINIMUM_APK_SIGNER_SHA256') { throw 'Extracted updater APK signer-set verification failed.' }"
echo "Provisioning bundle verification passed: exact allowlist, regular files, no symlinks, safe paths, staged and extracted content checks."
sha256sum "$OUTPUT_ZIP" > "$OUTPUT_ZIP.sha256"
- name: Prepare reviewed release notes
Expand Down Expand Up @@ -289,6 +342,8 @@ jobs:
echo "## Physical-test status and known limitations"
echo "- Physical acceptance is operator-gated; this workflow does not claim PTT, audio, room-switching or Location success without reviewed device evidence."
echo "- Provisioning PASS requires same-ID Ready after reboot; \`-SkipReboot\` is explicitly INCOMPLETE."
echo "- The existing-device updater verifies the exact bundle/APK/signer, preserves app data, and refuses unknown migrations or debug-to-release signer changes."
echo "- Updater acceptance must be recorded separately for every model/signing channel claimed; no release workflow run itself proves physical acceptance."
echo "- T56 network Location requires on-device consent within 120 seconds and remains subject to the documented manual safety boundary."
echo
echo "## Generated changes"
Expand Down
17 changes: 17 additions & 0 deletions Update Minimum Device.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@echo off
setlocal
title Minimum One-Shot Updater
echo Starting Minimum device update...
echo.
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\update-minimum-device.ps1"
set "MINIMUM_UPDATE_EXIT=%ERRORLEVEL%"
echo.
if "%MINIMUM_UPDATE_EXIT%"=="0" (
echo Update window finished.
) else (
echo UPDATE FAILED with exit code %MINIMUM_UPDATE_EXIT%.
echo Read the sanitized error above, correct it, then run this updater again.
)
echo.
pause
exit /b %MINIMUM_UPDATE_EXIT%
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ android {
// Remember: app_news_items_vX_Y_Z in src/main/res/values/strings.xml
// and NEWS_ITEMS in src/main/res/java/se/lublin/mumla/app/DialogUtils.java
// code:XYYZZbb (bb for build)
versionCode 3070300
versionCode 3070301
versionName gitDescribe

buildConfigField "long", "TIMESTAMP", System.currentTimeMillis() + "L"
Expand Down
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_EXISTING_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" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ public String getOrCreateDeviceId() {
return generated;
}

/** Returns the existing identity without creating or modifying app state. */
public String getExistingDeviceId() {
String existing = preferences.getString(DEVICE_ID_PREFERENCE, null);
return isValidDeviceId(existing) ? existing : null;
}

/**
* Generates a new identity for an explicitly authorized administrative action.
* Callers must protect the UI/action that invokes this method.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ public JSONObject loadActiveOrDefault() throws IOException, JSONException {
return fallback;
}

/** Reads the active Last Known Good cache without fallback, rollback, creation, or mutation. */
public JSONObject loadActiveForReport() throws IOException, JSONException {
synchronized (CACHE_LOCK) {
File active = new File(cacheDirectoryForRead(), ACTIVE_FILE);
if (!active.isFile()) {
throw new IOException("active radio config is missing");
}
JSONObject cached = readJson(active);
validateCompleteConfig(cached, null);
return cached;
}
}

/**
* Fetches and merges default, model and optional device configuration. The result is staged as
* pending and cannot replace the Last Known Good active config until the radio proves it works.
Expand Down Expand Up @@ -513,6 +526,14 @@ private File cacheDirectory() {
return directory;
}

private File cacheDirectoryForRead() throws IOException {
File directory = new File(context.getFilesDir(), "radio-config");
if (!directory.isDirectory()) {
throw new IOException("radio config cache is missing");
}
return directory;
}

private void writePendingLocked(JSONObject config) throws IOException {
File directory = cacheDirectory();
File pending = new File(directory, PENDING_FILE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;

import androidx.preference.PreferenceManager;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Locale;

import se.lublin.mumla.Settings;
import se.lublin.mumla.service.MumlaService;

/** Narrow, shell-permission-protected ADB entry point for managed radio provisioning. */
Expand All @@ -28,6 +33,8 @@ public final class RadioProvisionReceiver extends BroadcastReceiver {
"se.lublin.mumla.action.PROVISION_DEVICE_PROFILE";
public static final String ACTION_REPORT_IDENTITY =
"se.lublin.mumla.action.PROVISION_REPORT_IDENTITY";
public static final String ACTION_REPORT_EXISTING_IDENTITY =
"se.lublin.mumla.action.PROVISION_REPORT_EXISTING_IDENTITY";
public static final String ACTION_REPORT_STATUS =
"se.lublin.mumla.action.PROVISION_REPORT_STATUS";
public static final String ACTION_INSTALL_RADIO_CONFIG =
Expand Down Expand Up @@ -59,6 +66,11 @@ public void onReceive(Context context, Intent intent) {
PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId();
setResultCode(-1);
setResultData(deviceId);
} else if (ACTION_REPORT_EXISTING_IDENTITY.equals(intent.getAction())) {
String deviceId = new DeviceIdentityManager(
PreferenceManager.getDefaultSharedPreferences(context)).getExistingDeviceId();
setResultCode(deviceId == null ? 0 : -1);
setResultData(deviceId == null ? "unavailable" : deviceId);
} else if (ACTION_REPORT_STATUS.equals(intent.getAction())) {
reportProvisioningStatus(context);
} else if (ACTION_INSTALL_RADIO_CONFIG.equals(intent.getAction())) {
Expand All @@ -73,25 +85,55 @@ private void reportProvisioningStatus(Context context) {
setResultCode(0);
setResultData("unavailable");
try {
String deviceId = new DeviceIdentityManager(
PreferenceManager.getDefaultSharedPreferences(context)).getOrCreateDeviceId();
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
String deviceId = new DeviceIdentityManager(preferences).getExistingDeviceId();
if (deviceId == null) {
return;
}
RadioConfigRepository repository = new RadioConfigRepository(context);
org.json.JSONObject active = repository.loadActiveOrDefault();
org.json.JSONObject active = repository.loadActiveForReport();
String activeDeviceId = active.optString("deviceId", "");
int configVersion = active.optInt("configVersion", -1);
String selectedChannel = preferences.getString("radio_selected_channel_id", "");
String activeConfigDigest = sha256(active.toString());
String safeSettingsDigest = sha256(String.format(Locale.US,
"%s|%s|%s|%s|%s|%s|%s|%s",
preferences.getString(Settings.PREF_INPUT_METHOD, ""),
preferences.getBoolean(Settings.PREF_PTT_TOGGLE, false),
preferences.getBoolean(Settings.PREF_AUTO_RECONNECT, false),
preferences.getBoolean(Settings.PREF_PREPROCESSOR_ENABLED, false),
preferences.getBoolean(Settings.PREF_HALF_DUPLEX, false),
preferences.getBoolean(Settings.PREF_USE_TTS, false),
preferences.getBoolean(Settings.PREF_PTT_SOUND, false),
preferences.getInt(Settings.PREF_PUSH_KEY, -1)));
setResultCode(-1);
setResultData(String.format(Locale.US,
"deviceId=%s;activeDeviceId=%s;configVersion=%d;pending=%s;lastSuccessMs=%d",
"deviceId=%s;activeDeviceId=%s;configVersion=%d;pending=%s;lastSuccessMs=%d;"
+ "selectedChannel=%s;activeConfigSha256=%s;safeSettingsSha256=%s",
deviceId,
activeDeviceId,
configVersion,
repository.hasPending() ? "true" : "false",
RadioConfigUpdater.getLastSuccess(context)));
} catch (IOException | RuntimeException | org.json.JSONException ignored) {
RadioConfigUpdater.getLastSuccess(context),
selectedChannel,
activeConfigDigest,
safeSettingsDigest));
} catch (IOException | RuntimeException | org.json.JSONException
| NoSuchAlgorithmException ignored) {
// Status intentionally contains no config fields, endpoints or room data.
}
}

static String sha256(String value) throws NoSuchAlgorithmException {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder output = new StringBuilder(64);
for (byte item : digest) {
output.append(String.format(Locale.US, "%02X", item & 0xff));
}
return output.toString();
}

private void updateAprsObjectName(Context context, String objectName) {
setResultCode(0);
setResultData("rejected");
Expand Down
Loading
Loading