import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; /// Result returned from an R2 bundle upload via Cloudflare Worker gateway @immutable class R2UploadResult { final bool success; final String? objectKey; final int? bytesWritten; final String? errorMessage; final int? statusCode; final bool isRetryable; final bool requiresAuth; const R2UploadResult({ required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false, }); const R2UploadResult.success({ required String objectKey, int? bytesWritten, }) : this( success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false, requiresAuth: false, ); const R2UploadResult.authRequired( String message, { int statusCode = 401, }) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true, ); const R2UploadResult.temporaryFailure( String message, { int? statusCode, }) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: false, ); const R2UploadResult.permanentFailure( String message, { int? statusCode, }) : this( success: false, errorMessage: message, statusCode: statusCode, isRetryable: false, requiresAuth: false, ); } /// Service that streams telemetry ZIP bundles directly to Cloudflare R2 /// through the authenticated Cloudflare Worker gateway. class R2UploadService { static const String defaultWorkerUrl = String.fromEnvironment( 'R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', ); final http.Client _client; final String _workerBaseUrl; R2UploadService({ http.Client? client, String? workerBaseUrl, }) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); String get workerBaseUrl => _workerBaseUrl; /// Uploads a compressed telemetry ZIP bundle to Cloudflare R2 via the worker gateway Future uploadTelemetryBundle({ required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45), }) async { final endpoint = Uri.parse('$_workerBaseUrl/r2/upload'); try { final response = await _client .put( endpoint, headers: { 'Authorization': 'Bearer $idToken', 'Content-Type': 'application/zip', 'X-Remote-Ride-Id': remoteRideId, 'X-Bundle-Sha256': sha256Hash, 'X-App-Version': appVersion, }, body: zipBytes, ) .timeout(timeout); if (response.statusCode >= 200 && response.statusCode < 300) { try { final data = jsonDecode(response.body) as Map; final objectKey = data['objectKey'] as String? ?? 'users/unknown/rides/$remoteRideId/telemetry.zip'; final size = data['size'] as int? ?? zipBytes.length; return R2UploadResult.success(objectKey: objectKey, bytesWritten: size); } catch (_) { return R2UploadResult.success( objectKey: 'users/unknown/rides/$remoteRideId/telemetry.zip', bytesWritten: zipBytes.length, ); } } if (response.statusCode == 401 || response.statusCode == 403) { return R2UploadResult.authRequired( 'Cloudflare Worker authorization denied (${response.statusCode}): ${response.body}', statusCode: response.statusCode, ); } if (response.statusCode == 400 || response.statusCode == 422) { return R2UploadResult.permanentFailure( 'Worker rejected ride bundle payload (${response.statusCode}): ${response.body}', statusCode: response.statusCode, ); } // Server error / Cloudflare rate-limit or 5xx -> Retryable return R2UploadResult.temporaryFailure( 'Cloudflare Worker temporary error (${response.statusCode}): ${response.body}', statusCode: response.statusCode, ); } on TimeoutException { return const R2UploadResult.temporaryFailure( 'Upload to Cloudflare R2 timed out after 45 seconds.', ); } catch (e) { return R2UploadResult.temporaryFailure( 'Network error uploading to Cloudflare R2: $e', ); } } }