diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c36de4..46498a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,14 +44,14 @@ jobs: run: | set -euo pipefail raw="$(sed -nE 's/^version:[[:space:]]*//p' pubspec.yaml | head -n1 | tr -d '\r')" - if [[ ! "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?)\+([0-9]+)$ ]]; then + if [[ ! "$raw" =~ ^([0-9]+\.[0-9]+\.[0-9]+-xarleyn\.([1-9][0-9]*))\+([1-9][0-9]*)$ ]]; then echo "Unexpected pubspec version format: $raw" >&2 exit 1 fi version_name="${BASH_REMATCH[1]}" version_code="${BASH_REMATCH[3]}" tag="v${version_name}" - asset_name="meshcore-wardrive-${version_name}-${version_code}.apk" + asset_name="meshcore-wardrive-xarleyn-${version_name}-${version_code}.apk" { echo "raw=${raw}" echo "version_name=${version_name}" @@ -61,6 +61,27 @@ jobs: } >> "$GITHUB_OUTPUT" echo "Publishing ${raw} as ${tag} (${asset_name})" + - name: Ensure versionCode increases + env: + GH_TOKEN: ${{ github.token }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + run: | + set -euo pipefail + max_published_code=0 + while IFS= read -r asset_name; do + if [[ "$asset_name" =~ -([0-9]+)\.apk$ ]] && + (( BASH_REMATCH[1] > max_published_code )); then + max_published_code="${BASH_REMATCH[1]}" + fi + done < <( + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '.[].assets[].name' + ) + if (( VERSION_CODE <= max_published_code )); then + echo "versionCode ${VERSION_CODE} must exceed published ${max_published_code}." >&2 + exit 1 + fi + - name: Ensure release tag is free env: GH_TOKEN: ${{ github.token }} @@ -119,6 +140,37 @@ jobs: - name: Build release APK run: flutter build apk --release + - name: Verify release APK identity and signature + env: + EXPECTED_CERT_SHA256: ${{ secrets.ANDROID_CERT_SHA256 }} + VERSION_NAME: ${{ steps.version.outputs.version_name }} + VERSION_CODE: ${{ steps.version.outputs.version_code }} + run: | + set -euo pipefail + if [[ -z "$EXPECTED_CERT_SHA256" ]]; then + echo "Missing secret ANDROID_CERT_SHA256" >&2 + exit 1 + fi + apk="build/app/outputs/flutter-apk/app-release.apk" + build_tools="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n1)" + aapt="$build_tools/aapt" + apksigner="$build_tools/apksigner" + test -x "$aapt" + test -x "$apksigner" + + badging="$($aapt dump badging "$apk" | head -n1)" + [[ "$badging" == *"name='io.github.xarleyn.meshcore.wardrive'"* ]] + [[ "$badging" == *"versionCode='${VERSION_CODE}'"* ]] + [[ "$badging" == *"versionName='${VERSION_NAME}'"* ]] + + "$apksigner" verify --verbose "$apk" + actual_cert="$($apksigner verify --print-certs "$apk" | sed -nE 's/^Signer #1 certificate SHA-256 digest: (.*)$/\1/p' | head -n1 | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')" + expected_cert="$(printf '%s' "$EXPECTED_CERT_SHA256" | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]')" + if [[ -z "$actual_cert" || "$actual_cert" != "$expected_cert" ]]; then + echo "APK signer SHA-256 does not match ANDROID_CERT_SHA256." >&2 + exit 1 + fi + - name: Prepare release asset env: ASSET_NAME: ${{ steps.version.outputs.asset_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c152d7..c14f446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Breaking change + +- Changed the Android package ID from `mintylinux.meshcore.wardrive` to + `io.github.xarleyn.meshcore.wardrive`. Android installs this fork separately + from the original application and from older fork builds. No data migration + is provided: the new package starts with empty local data and default + settings, and requires permissions, background/MIUI settings, device access, + widgets, offline maps, and credentials to be configured again. + ### Added - Independent Map Display toggles for privacy zones and GPS exclusion zones. @@ -36,6 +45,13 @@ both their descriptions and their unlock logic, without converting between units: the thresholds stay 10/100/500, so 100 miles and 100 km unlock the same badge depending on the setting. +- Debug builds now use `io.github.xarleyn.meshcore.wardrive.debug`, allowing a + debug and signed release build of the fork to coexist. +- Fork releases use `base-xarleyn.N` version names. The update checker compares + both the base version and fork revision, ignores downgrades, and selects the + newest compatible tag from multiple GitHub releases. +- The release workflow verifies package ID, version name, increasing version + code, APK signature, release tag, and asset name before publication. ### Fixed diff --git a/README.md b/README.md index fadbdee..86ac60b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ companion radio over USB or Bluetooth, and displays coverage on a Flutter map. ## 📥 Download Pre-built APK -**Latest Release:** [Download from Releases Repository](https://github.com/mintylinux/Meshcore-Wardrive-Android) +**Latest Release:** [Download the xarleyn fork](https://github.com/xarleyn/Meshcore-Wardrive-Android-Source/releases) + +The fork uses Android package ID `io.github.xarleyn.meshcore.wardrive`, so it +can coexist with the original app. The first fork-identity release is a clean +installation and does not migrate data from `mintylinux.meshcore.wardrive`. ## 🚀 Features @@ -47,8 +51,8 @@ companion radio over USB or Bluetooth, and displays coverage on a Flutter map. 1. Clone this repository: ```bash -git clone https://github.com/mintylinux/Meshcore-Wardrive-Android-Source.git -cd meshcore_wardrive +git clone https://github.com/xarleyn/Meshcore-Wardrive-Android-Source.git +cd Meshcore-Wardrive-Android-Source ``` 2. Install dependencies: @@ -69,7 +73,8 @@ flutter run ### Building Release APK Configure the stable signing key once, then use the release script. It -increments Android's build number and keeps the displayed version synchronized: +increments Android's build number and the `-xarleyn.N` fork revision while +keeping the displayed version synchronized: ```powershell .\tool\build_release.ps1 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5c2f88d..3b957c2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -47,7 +47,7 @@ if (releaseTaskRequested) { } android { - namespace = "mintylinux.meshcore.wardrive" + namespace = "io.github.xarleyn.meshcore.wardrive" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -57,7 +57,7 @@ android { } defaultConfig { - applicationId = "mintylinux.meshcore.wardrive" + applicationId = "io.github.xarleyn.meshcore.wardrive" minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode @@ -74,6 +74,9 @@ android { } buildTypes { + debug { + applicationIdSuffix = ".debug" + } release { signingConfig = signingConfigs.getByName("release") } diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..bf6a547 --- /dev/null +++ b/android/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + MeshCore Wardrive (xarleyn Debug) + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4125e0f..cd62931 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ diff --git a/android/app/src/main/kotlin/mintylinux/meshcore/wardrive/MainActivity.kt b/android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/MainActivity.kt similarity index 96% rename from android/app/src/main/kotlin/mintylinux/meshcore/wardrive/MainActivity.kt rename to android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/MainActivity.kt index b4c2544..3c469c4 100644 --- a/android/app/src/main/kotlin/mintylinux/meshcore/wardrive/MainActivity.kt +++ b/android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/MainActivity.kt @@ -1,4 +1,4 @@ -package mintylinux.meshcore.wardrive +package io.github.xarleyn.meshcore.wardrive import android.Manifest import android.content.Context @@ -24,10 +24,10 @@ import io.flutter.plugin.common.MethodChannel class MainActivity : FlutterActivity() { private val TAG = "MeshcoreFeedback" - private val CHANNEL = "mintylinux.meshcore.wardrive/feedback" - private val WIFI_CHANNEL = "mintylinux.meshcore.wardrive/wifi_location" + private val CHANNEL = "io.github.xarleyn.meshcore.wardrive/feedback" + private val WIFI_CHANNEL = "io.github.xarleyn.meshcore.wardrive/wifi_location" private val TRACKING_SETTINGS_CHANNEL = - "mintylinux.meshcore.wardrive/tracking_settings" + "io.github.xarleyn.meshcore.wardrive/tracking_settings" private var toneGenerator: ToneGenerator? = null private fun getVibrator(): Vibrator { diff --git a/android/app/src/main/kotlin/mintylinux/meshcore/wardrive/WardriveWidgetProvider.kt b/android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/WardriveWidgetProvider.kt similarity index 98% rename from android/app/src/main/kotlin/mintylinux/meshcore/wardrive/WardriveWidgetProvider.kt rename to android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/WardriveWidgetProvider.kt index 722f084..0aef374 100644 --- a/android/app/src/main/kotlin/mintylinux/meshcore/wardrive/WardriveWidgetProvider.kt +++ b/android/app/src/main/kotlin/io/github/xarleyn/meshcore/wardrive/WardriveWidgetProvider.kt @@ -1,4 +1,4 @@ -package mintylinux.meshcore.wardrive +package io.github.xarleyn.meshcore.wardrive import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 0da5412..d18ada0 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1,6 +1,6 @@ - MeshCore Wardrive + MeshCore Wardrive (xarleyn) Ожидание Замеры LoRa diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 428e484..68c866b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - MeshCore Wardrive + MeshCore Wardrive (xarleyn) Idle Samples LoRa diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index b0b37b9..fd451f1 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -17,6 +17,18 @@ 3. Wait for installation to complete 4. Tap **Open** or find the app in your app drawer +### Important: fresh fork identity + +Starting with `v1.0.44-xarleyn.1`, this fork uses the Android package ID +`io.github.xarleyn.meshcore.wardrive`. The original application and older fork +builds used `mintylinux.meshcore.wardrive`, so Android treats this release as a +different application. Both applications can be installed at the same time. + +There is no automatic data migration. The new fork starts with an empty +database and default settings; data in the old package is not available to it. +Treat old measurements and settings as lost for this transition. Uninstalling +the old package permanently deletes its private data. + ### Step 4: Grant Permissions On first launch, grant these permissions: - **Location** (Always) - Required for GPS tracking @@ -61,6 +73,8 @@ On first launch, grant these permissions: - Make sure you have enough storage space (need ~100MB free) - For an update, make sure the APK has the same application ID and signing certificate as the installed app, and a higher build number +- `v1.0.44-xarleyn.1` is a fresh install and does not replace a build using + `mintylinux.meshcore.wardrive` - Restart your device and try again ### Permissions denied @@ -74,7 +88,9 @@ On first launch, grant these permissions: ## Updating -To update to a new version: +For updates between releases that already use +`io.github.xarleyn.meshcore.wardrive`: + 1. Download the new APK 2. Install over the existing app (data will be preserved) @@ -83,6 +99,12 @@ Uninstalling deletes the app's settings and collected wardrive data. If Android rejects the update because its signing certificate differs, export or back up important data before uninstalling the old app. +The one-time change from `mintylinux.meshcore.wardrive` to +`io.github.xarleyn.meshcore.wardrive` is not an update. After installing the +new fork, grant permissions again and reconfigure background location, battery +optimization/MIUI autostart, Bluetooth or USB access, widgets, offline maps, +and any stored credentials. No migration from the old package is provided. + ## Uninstalling 1. Go to Settings → Apps → MeshCore Wardrive diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 71c3be7..22cac15 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -2,7 +2,7 @@ Android accepts an APK as an update only when all of the following remain true: -- the `applicationId` is unchanged (`mintylinux.meshcore.wardrive`); +- the `applicationId` is unchanged (`io.github.xarleyn.meshcore.wardrive`); - the new `versionCode` is greater than the installed one; - the new APK is signed with the same certificate as the installed APK. @@ -10,6 +10,10 @@ Application settings and the SQLite database surviving installation are a reliable sign that Android performed an in-place update. Uninstalling the app deletes that private application data. +The package ID changed once at `v1.0.44-xarleyn.1`. That release is a fresh +installation, not an in-place update of `mintylinux.meshcore.wardrive`; no data +migration is implemented. All later releases must retain the new package ID. + ## Configure stable signing once Release builds intentionally fail when signing is not configured. This avoids @@ -32,10 +36,10 @@ Changing from an old debug key to a dedicated release key is itself a signing change. Existing installations signed by the old key must be uninstalled once, unless the original signing key is retained for releases. -`flutter run` installs a debug-signed APK with the same application ID. It can -update another debug installation, but it cannot replace an APK signed with the -dedicated release key. Use the signed release APK when validating the real -upgrade path; use `flutter run` for development-only device validation. +`flutter run` installs `io.github.xarleyn.meshcore.wardrive.debug`, while signed +releases use `io.github.xarleyn.meshcore.wardrive`. Debug and release builds can +therefore coexist and keep independent data. Use the signed release APK when +validating the real upgrade path. ## Build and increment the version @@ -45,15 +49,16 @@ From PowerShell at the repository root, run: .\tool\build_release.ps1 ``` -The script increments only the Android build number (`versionCode`), for -example `1.0.42+45` to `1.0.42+46`, synchronizes the in-app version constant, -and builds the release APK. If the build fails, it restores both version files. +The script increments the fork revision and Android build number, for example +`1.0.44-xarleyn.1+47` to `1.0.44-xarleyn.2+48`, synchronizes the in-app version +constant, and builds the release APK. If the build fails, it restores both +version files. -For a new public version, pass its semantic version name. The build number is -still incremented automatically: +When adopting a new upstream base version, reset the fork revision to `.1`. +The build number is still incremented automatically: ```powershell -.\tool\build_release.ps1 -VersionName 1.0.43 +.\tool\build_release.ps1 -VersionName 1.0.45-xarleyn.1 ``` To produce per-architecture APKs, add `-SplitPerAbi`. @@ -64,9 +69,13 @@ The lower-level cross-platform version commands are: . .\.toolchain\env.ps1 dart run tool/version.dart check dart run tool/version.dart sync -dart run tool/version.dart bump 1.0.43 +dart run tool/version.dart bump 1.0.45-xarleyn.1 ``` +The version tool rejects names without the required `-xarleyn.N` suffix and +rejects zero or invalid Android build numbers. The `bump` command without a +version name increments both the fork revision and `versionCode`. + Commit `pubspec.yaml` and `lib/constants/app_version.dart` together after a successful release build. Never commit `android/key.properties`, a keystore, or generated APK files. @@ -87,6 +96,7 @@ Configure these secrets on the fork before the first Actions release: | `ANDROID_KEYSTORE_PASSWORD` | `storePassword` from `key.properties` | | `ANDROID_KEY_PASSWORD` | `keyPassword` from `key.properties` | | `ANDROID_KEY_ALIAS` | `keyAlias` from `key.properties` | +| `ANDROID_CERT_SHA256` | SHA-256 fingerprint of the release certificate (colons optional) | On Linux or macOS: @@ -101,16 +111,42 @@ On Windows PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes('C:\secure\meshcore-wardrive-upload.jks')) | Set-Clipboard ``` +Read the expected certificate fingerprint with: + +```powershell +. .\.toolchain\env.ps1 +keytool -list -v -keystore C:\secure\meshcore-wardrive-upload.jks -alias upload +``` + +Copy the value shown as `SHA256` into `ANDROID_CERT_SHA256`. + ### Publish 1. Commit the desired `version:` in `pubspec.yaml` (and matching `lib/constants/app_version.dart`) on the branch you want to ship. 2. Open **Actions → Release APK → Run workflow**. 3. Optionally mark the GitHub Release as a pre-release. -4. The workflow creates tag `v{versionName}` (for example `v1.0.42` from - `1.0.42+45`) and attaches - `meshcore-wardrive-{versionName}-{versionCode}.apk`. +4. The workflow creates tag `v{versionName}` (for example + `v1.0.44-xarleyn.1`) and attaches + `meshcore-wardrive-xarleyn-{versionName}-{versionCode}.apk`. If that tag or release already exists, the workflow fails so you can bump the -version first. Continuous integration on `main` runs format checks, analyzer, -tests, and a debug APK build; it does not publish releases. +version first. It also fails when the version suffix, package ID, version code, +version name, or signing certificate is unexpected, or when `versionCode` is +not greater than codes found in existing release asset names. Continuous +integration on `main` runs format checks, analyzer, tests, and a debug APK +build; it does not publish releases. + +## Manual release validation + +Before publication, validate on a device that: + +- the original app, the fork release, and the fork debug build can coexist; +- upgrading from one `-xarleyn.N` release to the next preserves fork data; +- USB, Bluetooth LE, foreground location, notifications, and the widget work; +- MIUI Security Space and battery/autostart settings are configured for the new + package independently. + +The release workflow performs `aapt dump badging` and `apksigner verify` +equivalents automatically, but physical radio and Android background behavior +still require device testing. diff --git a/lib/constants/app_version.dart b/lib/constants/app_version.dart index f3ded4b..3c74e22 100644 --- a/lib/constants/app_version.dart +++ b/lib/constants/app_version.dart @@ -1,2 +1,2 @@ /// Application version synchronized from pubspec.yaml by tool/version.dart. -const String appVersion = '1.0.43'; +const String appVersion = '1.0.44-xarleyn.1'; diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 628c762..227396b 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -1637,14 +1637,17 @@ class _MapScreenState extends State { if (response.statusCode == 200) { final releases = jsonDecode(response.body) as List; - final latestVersion = releases.isEmpty - ? null - : versionFromReleaseTag(releases.first['tag_name'].toString()); + final latestVersion = latestVersionFromReleaseTags( + releases + .whereType>() + .map((release) => release['tag_name']) + .whereType(), + ); if (!mounted) return; if (latestVersion == null) { _showSnackBar(AppLocalizations.of(context).mapCouldNotCheckUpdates); - } else if (latestVersion == appVersion) { + } else if (!isNewerAppVersion(latestVersion, appVersion)) { _showSnackBar(AppLocalizations.of(context).mapOnLatestVersion); } else { final shouldDownload = await showDialog( @@ -1998,7 +2001,7 @@ class _MapScreenState extends State { ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png' : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', subdomains: isDarkMode ? const ['a', 'b', 'c', 'd'] : const [], - userAgentPackageName: 'com.meshcore.wardrive', + userAgentPackageName: 'io.github.xarleyn.meshcore.wardrive', tileProvider: _tileCacheStore != null ? CachedTileProvider(store: _tileCacheStore!) : null, diff --git a/lib/screens/settings/sections/about_section.dart b/lib/screens/settings/sections/about_section.dart index 2645e44..b6ceb35 100644 --- a/lib/screens/settings/sections/about_section.dart +++ b/lib/screens/settings/sections/about_section.dart @@ -15,6 +15,11 @@ List buildAboutSettings( title: l10n.settingsSectionAbout, icon: Icons.info_outline, ), + const ListTile( + title: Text('xarleyn fork'), + subtitle: Text('io.github.xarleyn.meshcore.wardrive'), + leading: Icon(Icons.call_split), + ), ListTile( title: Text(l10n.settingsCheckForUpdates), subtitle: Text(l10n.settingsAboutCurrentVersion(version)), diff --git a/lib/services/android_tracking_settings_service.dart b/lib/services/android_tracking_settings_service.dart index d88231f..6761017 100644 --- a/lib/services/android_tracking_settings_service.dart +++ b/lib/services/android_tracking_settings_service.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart'; class AndroidTrackingSettingsService { static const MethodChannel _channel = MethodChannel( - 'mintylinux.meshcore.wardrive/tracking_settings', + 'io.github.xarleyn.meshcore.wardrive/tracking_settings', ); Future isWifiScanThrottlingEnabled() { diff --git a/lib/services/sound_service.dart b/lib/services/sound_service.dart index efdf0be..af7c344 100644 --- a/lib/services/sound_service.dart +++ b/lib/services/sound_service.dart @@ -22,7 +22,7 @@ class SoundService { SoundService._(); static const _channel = MethodChannel( - 'mintylinux.meshcore.wardrive/feedback', + 'io.github.xarleyn.meshcore.wardrive/feedback', ); final SettingsService _settings = SettingsService(); bool _enabled = true; diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart index f81b710..98404eb 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -8,7 +8,7 @@ import 'settings_service.dart'; /// Wraps HomeWidget calls to push app state to the Android home screen widget. class WidgetService { static const String _androidWidgetName = 'WardriveWidgetProvider'; - static const String _appGroupId = 'mintylinux.meshcore.wardrive'; + static const String _appGroupId = 'io.github.xarleyn.meshcore.wardrive'; static Future initialize() async { await HomeWidget.setAppGroupId(_appGroupId); diff --git a/lib/services/wifi_location_service.dart b/lib/services/wifi_location_service.dart index 179c73b..feace65 100644 --- a/lib/services/wifi_location_service.dart +++ b/lib/services/wifi_location_service.dart @@ -51,7 +51,7 @@ class WifiLocationService { _scanner = scanner ?? _scanWithPlatformChannel; static const _channel = MethodChannel( - 'mintylinux.meshcore.wardrive/wifi_location', + 'io.github.xarleyn.meshcore.wardrive/wifi_location', ); static final Uri _endpoint = Uri.parse( 'https://api.beacondb.net/v1/geolocate', diff --git a/lib/utils/update_check.dart b/lib/utils/update_check.dart index 6010b60..cfea233 100644 --- a/lib/utils/update_check.dart +++ b/lib/utils/update_check.dart @@ -7,14 +7,80 @@ library; const String updateCheckApiUrl = - 'https://api.github.com/repos/xarleyn/Meshcore-Wardrive-Android-Source/releases?per_page=1'; + 'https://api.github.com/repos/xarleyn/Meshcore-Wardrive-Android-Source/releases?per_page=10'; const String updateCheckReleasesUrl = 'https://github.com/xarleyn/Meshcore-Wardrive-Android-Source/releases'; -/// Extracts a dotted version such as `1.0.42` from a release tag like -/// `v1.0.42`, `1.0.42`, or `Meshcore-Wardrive-Android-Source-1.0.42`. -/// Returns null when the tag carries no dotted version. +final RegExp _releaseVersionPattern = RegExp( + r'(\d+)\.(\d+)\.(\d+)(?:-xarleyn\.([1-9]\d*))?$', +); + +class ReleaseVersion implements Comparable { + const ReleaseVersion({ + required this.name, + required this.major, + required this.minor, + required this.patch, + required this.forkRevision, + }); + + final String name; + final int major; + final int minor; + final int patch; + + /// Legacy release tags without a fork suffix are revision zero. + final int forkRevision; + + static ReleaseVersion? fromTag(String tagName) { + final match = _releaseVersionPattern.firstMatch(tagName); + if (match == null) return null; + return ReleaseVersion( + name: match.group(0)!, + major: int.parse(match.group(1)!), + minor: int.parse(match.group(2)!), + patch: int.parse(match.group(3)!), + forkRevision: int.tryParse(match.group(4) ?? '') ?? 0, + ); + } + + @override + int compareTo(ReleaseVersion other) { + for (final comparison in [ + major.compareTo(other.major), + minor.compareTo(other.minor), + patch.compareTo(other.patch), + forkRevision.compareTo(other.forkRevision), + ]) { + if (comparison != 0) return comparison; + } + return 0; + } +} + +/// Extracts the complete supported version from a fork or legacy release tag. String? versionFromReleaseTag(String tagName) { - return RegExp(r'\d+(?:\.\d+)+').firstMatch(tagName)?.group(0); + return ReleaseVersion.fromTag(tagName)?.name; +} + +/// Selects the greatest supported version regardless of API response order. +String? latestVersionFromReleaseTags(Iterable tagNames) { + ReleaseVersion? latest; + for (final tagName in tagNames) { + final candidate = ReleaseVersion.fromTag(tagName); + if (candidate != null && + (latest == null || candidate.compareTo(latest) > 0)) { + latest = candidate; + } + } + return latest?.name; +} + +/// Returns false for equal, older, or malformed versions. +bool isNewerAppVersion(String candidate, String current) { + final candidateVersion = ReleaseVersion.fromTag(candidate); + final currentVersion = ReleaseVersion.fromTag(current); + if (candidateVersion == null || currentVersion == null) return false; + return candidateVersion.compareTo(currentVersion) > 0; } diff --git a/pubspec.yaml b/pubspec.yaml index e86c6f1..2f9c08b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.43+46 +version: 1.0.44-xarleyn.1+47 environment: sdk: ^3.13.0 diff --git a/test/android_tracking_settings_service_test.dart b/test/android_tracking_settings_service_test.dart index ccd8b84..d7c22af 100644 --- a/test/android_tracking_settings_service_test.dart +++ b/test/android_tracking_settings_service_test.dart @@ -6,7 +6,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); const channel = MethodChannel( - 'mintylinux.meshcore.wardrive/tracking_settings', + 'io.github.xarleyn.meshcore.wardrive/tracking_settings', ); final service = AndroidTrackingSettingsService(); diff --git a/test/sound/sound_service_test.dart b/test/sound/sound_service_test.dart index ffcf43c..33d98d5 100644 --- a/test/sound/sound_service_test.dart +++ b/test/sound/sound_service_test.dart @@ -6,7 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const channel = MethodChannel('mintylinux.meshcore.wardrive/feedback'); + const channel = MethodChannel('io.github.xarleyn.meshcore.wardrive/feedback'); final toneCalls = >[]; final vibrateCalls = >[]; diff --git a/test/update/update_check_test.dart b/test/update/update_check_test.dart index 48a32c3..e7d643e 100644 --- a/test/update/update_check_test.dart +++ b/test/update/update_check_test.dart @@ -11,6 +11,10 @@ void main() { expect(versionFromReleaseTag('v1.0.43'), '1.0.43'); }); + test('preserves the fork revision', () { + expect(versionFromReleaseTag('v1.0.44-xarleyn.1'), '1.0.44-xarleyn.1'); + }); + test('parses a repository-prefixed tag', () { expect( versionFromReleaseTag('Meshcore-Wardrive-Android-Source-1.0.43'), @@ -18,14 +22,51 @@ void main() { ); }); - test('returns only the dotted version part', () { - expect(versionFromReleaseTag('release-2.10-beta'), '2.10'); - }); - - test('returns null for tags without a dotted version', () { + test('returns null for unsupported tags', () { expect(versionFromReleaseTag('nightly'), isNull); expect(versionFromReleaseTag(''), isNull); expect(versionFromReleaseTag('build-42'), isNull); + expect(versionFromReleaseTag('release-2.10-beta'), isNull); + expect(versionFromReleaseTag('v1.0.44-xarleyn.0'), isNull); + }); + }); + + group('fork version ordering', () { + test('selects the highest version from unordered release tags', () { + expect( + latestVersionFromReleaseTags([ + 'v1.0.44-xarleyn.1', + 'nightly', + 'v1.0.43', + 'v1.0.45-xarleyn.1', + 'v1.0.44-xarleyn.2', + ]), + '1.0.45-xarleyn.1', + ); + }); + + test('recognizes a newer fork revision', () { + expect(isNewerAppVersion('1.0.44-xarleyn.2', '1.0.44-xarleyn.1'), isTrue); + }); + + test('recognizes a newer upstream base version', () { + expect(isNewerAppVersion('1.0.45-xarleyn.1', '1.0.44-xarleyn.9'), isTrue); + }); + + test('does not report an equal or older release as an update', () { + expect( + isNewerAppVersion('1.0.44-xarleyn.1', '1.0.44-xarleyn.1'), + isFalse, + ); + expect( + isNewerAppVersion('1.0.44-xarleyn.1', '1.0.45-xarleyn.1'), + isFalse, + ); + }); + + test('supports legacy tags as fork revision zero', () { + expect(isNewerAppVersion('1.0.44-xarleyn.1', '1.0.44'), isTrue); + expect(isNewerAppVersion('1.0.43', '1.0.44-xarleyn.1'), isFalse); }); }); @@ -47,7 +88,7 @@ void main() { test('uses the releases list so prereleases are found', () { // /releases/latest ignores prereleases and would 404 while the // repository only has a prerelease published. - expect(updateCheckApiUrl, contains('?per_page=1')); + expect(updateCheckApiUrl, contains('?per_page=10')); expect(updateCheckApiUrl, isNot(contains('/releases/latest'))); }); }); diff --git a/test/version_tool_test.dart b/test/version_tool_test.dart index 603f3e8..e312117 100644 --- a/test/version_tool_test.dart +++ b/test/version_tool_test.dart @@ -7,26 +7,26 @@ void main() { test('parses the project version', () { final version = parsePubspecVersion(''' name: meshcore_wardrive -version: 1.2.3+47 +version: 1.2.3-xarleyn.4+47 '''); - expect(version.name, '1.2.3'); + expect(version.name, '1.2.3-xarleyn.4'); expect(version.buildNumber, 47); }); test('increments and replaces only the pubspec version', () { const contents = ''' name: meshcore_wardrive -version: 1.2.3+47 +version: 1.2.3-xarleyn.4+47 description: version: remains text '''; final updated = replacePubspecVersion( contents, - const ProjectVersion('1.2.4-beta.1', 48), + const ProjectVersion('1.2.4-xarleyn.1', 48), ); - expect(updated, contains('version: 1.2.4-beta.1+48')); + expect(updated, contains('version: 1.2.4-xarleyn.1+48')); expect(updated, contains('description: version: remains text')); }); @@ -37,21 +37,29 @@ const String appVersion = '1.2.3'; '''; expect( - replaceAppVersion(contents, '1.2.4'), - contains("const String appVersion = '1.2.4';"), + replaceAppVersion(contents, '1.2.4-xarleyn.1'), + contains("const String appVersion = '1.2.4-xarleyn.1';"), ); }); - test('rejects invalid version names', () { + test('increments the fork revision by default', () { + expect(nextForkVersionName('1.2.4-xarleyn.1'), '1.2.4-xarleyn.2'); + }); + + test('rejects versions without a positive fork revision', () { + expect( + () => validateVersion('1.2.3', 2), + throwsA(isA()), + ); expect( - () => validateVersion('release-1', 2), + () => validateVersion('1.2.3-xarleyn.0', 2), throwsA(isA()), ); }); test('rejects Android version codes outside the supported range', () { expect( - () => validateVersion('1.2.3', 2100000001), + () => validateVersion('1.2.3-xarleyn.1', 2100000001), throwsA(isA()), ); }); diff --git a/tool/build_release.ps1 b/tool/build_release.ps1 index 4c992ec..d7a2d68 100644 --- a/tool/build_release.ps1 +++ b/tool/build_release.ps1 @@ -1,5 +1,5 @@ param( - [ValidatePattern('^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$')] + [ValidatePattern('^\d+\.\d+\.\d+-xarleyn\.[1-9]\d*$')] [string]$VersionName, [switch]$SplitPerAbi diff --git a/tool/version.dart b/tool/version.dart index c9bd3e7..1be4071 100644 --- a/tool/version.dart +++ b/tool/version.dart @@ -5,7 +5,7 @@ const _appVersionPath = 'lib/constants/app_version.dart'; const _maxAndroidVersionCode = 2100000000; final RegExp _versionNamePattern = RegExp( - r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$', + r'^(\d+\.\d+\.\d+)-xarleyn\.([1-9]\d*)$', ); final RegExp _pubspecVersionPattern = RegExp( r'^version:\s*([^\s+]+)\+(\d+)\s*$', @@ -40,8 +40,8 @@ ProjectVersion parsePubspecVersion(String contents) { void validateVersion(String name, int buildNumber) { if (!_versionNamePattern.hasMatch(name)) { throw FormatException( - 'Invalid version name "$name". Expected semantic versioning, for ' - 'example 1.2.3 or 1.2.3-beta.1.', + 'Invalid version name "$name". Expected the fork version format, for ' + 'example 1.2.3-xarleyn.1.', ); } if (buildNumber < 1 || buildNumber > _maxAndroidVersionCode) { @@ -54,6 +54,16 @@ void validateVersion(String name, int buildNumber) { } } +String nextForkVersionName(String currentName) { + final match = _versionNamePattern.firstMatch(currentName); + if (match == null) { + validateVersion(currentName, 1); + throw StateError('Unreachable version validation state.'); + } + final revision = int.parse(match.group(2)!); + return '${match.group(1)}-xarleyn.${revision + 1}'; +} + String replacePubspecVersion(String contents, ProjectVersion version) { parsePubspecVersion(contents); validateVersion(version.name, version.buildNumber); @@ -121,7 +131,9 @@ void main(List arguments) { ); stdout.writeln('Synchronized app version: $current'); case 'bump': - final nextName = arguments.length == 2 ? arguments[1] : current.name; + final nextName = arguments.length == 2 + ? arguments[1] + : nextForkVersionName(current.name); final next = ProjectVersion(nextName, current.buildNumber + 1); validateVersion(next.name, next.buildNumber); pubspecFile.writeAsStringSync(