diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index e1c502dd..218b6b5e 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -5,10 +5,12 @@ on: paths: - "flutter-app/**" - ".github/workflows/flutter-ci.yml" + push: branches: - main - "chore/**" + - "feat/**" paths: - "flutter-app/**" - ".github/workflows/flutter-ci.yml" @@ -20,10 +22,6 @@ 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 @@ -31,9 +29,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - # 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: @@ -56,3 +51,23 @@ jobs: - name: Test 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 + 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 diff --git a/flutter-app/lib/core/constants.dart b/flutter-app/lib/core/constants.dart index e28bda1f..a5989071 100644 --- a/flutter-app/lib/core/constants.dart +++ b/flutter-app/lib/core/constants.dart @@ -146,7 +146,8 @@ class AppConstants { /// stay a plain const instead of pulling in `package_info_plus`: it is baked /// 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). + /// read 2.0.0 while pubspec was already at + static const appVersion = '2.4.1'; /// Honest, identifying User-Agent for APIs that require one. 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/screens/settings/settings_screen.dart b/flutter-app/lib/screens/settings/settings_screen.dart index 3cd10691..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'; @@ -799,6 +798,7 @@ Future _createBackup(BuildContext context) async { 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; 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), + ); + }); + }); +}