import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:ota_update/ota_update.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../models/update_failure.dart'; import '../utils/app_config.dart'; import '../utils/app_update_util.dart'; enum UpdatePhase { downloading, installing, /// Android's installer prompt is open; the app is replaced once the user /// confirms it, so there is no "done" state to show. waitingForInstaller, failed, } class UpdateProgressViewModel extends ChangeNotifier { final UpdateInfo update; final bool simulate; final bool simulateError; UpdatePhase _phase = UpdatePhase.downloading; double? _progress; UpdateFailure? _failure; String? _currentVersion; StreamSubscription? _subscription; Timer? _simTimer; UpdatePhase get phase => _phase; /// Download progress from 0 to 1, or null while it is not known yet. double? get progress => _progress; UpdateFailure? get failure => _failure; String? get currentVersion => _currentVersion; bool get isBusy => _phase == UpdatePhase.downloading || _phase == UpdatePhase.installing; UpdateProgressViewModel({ required this.update, this.simulate = false, this.simulateError = false, }); void init() { unawaited(_loadCurrentVersion()); start(); } void start() { cancel(); _phase = UpdatePhase.downloading; _progress = null; _failure = null; notifyListeners(); if (simulate) { _runSimulation(); return; } final url = update.download.startsWith('http') ? update.download : '$kUpdateServerUrl${update.download}'; _subscription = OtaUpdate() .execute( url, destinationFilename: 'update.apk', sha256checksum: update.sha256, ) .listen(_onEvent, onError: _onError, onDone: _onDone); } void cancel() { _simTimer?.cancel(); _simTimer = null; unawaited(_subscription?.cancel()); _subscription = null; } Future _loadCurrentVersion() async { try { final info = await PackageInfo.fromPlatform(); _currentVersion = info.version; notifyListeners(); } catch (e) { debugPrint('UpdateProgressViewModel: could not read version: $e'); } } void _onEvent(OtaEvent event) { switch (event.status) { case OtaStatus.DOWNLOADING: final percent = double.tryParse(event.value ?? '') ?? 0; _progress = (percent / 100).clamp(0.0, 1.0); _phase = UpdatePhase.downloading; notifyListeners(); case OtaStatus.INSTALLING: _progress = 1; _phase = UpdatePhase.installing; notifyListeners(); case OtaStatus.INSTALLATION_DONE: _phase = UpdatePhase.waitingForInstaller; notifyListeners(); case OtaStatus.DOWNLOAD_ERROR: _fail(UpdateFailure.download, event.value); case OtaStatus.CHECKSUM_ERROR: _fail(UpdateFailure.checksum, event.value); case OtaStatus.PERMISSION_NOT_GRANTED_ERROR: _fail(UpdateFailure.permission, event.value); case OtaStatus.INSTALLATION_ERROR: _fail(UpdateFailure.install, event.value); case OtaStatus.ALREADY_RUNNING_ERROR: _fail(UpdateFailure.alreadyRunning, event.value); case OtaStatus.INTERNAL_ERROR: _fail(UpdateFailure.unknown, event.value); case OtaStatus.CANCELED: break; } } /// The plugin closes the stream once it has handed the APK to Android's /// installer; closing earlier means it gave up without telling why. void _onDone() { _subscription = null; switch (_phase) { case UpdatePhase.installing: _phase = UpdatePhase.waitingForInstaller; notifyListeners(); case UpdatePhase.downloading: _fail(UpdateFailure.unknown, 'Update stream closed while downloading'); case UpdatePhase.waitingForInstaller || UpdatePhase.failed: break; } } void _onError(Object error, StackTrace stackTrace) { debugPrint('UpdateProgressViewModel: stream error: $error'); unawaited(Sentry.captureException(error, stackTrace: stackTrace)); _fail(UpdateFailure.unknown, null, report: false); } void _fail(UpdateFailure failure, String? detail, {bool report = true}) { debugPrint('UpdateProgressViewModel: $failure: $detail'); if (report) { unawaited( Sentry.captureMessage( 'OTA update failed ($failure, ${update.version}): $detail', ), ); } cancel(); _phase = UpdatePhase.failed; _failure = failure; notifyListeners(); } void _runSimulation() { const totalSteps = 40; var step = 0; _simTimer = Timer.periodic(const Duration(milliseconds: 150), (timer) { step++; if (simulateError && step == totalSteps ~/ 2) { _phase = UpdatePhase.failed; _failure = UpdateFailure.simulated; timer.cancel(); notifyListeners(); return; } if (step < totalSteps) { _progress = step / totalSteps; notifyListeners(); return; } timer.cancel(); _progress = 1; _phase = UpdatePhase.installing; notifyListeners(); _simTimer = Timer(const Duration(seconds: 2), () { _phase = UpdatePhase.waitingForInstaller; notifyListeners(); }); }); } @override void dispose() { cancel(); super.dispose(); } }