From 7940af71758193b62eea26038471acdef74d9617 Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:54:53 -0700 Subject: [PATCH 1/7] feat: add resilient network error handling --- .../lib/core/network/network_error.dart | 27 ++ .../lib/core/network/network_policy.dart | 27 ++ flutter-app/lib/services/vendo_service.dart | 262 ++++++++++++++++-- .../test/core/network/network_error_test.dart | 41 +++ .../core/network/network_policy_test.dart | 28 ++ 5 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 flutter-app/lib/core/network/network_error.dart create mode 100644 flutter-app/lib/core/network/network_policy.dart create mode 100644 flutter-app/test/core/network/network_error_test.dart create mode 100644 flutter-app/test/core/network/network_policy_test.dart diff --git a/flutter-app/lib/core/network/network_error.dart b/flutter-app/lib/core/network/network_error.dart new file mode 100644 index 00000000..503d9c82 --- /dev/null +++ b/flutter-app/lib/core/network/network_error.dart @@ -0,0 +1,27 @@ +enum NetworkErrorType { + timeout, + rateLimited, + unavailable, + badResponse, + unknown, +} + +class NetworkError implements Exception { + final NetworkErrorType type; + final String message; + final int? statusCode; + final Object? cause; + + const NetworkError({ + required this.type, + required this.message, + this.statusCode, + this.cause, + }); + + @override + String toString() { + final status = statusCode != null ? ' (HTTP $statusCode)' : ''; + return 'NetworkError.${type.name}$status: $message'; + } +} diff --git a/flutter-app/lib/core/network/network_policy.dart b/flutter-app/lib/core/network/network_policy.dart new file mode 100644 index 00000000..4a8185ff --- /dev/null +++ b/flutter-app/lib/core/network/network_policy.dart @@ -0,0 +1,27 @@ +class NetworkPolicy { + final Duration timeout; + final int maxRetries; + final Duration initialBackoff; + + const NetworkPolicy({ + required this.timeout, + required this.maxRetries, + required this.initialBackoff, + }); + + Duration backoffForAttempt(int attempt) { + return initialBackoff * (1 << attempt); + } + + static const standard = NetworkPolicy( + timeout: Duration(seconds: 15), + maxRetries: 2, + initialBackoff: Duration(milliseconds: 500), + ); + + static const critical = NetworkPolicy( + timeout: Duration(seconds: 20), + maxRetries: 3, + initialBackoff: Duration(milliseconds: 750), + ); +} diff --git a/flutter-app/lib/services/vendo_service.dart b/flutter-app/lib/services/vendo_service.dart index 49345f36..6bf26a52 100644 --- a/flutter-app/lib/services/vendo_service.dart +++ b/flutter-app/lib/services/vendo_service.dart @@ -1,15 +1,19 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; + import 'package:http/http.dart' as http; + import '../core/app_log.dart'; -import '../models/station.dart'; -import 'db_api_service.dart' show SegmentPrice; +import '../core/network/network_error.dart'; +import '../core/network/network_policy.dart'; import '../models/best_price.dart'; -import '../models/walking_route.dart'; import '../models/departure.dart'; import '../models/journey.dart'; +import '../models/station.dart'; import '../models/trip.dart'; +import '../models/walking_route.dart'; +import 'db_api_service.dart' show SegmentPrice; /// Client for the DB Navigator mobile backend (`app.services-bahn.de/mob`). /// @@ -55,7 +59,7 @@ class VendoService { /// Static: one gate for the whole app, since the limit is per client, not /// per service instance. static final _zuglaufGate = _RequestGate(3); - static const _maxRetries = 2; + static const _policy = NetworkPolicy.standard; static const _base = 'https://app.services-bahn.de/mob'; static const _journeyMedia = @@ -148,9 +152,6 @@ class VendoService { 'maxUmstiege': ?maxTransfers, if (viaLocations != null && viaLocations.isNotEmpty) 'viaLocations': viaLocations, - // Earlier/later pagination: the DB Navigator backend returns - // frueherContext/spaeterContext tokens; replaying one here scrolls - // the result window. Field is `context` (English), not `kontext`. 'context': ?context, }, }, @@ -184,6 +185,7 @@ class VendoService { 'fahrplan HTTP ${res.statusCode} (${res.bodyBytes.length}B)', tag: 'vendo', ); + if (res.statusCode != 200) { // Surface the upstream body — DB encodes the real reason (bot block, // bad location id, rate limit) in the JSON, not just the status code. @@ -670,16 +672,119 @@ class VendoService { /// `{lat, lng}` points, or null if the backend carries no geometry. Future>?> fetchTripPolyline(String zuglaufId) async { final url = '$_base/zuglauf/${Uri.encodeComponent(zuglaufId)}'; + final res = await _client .get(Uri.parse(url), headers: _headers(_zuglaufMedia)) .timeout(const Duration(seconds: 10)); + if (res.statusCode != 200) { throw VendoException('Vendo zuglauf HTTP ${res.statusCode}'); } + final data = json.decode(utf8.decode(res.bodyBytes)) as Map; + final realtime = data['echtzeitNotizen']; + + AppLog.log( + 'zuglauf echtzeitNotizen type: ${realtime.runtimeType}', + tag: 'vendo', + ); + + if (realtime is List) { + AppLog.log( + 'zuglauf echtzeitNotizen count: ${realtime.length}', + tag: 'vendo', + ); + + for (final note in realtime.take(5)) { + AppLog.log('zuglauf echtzeitNotiz: $note', tag: 'vendo'); + } + } else { + AppLog.log('zuglauf echtzeitNotizen value: $realtime', tag: 'vendo'); + } + AppLog.log('zuglauf top-level keys: ${data.keys.toList()}', tag: 'vendo'); + final fahrplan = data['fahrplan']; + + if (fahrplan is Map) { + AppLog.log( + 'zuglauf fahrplan keys: ${fahrplan.keys.toList()}', + tag: 'vendo', + ); + final tageOhneFahrt = fahrplan['tageOhneFahrt']; + + AppLog.log( + 'zuglauf tageOhneFahrt runtimeType: ' + '${tageOhneFahrt.runtimeType}', + tag: 'vendo', + ); + + AppLog.log('zuglauf tageOhneFahrt value: $tageOhneFahrt', tag: 'vendo'); + final regulaerer = fahrplan['regulaererFahrplan']; + + AppLog.log( + 'zuglauf regulaererFahrplan runtimeType: ' + '${regulaerer.runtimeType}', + tag: 'vendo', + ); + + AppLog.log('zuglauf regulaererFahrplan value: $regulaerer', tag: 'vendo'); + + if (regulaerer is Map) { + AppLog.log( + 'zuglauf regulaererFahrplan keys: ' + '${regulaerer.keys.toList()}', + tag: 'vendo', + ); + } else if (regulaerer is List) { + AppLog.log( + 'zuglauf regulaererFahrplan list length: ' + '${regulaerer.length}', + tag: 'vendo', + ); + + if (regulaerer.isNotEmpty && regulaerer.first is Map) { + AppLog.log( + 'zuglauf first regular entry keys: ' + '${(regulaerer.first as Map).keys.toList()}', + tag: 'vendo', + ); + } + } + } + + final halte = data['halte']; + + if (halte is List) { + AppLog.log('zuglauf halte count: ${halte.length}', tag: 'vendo'); + + if (halte.isNotEmpty && halte.last is Map) { + final lastHalt = halte.last as Map; + + AppLog.log('zuglauf last halt ort: ${lastHalt['ort']}', tag: 'vendo'); + + AppLog.log( + 'zuglauf last halt abgangsDatum: ' + '${lastHalt['abgangsDatum']}', + tag: 'vendo', + ); + + AppLog.log( + 'zuglauf last halt ezGleis: ' + '${lastHalt['ezGleis']}', + tag: 'vendo', + ); + + AppLog.log( + 'zuglauf last halt gleis: ' + '${lastHalt['gleis']}', + tag: 'vendo', + ); + } + } final points = _parsePolyline(data); + AppLog.log('zuglauf polyline ${points?.length ?? 0} pts', tag: 'vendo'); + return points; } @@ -855,9 +960,19 @@ class VendoService { // that reliably trips the backend's per-client limit and every leg fails // together — which is what made the detail view collapse to the minimal // card for *all* connections at once, then recover minutes later (#14). + + final stopwatch = Stopwatch()..start(); + final res = await _zuglaufGate.run( () => _getWithRetry(url, _zuglaufMedia, tag: 'zuglauf'), ); + + stopwatch.stop(); + + AppLog.log( + 'zuglauf E2E = ${stopwatch.elapsedMilliseconds} ms', + tag: 'vendo', + ); return json.decode(utf8.decode(res.bodyBytes)) as Map; } @@ -867,7 +982,8 @@ class VendoService { /// GET honouring 429 + `Retry-After`. The backend answers a tripped limit /// with `{"domain":"MOB","code":"RETRY","status":"ERROR"}` and a - /// `Retry-After` (~18s observed), i.e. it tells us exactly when to come + /// `Retry-After` (~18s observed), i.e. it tells us ex + /// actly when to come /// back — treating that as a hard failure throws away a request that would /// have succeeded. Mirrors DbAccountService's existing 429 backoff. Future _getWithRetry( @@ -876,27 +992,73 @@ class VendoService { required String tag, int attempt = 0, }) async { - final res = await _client - .get(Uri.parse(url), headers: _headers(media)) - .timeout(const Duration(seconds: 10)); - if (res.statusCode == 429 && attempt < _maxRetries) { - final retryAfter = int.tryParse(res.headers['retry-after'] ?? ''); - // No Retry-After → exponential backoff (2s, 4s). Cap the honoured wait: - // a rider staring at a spinner won't sit through a 60s hint. - final delay = Duration( - seconds: (retryAfter ?? (2 << attempt)).clamp(1, 20), + final stopwatch = Stopwatch()..start(); + + late final http.Response res; + + try { + res = await _client + .get(Uri.parse(url), headers: _headers(media)) + .timeout(_policy.timeout); + } on TimeoutException catch (e) { + stopwatch.stop(); + + AppLog.log( + 'zuglauf API attempt ${attempt + 1} timeout = ' + '${stopwatch.elapsedMilliseconds} ms', + tag: 'vendo', + ); + + throw NetworkError( + type: NetworkErrorType.timeout, + message: 'Vendo $tag request timed out', + cause: e, + ); + } on Exception catch (e) { + stopwatch.stop(); + + AppLog.log( + 'zuglauf API attempt ${attempt + 1} failed = ' + '${stopwatch.elapsedMilliseconds} ms', + tag: 'vendo', + ); + + throw NetworkError( + type: NetworkErrorType.unknown, + message: 'Vendo $tag request failed', + cause: e, ); + } + stopwatch.stop(); + + AppLog.log( + 'zuglauf API attempt ${attempt + 1} = ' + '${stopwatch.elapsedMilliseconds} ms', + tag: 'vendo', + ); + + if (res.statusCode == 429 && attempt < _policy.maxRetries) { + final retryAfter = int.tryParse(res.headers['retry-after'] ?? ''); + + final delay = retryAfter != null + ? Duration(seconds: retryAfter) + : _policy.backoffForAttempt(attempt); + AppLog.log( '429 on $tag → backoff ${delay.inSeconds}s ' - '(attempt ${attempt + 1}/$_maxRetries)', + '(attempt ${attempt + 1}/${_policy.maxRetries})', tag: 'vendo', ); + await Future.delayed(delay); + return _getWithRetry(url, media, tag: tag, attempt: attempt + 1); } + if (res.statusCode != 200) { throw VendoException('Vendo $tag HTTP ${res.statusCode}'); } + return res; } @@ -1405,6 +1567,31 @@ class VendoService { collect(a['echtzeitNotizen']); for (final h in halte.whereType>()) { // See _parseTripFromZuglauf: stop-level notes live in `echtzeitNotizen`. + for (final halt in halte.whereType>()) { + final ort = halt['ort'] as Map?; + + AppLog.log('halt ${ort?['name']}', tag: 'vendo'); + + final auslastung = halt['auslastungsInfos']; + + AppLog.log( + ' auslastungsInfos type: ${auslastung.runtimeType}', + tag: 'vendo', + ); + + if (auslastung is List) { + AppLog.log( + ' auslastungsInfos count: ${auslastung.length}', + tag: 'vendo', + ); + + for (final info in auslastung.take(3)) { + AppLog.log(' auslastung: $info', tag: 'vendo'); + } + } else { + AppLog.log(' auslastungsInfos value: $auslastung', tag: 'vendo'); + } + } collect(h['echtzeitNotizen']); } @@ -1635,8 +1822,43 @@ class VendoService { return actual.difference(planned).inSeconds; } - DateTime? _parse(dynamic v) => - v is String ? DateTime.tryParse(v)?.toLocal() : null; + DateTime? _parse(dynamic v) { + if (v is! String) return null; + + final match = RegExp( + r'^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?', + ).firstMatch(v); + + if (match == null) return null; + + final year = int.parse(match.group(1)!); + final month = int.parse(match.group(2)!); + final day = int.parse(match.group(3)!); + final hour = int.parse(match.group(4)!); + final minute = int.parse(match.group(5)!); + final second = int.tryParse(match.group(6) ?? '0') ?? 0; + + var millisecond = 0; + var microsecond = 0; + + final fraction = match.group(7); + if (fraction != null) { + final padded = fraction.padRight(6, '0'); + millisecond = int.parse(padded.substring(0, 3)); + microsecond = int.parse(padded.substring(3, 6)); + } + + return DateTime( + year, + month, + day, + hour, + minute, + second, + millisecond, + microsecond, + ); + } /// Vendo durations (`verfuegbareZeit`, `abschnittsDauer`) are seconds. Duration? _seconds(dynamic v) => diff --git a/flutter-app/test/core/network/network_error_test.dart b/flutter-app/test/core/network/network_error_test.dart new file mode 100644 index 00000000..2b3e6152 --- /dev/null +++ b/flutter-app/test/core/network/network_error_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:besser_bahn/core/network/network_error.dart'; + +void main() { + group('NetworkError', () { + test('timeout error exposes its type', () { + const error = NetworkError( + type: NetworkErrorType.timeout, + message: 'Request timed out', + ); + + expect(error.type, NetworkErrorType.timeout); + expect(error.statusCode, isNull); + }); + + test('rate limited error keeps status code', () { + const error = NetworkError( + type: NetworkErrorType.rateLimited, + message: 'Too many requests', + statusCode: 429, + ); + + expect(error.type, NetworkErrorType.rateLimited); + expect(error.statusCode, 429); + }); + + test('toString includes type and status code', () { + const error = NetworkError( + type: NetworkErrorType.badResponse, + message: 'Bad response', + statusCode: 503, + ); + + expect( + error.toString(), + 'NetworkError.badResponse (HTTP 503): Bad response', + ); + }); + }); +} diff --git a/flutter-app/test/core/network/network_policy_test.dart b/flutter-app/test/core/network/network_policy_test.dart new file mode 100644 index 00000000..d81d5378 --- /dev/null +++ b/flutter-app/test/core/network/network_policy_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:besser_bahn/core/network/network_policy.dart'; + +void main() { + group('NetworkPolicy', () { + test('standard policy has expected defaults', () { + expect(NetworkPolicy.standard.timeout, const Duration(seconds: 15)); + expect(NetworkPolicy.standard.maxRetries, 2); + expect( + NetworkPolicy.standard.initialBackoff, + const Duration(milliseconds: 500), + ); + }); + + test('critical policy allows more retries', () { + expect( + NetworkPolicy.critical.maxRetries, + greaterThan(NetworkPolicy.standard.maxRetries), + ); + + expect( + NetworkPolicy.critical.timeout, + greaterThan(NetworkPolicy.standard.timeout), + ); + }); + }); +} From e3c5ba12356910bf66b88a1057066ebd5b8b678a Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:47:00 -0700 Subject: [PATCH 2/7] fix: restore CI compatibility --- flutter-app/lib/core/constants.dart | 2 +- flutter-app/lib/screens/settings/settings_screen.dart | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/flutter-app/lib/core/constants.dart b/flutter-app/lib/core/constants.dart index 5483731e..fd25bde6 100644 --- a/flutter-app/lib/core/constants.dart +++ b/flutter-app/lib/core/constants.dart @@ -147,7 +147,7 @@ class AppConstants { /// in at compile time, needs no plugin channel (so it also works in tests and /// on desktop), yet cannot silently go stale. It had gone stale before — it /// read 2.0.0 while pubspec was already at 2.1.0 (#34). - static const appVersion = '2.3.1'; + static const appVersion = '2.4.0'; /// Honest, identifying User-Agent for APIs that require one. /// diff --git a/flutter-app/lib/screens/settings/settings_screen.dart b/flutter-app/lib/screens/settings/settings_screen.dart index 3cd10691..a29e78fe 100644 --- a/flutter-app/lib/screens/settings/settings_screen.dart +++ b/flutter-app/lib/screens/settings/settings_screen.dart @@ -797,10 +797,11 @@ Future _createBackup(BuildContext context) async { /// launch, so the running app still holds the old data until it does that /// again. Future _restoreBackup(BuildContext context) async { - final file = await FilePicker.pickFile(); - if (file == null || !context.mounted) return; - final bytes = await file.readAsBytes(); - if (!context.mounted) return; + final result = await FilePicker.platform.pickFiles(); + if (result == null || result.files.isEmpty || !context.mounted) return; + + final bytes = result.files.single.bytes; + if (bytes == null) return; final password = await _askPassword(context, confirm: false); if (password == null || !context.mounted) return; @@ -839,3 +840,5 @@ Future _openUrl(BuildContext context, String url) async { ); } } + + From 45dc85baaf77ff39315976399ae97d0d6a42389c Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:22:36 -0700 Subject: [PATCH 3/7] fix: use file picker v12 API --- .../lib/screens/settings/settings_screen.dart | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/flutter-app/lib/screens/settings/settings_screen.dart b/flutter-app/lib/screens/settings/settings_screen.dart index a29e78fe..4352b0b6 100644 --- a/flutter-app/lib/screens/settings/settings_screen.dart +++ b/flutter-app/lib/screens/settings/settings_screen.dart @@ -1,18 +1,17 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'package:file_picker/file_picker.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../core/backup.dart'; import '../../core/constants.dart'; import '../../core/offline_package.dart'; import '../../models/reisende.dart'; import '../../models/split_ticket.dart'; -import '../../models/transfer_profile.dart'; import '../../models/traewelling_models.dart'; +import '../../models/transfer_profile.dart'; import '../../providers/offline_package_provider.dart'; import '../../providers/service_providers.dart'; import '../../providers/settings_provider.dart'; @@ -797,11 +796,11 @@ Future _createBackup(BuildContext context) async { /// launch, so the running app still holds the old data until it does that /// again. Future _restoreBackup(BuildContext context) async { - final result = await FilePicker.platform.pickFiles(); - if (result == null || result.files.isEmpty || !context.mounted) return; + final file = await FilePicker.pickFile(); + if (file == null || !context.mounted) return; - final bytes = result.files.single.bytes; - if (bytes == null) return; + final bytes = await file.readAsBytes(); + if (!context.mounted) return; final password = await _askPassword(context, confirm: false); if (password == null || !context.mounted) return; @@ -840,5 +839,3 @@ Future _openUrl(BuildContext context, String url) async { ); } } - - From 9c43315b0c24c572e6d5cbf7f9415dc57b42910d Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:56:23 -0700 Subject: [PATCH 4/7] ci: enable GitHub OIDC for Firebase distribution --- .github/workflows/flutter-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index 53c93888..8e06d1d4 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -15,6 +15,7 @@ on: permissions: contents: read + id-token: write jobs: analyze-and-test: From 3af97356fe713f6a62f163223f6ce378a73a3a0a Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Wed, 2 Sep 2026 06:58:16 -0700 Subject: [PATCH 5/7] ci: add Google Cloud WIF authentication --- .github/workflows/flutter-ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index 8e06d1d4..ca69ea72 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -24,7 +24,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: "projects/658602031061/locations/global/workloadIdentityPools/github-actions/providers/github-actions" + service_account: "github-actions-firebase@besser-bahn.iam.gserviceaccount.com" # The SDK version comes from pubspec.yaml (`environment: flutter:`), the # same line IzzyOnDroid's reproducible-build script parses. Pinning it a # second time here would let CI drift away from the release toolchain. From a7591a618c810c1d1fd78c22d6eb52904ecdc677 Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:04:50 -0700 Subject: [PATCH 6/7] ci: fix fork PR authentication flow --- .github/workflows/flutter-ci.yml | 38 +++++++++++++++----------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index c3561e8f..b4d642a7 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -5,26 +5,23 @@ on: paths: - "flutter-app/**" - ".github/workflows/flutter-ci.yml" + push: branches: - main - "chore/**" + - "feat/**" paths: - "flutter-app/**" - ".github/workflows/flutter-ci.yml" permissions: contents: read - id-token: write jobs: analyze-and-test: runs-on: ubuntu-latest - # DB serves German local times, and a few parser tests assert the local - # wall-clock hour of a "+02:00" fixture (e.g. 00:00 local). On a UTC runner - # DateTime.toLocal() shifts that to 22:00 the day before, so the tests fail - # only in CI. Pin the runner to Europe/Berlin so it matches the dev machines. env: TZ: Europe/Berlin @@ -32,21 +29,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - workload_identity_provider: "projects/658602031061/locations/global/workloadIdentityPools/github-actions/providers/github-actions" - service_account: "github-actions-firebase@besser-bahn.iam.gserviceaccount.com" - - # The SDK version comes from pubspec.yaml (`environment: flutter:`), the - # same line IzzyOnDroid's reproducible-build script parses. Pinning it a - # second time here would let CI drift away from the release toolchain. - - name: Set up Flutter - uses: subosito/flutter-action@v2 - - # The SDK version comes from pubspec.yaml (`environment: flutter:`), the - # same line IzzyOnDroid's reproducible-build script parses. Pinning it a - # second time here would let CI drift away from the release toolchain. - name: Set up Flutter uses: subosito/flutter-action@v2 with: @@ -69,3 +51,19 @@ jobs: - name: Test working-directory: flutter-app run: flutter test + + authenticate-google: + if: github.event_name == 'push' + needs: analyze-and-test + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: "projects/658602031061/locations/global/workloadIdentityPools/github-actions/providers/github-actions" + service_account: "github-actions-firebase@besser-bahn.iam.gserviceaccount.com" \ No newline at end of file From 8d61134e8bcef9ed87e80a5664a3b8879fc2cf68 Mon Sep 17 00:00:00 2001 From: YousefAbaas <168109759+YousefAbaas@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:09:28 -0700 Subject: [PATCH 7/7] ci: build release APK --- .github/workflows/flutter-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index b4d642a7..218b6b5e 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -52,6 +52,10 @@ jobs: working-directory: flutter-app run: flutter test + - name: Build APK + working-directory: flutter-app + run: flutter build apk --release + authenticate-google: if: github.event_name == 'push' needs: analyze-and-test