import 'dart:async'; import 'dart:io'; import 'package:ota_update/ota_update.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/app_version.dart'; /// Handles the business logic for fetching version information from the /// ``app_versions`` table and downloading/installing a replacement APK. /// /// This class is intentionally *not* coupled to any UI framework so that it can /// be unit‑tested easily. Widgets should only call into the public methods and /// display whatever progress / error states they need. class AppUpdateService { AppUpdateService._(); /// Public singleton that other parts of the app can depend on. The private /// constructor prevents instantiation from tests; they should mock the /// individual methods instead. static final instance = AppUpdateService._(); final SupabaseClient _client = Supabase.instance.client; /// Fetches the most recent record from ``app_versions``. The table is /// expected to contain a single row per release; we use the highest /// ``version_code`` so that historical entries may be kept if desired. Future _fetchLatestVersion() async { try { final Map? data = await _client .from('app_versions') .select() .order('version_code', ascending: false) .limit(1) .maybeSingle(); if (data == null) return null; return AppVersion.fromMap(data); } catch (e) { // rethrow so callers can handle/log rethrow; } } /// Compare the running build number with the latest data from the server and /// return an [AppUpdateInfo] object describing the relationship. Errors /// (network down, no supabase instance) are simply re‑thrown; the caller can /// decide whether to ignore them or surface them to the user. Future checkForUpdate() async { final pkg = await PackageInfo.fromPlatform(); final currentBuild = int.tryParse(pkg.buildNumber) ?? 0; final serverVersion = await _fetchLatestVersion(); if (serverVersion == null) { // table empty – nothing to do return AppUpdateInfo( currentBuildNumber: currentBuild, latestVersion: null, isUpdateAvailable: false, isForceUpdate: false, ); } final available = currentBuild < serverVersion.versionCode; final force = currentBuild < serverVersion.minVersionRequired; return AppUpdateInfo( currentBuildNumber: currentBuild, latestVersion: serverVersion, isUpdateAvailable: available, isForceUpdate: force, ); } /// Downloads and installs the APK at [downloadUrl] using `ota_update`. /// Progress is surfaced via [onProgress] (0.0–1.0). The returned future /// completes once the download has finished and installation has started. /// /// Throws on permission denial or when the OTA stream reports an error. Future downloadAndInstallApk( String downloadUrl, { required void Function(double) onProgress, }) async { if (!Platform.isAndroid) { throw UnsupportedError( 'In‑app updates currently only supported on Android', ); } final status = await Permission.requestInstallPackages.request(); if (!status.isGranted) { throw Exception('installation permission denied'); } final completer = Completer(); StreamSubscription? sub; try { sub = OtaUpdate() .execute( downloadUrl, destinationFilename: 'app_${DateTime.now().millisecondsSinceEpoch}.apk', ) .listen( (event) { switch (event.status) { case OtaStatus.DOWNLOADING: final pct = double.tryParse(event.value ?? '0') ?? 0; onProgress(pct / 100.0); break; case OtaStatus.INSTALLING: if (!completer.isCompleted) completer.complete(); break; default: // treat all other statuses as failure; they provide a string // description in `event.value`. if (!completer.isCompleted) { completer.completeError( Exception('OTA update failed: ${event.status}'), ); } break; } }, onError: (e) { if (!completer.isCompleted) completer.completeError(e); }, ); await completer.future; } finally { if (sub != null) await sub.cancel(); } } }