import 'dart:async'; import 'dart:io'; import 'package:ota_update/ota_update.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.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; static const MethodChannel _platform = MethodChannel('tasq/ota'); // Smoothed progress state — the service smooths raw percent updates into // small animated increments so UI progress displays feel responsive. double _smoothedProgress = 0.0; double _smoothedTarget = 0.0; Timer? _smoothedTimer; Future _openUnknownAppSourcesSettings() async { try { await _platform.invokeMethod('openUnknownSources'); } on PlatformException { // ignore if platform method is not implemented } } /// 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 { // Request all rows so we can compare semantic versions locally. The // Supabase client returns a List for select() (maybeSingle returns a Map). final data = await _client.from('app_versions').select(); // The Supabase client returns a PostgrestList (List-like). Convert to // a Dart List and pick the highest semantic version. final list = data as List; if (list.isEmpty) return null; Version? best; Map? bestRow; for (final item in list) { if (item is Map) { final map = Map.from(item); final v = map['version_code']?.toString() ?? ''; try { final parsed = Version.parse(v); if (best == null || parsed > best) { best = parsed; bestRow = map; } } catch (_) { // ignore non-semver rows } } } if (bestRow != null) return AppVersion.fromMap(bestRow); // fallback to the first row return AppVersion.fromMap(Map.from(list.first)); } catch (e) { 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 { // only run on Android devices; web and other platforms skip if (!Platform.isAndroid) { return AppUpdateInfo( currentBuildNumber: '', latestVersion: null, isUpdateAvailable: false, isForceUpdate: false, ); } final pkg = await PackageInfo.fromPlatform(); // prefer the version name which commonly holds semantic versions like // "1.2.3". fall back to buildNumber when version is empty. final ver = pkg.version.trim(); final build = pkg.buildNumber.trim(); final currentVersion = ver.isNotEmpty ? ver : build; final serverVersion = await _fetchLatestVersion(); if (serverVersion == null) { return AppUpdateInfo( currentBuildNumber: currentVersion, latestVersion: null, isUpdateAvailable: false, isForceUpdate: false, ); } // compare using semantic versioning where possible Version safeParse(String s) { try { return Version.parse(s); } catch (_) { return Version(0, 0, 0); } } final vCurrent = safeParse(currentVersion); final vLatest = safeParse(serverVersion.versionCode); final vMin = safeParse(serverVersion.minVersionRequired); final available = vCurrent < vLatest; final force = vCurrent < vMin; return AppUpdateInfo( currentBuildNumber: currentVersion, 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) { // Open the system settings page so the user can enable "Install unknown apps" try { await _openUnknownAppSourcesSettings(); } catch (_) {} throw Exception('installation permission denied; opened settings'); } // Wrap OTA download in a retry loop to handle transient network issues // (socket closed / timeout) that the underlying okhttp client may throw. const int maxAttempts = 3; for (int attempt = 1; attempt <= maxAttempts; attempt++) { final completer = Completer(); StreamSubscription? sub; try { debugPrint('OTA: starting attempt $attempt/$maxAttempts'); sub = OtaUpdate() .execute( downloadUrl, destinationFilename: 'app_${DateTime.now().millisecondsSinceEpoch}.apk', ) .listen( (event) { final statusStr = event.status.toString(); // DOWNLOADING usually reports percent in event.value if (statusStr.endsWith('DOWNLOADING')) { final pct = double.tryParse(event.value ?? '0') ?? 0; try { _setSmoothedProgress(pct / 100.0, onProgress); } catch (_) {} } else if (statusStr.endsWith('INSTALLING')) { if (!completer.isCompleted) completer.complete(); } else if (statusStr.toLowerCase().contains('cancel')) { if (!completer.isCompleted) { completer.completeError( Exception('OTA cancelled: ${event.value ?? ''}'), ); } } else if (statusStr.toLowerCase().contains('permission') || (event.value ?? '').toLowerCase().contains('permission')) { try { _openUnknownAppSourcesSettings(); } catch (_) {} if (!completer.isCompleted) { completer.completeError( Exception( 'OTA permission not granted: ${event.value ?? ''}', ), ); } } else { // Only treat events containing known network/error keywords as fatal final val = (event.value ?? '').toLowerCase(); if (val.contains('timeout') || val.contains('socket') || val.contains('closed') || statusStr.toLowerCase().contains('error')) { if (!completer.isCompleted) { completer.completeError( Exception( 'OTA update failed ($statusStr): ${event.value ?? ''}', ), ); } } else { // Unknown non-error status — ignore to avoid premature failure. } } }, onError: (e) { if (!completer.isCompleted) completer.completeError(e); }, ); await completer.future; debugPrint('OTA: attempt $attempt succeeded'); return; } catch (e, st) { debugPrint('OTA: attempt $attempt failed: $e\n$st'); // If last attempt, rethrow so caller can handle/report it. if (attempt == maxAttempts) rethrow; final msg = e.toString(); // Retry only on network/timeouts/socket-related failures. if (msg.toLowerCase().contains('timeout') || msg.toLowerCase().contains('socket') || msg.toLowerCase().contains('closed')) { final backoff = Duration(seconds: 2 * attempt); debugPrint('OTA: retrying after ${backoff.inSeconds}s backoff'); await Future.delayed(backoff); continue; // next attempt } // For non-network errors, do not retry. rethrow; } finally { if (sub != null) await sub.cancel(); _smoothedTimer?.cancel(); _smoothedTimer = null; } } // If we reach here all ota_update attempts failed; try a Dart HTTP download // to a temp file and ask native code to install it via FileProvider. try { debugPrint('OTA: falling back to Dart HTTP download'); final filePath = await _downloadApkToFile(downloadUrl, onProgress); try { await _platform.invokeMethod('installApk', {'path': filePath}); } on PlatformException catch (e) { throw Exception('Failed to invoke native installer: ${e.message}'); } } catch (e) { rethrow; } } Future _downloadApkToFile( String url, void Function(double) onProgress, ) async { final uri = Uri.parse(url); final client = HttpClient(); client.connectionTimeout = const Duration(minutes: 5); final req = await client.getUrl(uri); final resp = await req.close(); if (resp.statusCode != 200) { throw Exception('Download failed: HTTP ${resp.statusCode}'); } final tempDir = Directory.systemTemp.createTempSync('ota_'); final file = File( '${tempDir.path}/app_${DateTime.now().millisecondsSinceEpoch}.apk', ); final sink = file.openWrite(); final contentLength = resp.contentLength; int received = 0; try { await for (final chunk in resp) { received += chunk.length; sink.add(chunk); if (contentLength > 0) { try { _setSmoothedProgress(received / contentLength, onProgress); } catch (_) {} } } } finally { await sink.close(); client.close(force: true); _smoothedTimer?.cancel(); _smoothedTimer = null; _smoothedProgress = 0.0; } return file.path; } void _setSmoothedProgress(double target, void Function(double) onProgress) { // Clamp target = target.clamp(0.0, 1.0); // If target is less than or equal to current, update immediately. if (target <= _smoothedProgress) { _smoothedTarget = target; _smoothedProgress = target; try { onProgress(_smoothedProgress); } catch (_) {} return; } // Otherwise, set the target and ensure a single periodic timer is running // which will nudge _smoothedProgress toward _smoothedTarget. This avoids // canceling/creating timers on every small update which caused batching. _smoothedTarget = target; if (_smoothedTimer != null) return; const tickMs = 100; _smoothedTimer = Timer.periodic(const Duration(milliseconds: tickMs), (t) { // Move a fraction toward the target for a smooth ease-out feel. final remaining = _smoothedTarget - _smoothedProgress; final step = (remaining * 0.5).clamp(0.002, 0.05); _smoothedProgress = (_smoothedProgress + step).clamp(0.0, 1.0); try { onProgress(_smoothedProgress); } catch (_) {} // Stop when we're very close to the target. if ((_smoothedTarget - _smoothedProgress) < 0.001) { _smoothedProgress = _smoothedTarget; try { onProgress(_smoothedProgress); } catch (_) {} _smoothedTimer?.cancel(); _smoothedTimer = null; } }); } }