import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; @immutable class R2DownloadResult { final bool success; final String? cloudRideId; final Uint8List? zipBytes; final String? sha256Hash; final String? etag; final String? errorMessage; final int? statusCode; final bool isRetryable; final bool requiresAuth; const R2DownloadResult({ required this.success, this.cloudRideId, this.zipBytes, this.sha256Hash, this.etag, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false, }); const R2DownloadResult.success({ required String cloudRideId, required Uint8List zipBytes, required String sha256Hash, String? etag, }) : this( success: true, cloudRideId: cloudRideId, zipBytes: zipBytes, sha256Hash: sha256Hash, etag: etag, statusCode: 200, isRetryable: false, ); const R2DownloadResult.authRequired(String message, {int statusCode = 401}) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true, ); const R2DownloadResult.temporaryFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true); const R2DownloadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false); } /// Downloads a verified telemetry bundle through the authenticated Worker gateway. /// The Worker authorizes the ride, and this service verifies the returned SHA-256 /// before exposing the bytes to restore code. class R2DownloadService { static const String defaultWorkerUrl = String.fromEnvironment( 'R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', ); final http.Client _client; final String _workerBaseUrl; R2DownloadService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); String get workerBaseUrl => _workerBaseUrl; Future downloadTelemetryBundle({ required String idToken, required String cloudRideId, Duration timeout = const Duration(seconds: 45), }) async { if (idToken.trim().isEmpty) { return const R2DownloadResult.authRequired('Firebase ID token is required for R2 download.'); } if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(cloudRideId)) { return const R2DownloadResult.permanentFailure('Invalid cloud ride identifier.', statusCode: 400); } try { final uri = Uri.parse('$_workerBaseUrl/r2/download').replace( queryParameters: {'rideId': cloudRideId}, ); final response = await _client.get( uri, headers: { 'Authorization': 'Bearer ${idToken.trim()}', 'Accept': 'application/zip', }, ).timeout(timeout); final status = response.statusCode; if (status == 401 || status == 403) { return R2DownloadResult.authRequired( 'R2 Worker authorization denied ($status).', statusCode: status, ); } if (status == 404) { return R2DownloadResult.permanentFailure( 'Telemetry bundle is not available in MotorCloud.', statusCode: status, ); } if (status == 409 || status == 422) { return R2DownloadResult.permanentFailure( 'MotorCloud rejected the telemetry bundle integrity or ownership metadata.', statusCode: status, ); } if (status < 200 || status >= 300) { return R2DownloadResult.temporaryFailure( 'R2 Worker temporary error ($status).', statusCode: status, ); } final returnedRideId = response.headers['x-motorsignal-ride-id']; if (returnedRideId != cloudRideId) { return const R2DownloadResult.permanentFailure( 'R2 Worker returned an unexpected cloud ride identifier.', statusCode: 422, ); } final expectedSha256 = response.headers['x-motorsignal-sha256']?.trim().toLowerCase(); if (expectedSha256 == null || !RegExp(r'^[a-f0-9]{64}$').hasMatch(expectedSha256)) { return const R2DownloadResult.permanentFailure( 'R2 Worker did not provide a valid telemetry SHA-256.', statusCode: 422, ); } final bytes = Uint8List.fromList(response.bodyBytes); if (bytes.isEmpty) { return const R2DownloadResult.permanentFailure('Downloaded telemetry bundle is empty.', statusCode: 422); } final actualSha256 = sha256.convert(bytes).toString().toLowerCase(); if (actualSha256 != expectedSha256) { return const R2DownloadResult.permanentFailure( 'Telemetry bundle SHA-256 verification failed.', statusCode: 422, ); } return R2DownloadResult.success( cloudRideId: cloudRideId, zipBytes: bytes, sha256Hash: actualSha256, etag: response.headers['etag'], ); } on TimeoutException { return R2DownloadResult.temporaryFailure( 'Download from Cloudflare R2 timed out after ${timeout.inSeconds} seconds.', ); } on FormatException catch (e) { return R2DownloadResult.permanentFailure('Invalid R2 download response: $e', statusCode: 422); } catch (e) { return R2DownloadResult.temporaryFailure('Network error downloading from Cloudflare R2: $e'); } } /// Useful when the Worker returns a JSON error body for diagnostics. static String errorBody(http.Response response) { try { final decoded = jsonDecode(response.body); if (decoded is Map && decoded['error'] is String) return decoded['error'] as String; } catch (_) {} return 'R2 Worker request failed (${response.statusCode}).'; } }