209 lines
5.4 KiB
Dart
209 lines
5.4 KiB
Dart
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<OtaEvent>? _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<void> _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();
|
|
}
|
|
}
|