From a472b0c05c928658b2a86bcde9ea4ede73d03cd0 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 26 Apr 2026 08:24:04 +0200 Subject: [PATCH 1/5] chore: Add Play release cycle #31 ref: --- .github/workflows/build-artifacts.yml | 72 ++++++++++++++- android/Gemfile | 4 + android/fastlane/Fastfile | 82 +++++++++++------ docs/google-play-release.md | 128 ++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 android/Gemfile create mode 100644 docs/google-play-release.md diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index 2169fca..18c283f 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: "17" @@ -44,6 +44,14 @@ jobs: cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }} pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }} + - name: Setup Ruby + if: github.event_name == 'release' + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: android + - name: Cache Android NDK uses: actions/cache@v4 with: @@ -55,22 +63,76 @@ jobs: - name: Install dependencies run: flutter pub get + - name: Validate Android release secrets + if: github.event_name == 'release' + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + test -n "$ANDROID_KEYSTORE_BASE64" + test -n "$ANDROID_KEYSTORE_PASSWORD" + test -n "$ANDROID_KEY_ALIAS" + test -n "$ANDROID_KEY_PASSWORD" + test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" + + - name: Create Android signing files + if: github.event_name == 'release' + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release-keystore.jks + cat < android/key.properties + storePassword=$ANDROID_KEYSTORE_PASSWORD + keyPassword=$ANDROID_KEY_PASSWORD + keyAlias=$ANDROID_KEY_ALIAS + storeFile=../release-keystore.jks + EOF + - name: Build APK run: flutter build apk --release - - name: Prepare APK artifact + - name: Build Play release AAB + if: github.event_name == 'release' + run: flutter build appbundle --release + + - name: Prepare Android artifacts run: | RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}" TAG="${RAW_TAG//\//-}" cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk" + if [ -f build/app/outputs/bundle/release/app-release.aab ]; then + cp build/app/outputs/bundle/release/app-release.aab "${APP_NAME}-${TAG}-play.aab" + fi - - name: Upload APK artifact + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: release-android - path: "${{ env.APP_NAME }}-*-android.apk" + path: | + ${{ env.APP_NAME }}-*-android.apk + ${{ env.APP_NAME }}-*-play.aab if-no-files-found: error + - name: Upload Play release to internal testing + if: github.event_name == 'release' + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + SKIP_ANDROID_BUILD: "1" + run: cd android && bundle exec fastlane android internal + + - name: Upload Play release to production + if: github.event_name == 'release' && !github.event.release.prerelease + env: + GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }} + SKIP_ANDROID_BUILD: "1" + run: cd android && bundle exec fastlane android production + build-linux: name: Build Linux App runs-on: ubuntu-latest @@ -348,6 +410,7 @@ jobs: body: | Release assets are published for all supported build targets: - Android (`.apk`) + - Google Play (`.aab`) - Linux (`.tar.gz`) - macOS (`.dmg`) - Windows (`.zip`) @@ -362,6 +425,7 @@ jobs: 4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators). files: | dist/*.apk + dist/*.aab dist/*.tar.gz dist/*.zip dist/*.dmg diff --git a/android/Gemfile b/android/Gemfile new file mode 100644 index 0000000..859faf2 --- /dev/null +++ b/android/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "ostruct" diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index cd0233a..f18cbc5 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -16,44 +16,72 @@ default_platform(:android) platform :android do + def project_root + File.expand_path("../..", __dir__) + end + + def play_service_account_json + ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON") + end + + def play_build_command + "cd #{project_root} && flutter build appbundle --release" + end + + def play_aab_path + File.join(project_root, "build/app/outputs/bundle/release/app-release.aab") + end + + def play_package_name + "com.meshcore.sar.meshcore_sar_app" + end + + def direct_apk_build_command + "cd #{project_root} && flutter build apk --release" + end + + def direct_apk_path + File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk") + end + + def skip_android_build? + ENV["SKIP_ANDROID_BUILD"] == "1" + end + desc "Runs all the tests" lane :test do gradle(task: "test") end desc "Build release APK" - lane :beta do - # Get the project root directory (two levels up from android/fastlane/) - project_root = File.expand_path("../..", __dir__) + lane :direct_apk do + sh(direct_apk_build_command) - # Build APK with Flutter - sh("cd #{project_root} && flutter build apk --release") - - apk_path = File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk") - - # Uncomment to distribute via Firebase App Distribution: - # firebase_app_distribution( - # app: "YOUR_FIREBASE_APP_ID", - # apk_path: apk_path, - # groups: "testers" - # ) - - UI.success("APK built at: #{apk_path}") + UI.success("APK built successfully at: #{direct_apk_path}") end - desc "Deploy a new version to the Google Play" - lane :deploy do - # Get the project root directory (two levels up from android/fastlane/) - project_root = File.expand_path("../..", __dir__) - - # Build AAB with Flutter (required for Play Store since 2021) - sh("cd #{project_root} && flutter build appbundle --release") - - aab_path = File.join(project_root, "build/app/outputs/bundle/release/app-release.aab") + desc "Build Play release AAB and upload to internal testing" + lane :internal do + sh(play_build_command) unless skip_android_build? upload_to_play_store( - aab: aab_path, - track: "internal" + aab: play_aab_path, + package_name: play_package_name, + track: "internal", + release_status: "draft", + json_key_data: play_service_account_json + ) + end + + desc "Build Play release AAB and upload to production" + lane :production do + sh(play_build_command) unless skip_android_build? + + upload_to_play_store( + aab: play_aab_path, + package_name: play_package_name, + track: "production", + json_key_data: play_service_account_json ) end end diff --git a/docs/google-play-release.md b/docs/google-play-release.md new file mode 100644 index 0000000..03e73b2 --- /dev/null +++ b/docs/google-play-release.md @@ -0,0 +1,128 @@ +# Google Play Release Runbook + +## Purpose + +This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`. + +## Distribution Modes + +- GitHub release: signed APK for direct install or manual distribution. +- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production. + +## Required Secrets and Local Files + +GitHub Actions secrets for `dz0ny/meshcore-sar`: + +- `ANDROID_KEYSTORE_BASE64` +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` +- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` + +Local files used to seed the secrets: + +- `android/key.properties` +- release keystore referenced by `android/key.properties` +- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json` + +The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`. + +## CI Release Flow + +The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published. + +Android release steps: + +1. Validate all Android signing and Google Play secrets. +2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets. +3. Build the signed APK with `flutter build apk --release`. +4. Build the signed Play AAB with `flutter build appbundle --release`. +5. Upload the APK and AAB as GitHub release assets. +6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`. +7. Upload the AAB to Play production when the GitHub release is not marked as prerelease. + +The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts. + +## Versioning Rules + +- Version source of truth: `pubspec.yaml`. +- Android `versionCode` is the number after `+`. +- `versionCode` must always increase for Play uploads. +- `make bump`, `make build`, and `make bundle` increment the version. +- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally. + +## Manual Build Commands + +Run commands from the repo root. + +```bash +flutter build apk --release +flutter build appbundle --release +``` + +Repo shortcuts: + +```bash +make build +make bundle +make build-no-bump +make bundle-no-bump +``` + +## Fastlane Lanes + +Run commands from `android/`. + +```bash +bundle exec fastlane android direct_apk +bundle exec fastlane android internal +bundle exec fastlane android production +``` + +Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it. + +```bash +SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal +``` + +## Expected Artifacts + +- APK: `build/app/outputs/flutter-apk/app-release.apk` +- AAB: `build/app/outputs/bundle/release/app-release.aab` +- Release APK asset: `meshcore-sar--android.apk` +- Release AAB asset: `meshcore-sar--play.aab` + +## Internal Testing Release Checklist + +1. Confirm `pubspec.yaml` version is correct and `versionCode` increased. +2. Confirm `android/key.properties` points to the release keystore for local builds. +3. Confirm all five GitHub Actions secrets exist. +4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion. +5. Install from the internal testing track on a real Android device. +6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior. + +## Production Release Checklist + +1. Complete internal testing validation first. +2. Confirm Play Console forms are current: + - App content + - Data safety + - App access + - Ads declaration + - Content rating +3. Confirm store assets are current: + - app icon + - feature graphic + - phone screenshots + - tablet screenshots if used + - support URL + - privacy policy URL +4. Publish a non-prerelease GitHub release. +5. Confirm the GitHub release has the APK and AAB assets. +6. Confirm Play Console has the new internal and production release entries. + +## Rollback Rules + +- Never reuse or lower an Android `versionCode`. +- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix. +- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play. From 0b463f645460dba24250e6df2a6004c165407f93 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 26 Apr 2026 08:24:07 +0200 Subject: [PATCH 2/5] fix: Stabilize reported tests #31 ref: #32 #37 --- lib/providers/contacts_provider.dart | 10 ++++-- .../messages/recipient_selector_sheet.dart | 13 ++++++-- .../sensors/sensor_telemetry_card.dart | 32 ++++++++++++++++--- ...messages_provider_retransmission_test.dart | 4 ++- test/widgets/connection_dialog_test.dart | 5 ++- .../traffic_stats_reporting_section_test.dart | 22 ++++++------- 6 files changed, 63 insertions(+), 23 deletions(-) diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 2072988..c000a00 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -1263,11 +1263,17 @@ class ContactsProvider with ChangeNotifier { Map? existingExtraSensorData, Map? incomingExtraSensorData, ) { - final merged = {...?existingExtraSensorData}; if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) { - return merged; + return {...?existingExtraSensorData}; } + final isFullTelemetryRefresh = incomingExtraSensorData.containsKey( + _rawTelemetryHexKey, + ); + final merged = { + if (!isFullTelemetryRefresh) ...?existingExtraSensorData, + }; + final incomingMetricFamilies = incomingExtraSensorData.keys .map(_telemetryMetricFamilyForKey) .whereType() diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 09e7657..0653b62 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -328,9 +328,16 @@ class _RecipientSelectorSheetState extends State { MessagesProvider? messagesProvider, ) { final previewData = _channelPreviewData(context, channel, messagesProvider); - final sharingMode = context.watch().channelLocationSharingModeForChannel( - channel.publicKey.length > 1 ? channel.publicKey[1] : 0, - ); + ChannelLocationSharingMode? sharingMode; + try { + sharingMode = context + .watch() + .channelLocationSharingModeForChannel( + channel.publicKey.length > 1 ? channel.publicKey[1] : 0, + ); + } on ProviderNotFoundException { + sharingMode = null; + } return _buildRecipientCard( context: context, diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 22503f1..a811791 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -238,15 +238,27 @@ List sensorMetricOptionsFor( final coreFieldKeys = { if (batteryMilliVolts != null || batteryPercentage != null) ...extraSensorData?.keys.where( - (k) => k.startsWith('voltage_') || k.startsWith('analog_input_'), + (k) => + _isSourceChannelMetric(extraSensorData, 'voltage', k) || + _isSourceChannelMetric(extraSensorData, 'battery', k) || + k.startsWith('analog_input_'), ) ?? [], if (temperature != null) - ...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [], + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'temperature', k), + ) ?? + [], if (humidity != null) - ...extraSensorData?.keys.where((k) => k.startsWith('humidity_')) ?? [], + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'humidity', k), + ) ?? + [], if (pressure != null) - ...extraSensorData?.keys.where((k) => k.startsWith('pressure_')) ?? [], + ...extraSensorData?.keys.where( + (k) => _isSourceChannelMetric(extraSensorData, 'pressure', k), + ) ?? + [], }; if (extraSensorData != null) { @@ -3117,6 +3129,18 @@ int? _sourceChannelForField( return null; } +bool _isSourceChannelMetric( + Map? extraSensorData, + String fieldKey, + String metricKey, +) { + final sourceChannel = _sourceChannelForField(extraSensorData, fieldKey); + if (sourceChannel == null) { + return false; + } + return metricKey == '${fieldKey}_$sourceChannel'; +} + String _resolvedMetricLabel( String fieldKey, String defaultLabel, { diff --git a/test/providers/messages_provider_retransmission_test.dart b/test/providers/messages_provider_retransmission_test.dart index d5f60ad..0b6b189 100644 --- a/test/providers/messages_provider_retransmission_test.dart +++ b/test/providers/messages_provider_retransmission_test.dart @@ -926,7 +926,9 @@ void main() { ); provider.markMessageSent('m2', 88, 10); - async.elapse(const Duration(milliseconds: 11)); + final timeoutMs = provider.messages.single.suggestedTimeoutMs!; + + async.elapse(Duration(milliseconds: timeoutMs + 1)); async.flushMicrotasks(); expect(provider.messages.single.retryAttempt, 1); diff --git a/test/widgets/connection_dialog_test.dart b/test/widgets/connection_dialog_test.dart index 9122c64..41f5bdd 100644 --- a/test/widgets/connection_dialog_test.dart +++ b/test/widgets/connection_dialog_test.dart @@ -201,7 +201,10 @@ void main() { addTearDown(tester.view.reset); final connectionProvider = _TcpConnectableFakeConnectionProvider(); - final networkScanner = _FakeNetworkScannerService(initiallyScanning: true); + final networkScanner = _FakeNetworkScannerService( + initiallyScanning: true, + keepScanning: true, + ); await tester.pumpWidget( ChangeNotifierProvider.value( diff --git a/test/widgets/traffic_stats_reporting_section_test.dart b/test/widgets/traffic_stats_reporting_section_test.dart index 7865e84..9f939c9 100644 --- a/test/widgets/traffic_stats_reporting_section_test.dart +++ b/test/widgets/traffic_stats_reporting_section_test.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/services/traffic_stats_reporting_service.dart'; import 'package:meshcore_sar_app/widgets/settings/traffic_stats_reporting_section.dart'; @@ -57,6 +58,8 @@ void main() { await tester.pumpWidget( MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: ListenableBuilder( listenable: service, @@ -68,27 +71,22 @@ void main() { ), ); - expect(find.text('Anonymous RX stats reporting'), findsOneWidget); - expect( - find.text( - 'Upload RX live-traffic packet type and path mode totals to the fixed Cloudflare worker every 5 minutes.', - ), - findsOneWidget, - ); + expect(find.text('Anonymous RX stats'), findsOneWidget); + expect(find.text('Upload packet totals every 5 min'), findsOneWidget); expect(find.text('Reporting interval'), findsNothing); expect(service.isEnabled, isTrue); + await tester.tap(find.widgetWithText(TextButton, 'View')); + await tester.pump(); + + expect(launchedUrls, ['https://mcstats.dz0ny.dev']); + await tester.tap(find.byType(Switch)); await tester.pumpAndSettle(); expect(service.isEnabled, isFalse); expect(service.intervalMinutes, 5); - await tester.tap(find.widgetWithText(TextButton, 'View public stats')); - await tester.pump(); - - expect(launchedUrls, ['https://mcstats.dz0ny.dev']); - service.dispose(); }); } From d067095345ecd18a778437157420984aaf392e90 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 26 Apr 2026 08:24:09 +0200 Subject: [PATCH 3/5] fix: Add background service #37 ref: #31 --- android/app/src/main/AndroidManifest.xml | 9 +- lib/services/background_location_service.dart | 124 +++++++++++++++++- 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a0586bf..be8136f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ - + @@ -62,6 +63,12 @@ +