294 lines
7.8 KiB
Dart
294 lines
7.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../core/contracts/app_update.dart';
|
|
import '../features/update/update_service.dart';
|
|
import '../shared/utils/app_logger.dart';
|
|
import 'shared/settings_persistence.dart';
|
|
|
|
enum UpdateStatus {
|
|
idle,
|
|
checking,
|
|
upToDate,
|
|
available,
|
|
downloading,
|
|
readyToInstall,
|
|
error,
|
|
}
|
|
|
|
class UpdateState {
|
|
final UpdateStatus status;
|
|
final AppUpdateInfo? info;
|
|
final int received;
|
|
final int total;
|
|
|
|
|
|
final double speedBytesPerSec;
|
|
final String? error;
|
|
final bool silent;
|
|
final String? installerPath;
|
|
|
|
const UpdateState({
|
|
this.status = UpdateStatus.idle,
|
|
this.info,
|
|
this.received = 0,
|
|
this.total = 0,
|
|
this.speedBytesPerSec = 0,
|
|
this.error,
|
|
this.silent = false,
|
|
this.installerPath,
|
|
});
|
|
|
|
UpdateState copyWith({
|
|
UpdateStatus? status,
|
|
AppUpdateInfo? info,
|
|
int? received,
|
|
int? total,
|
|
double? speedBytesPerSec,
|
|
String? error,
|
|
bool? silent,
|
|
String? installerPath,
|
|
bool clearInfo = false,
|
|
bool clearError = false,
|
|
bool clearInstallerPath = false,
|
|
}) {
|
|
return UpdateState(
|
|
status: status ?? this.status,
|
|
info: clearInfo ? null : info ?? this.info,
|
|
received: received ?? this.received,
|
|
total: total ?? this.total,
|
|
speedBytesPerSec: speedBytesPerSec ?? this.speedBytesPerSec,
|
|
error: clearError ? null : error ?? this.error,
|
|
silent: silent ?? this.silent,
|
|
installerPath: clearInstallerPath
|
|
? null
|
|
: installerPath ?? this.installerPath,
|
|
);
|
|
}
|
|
}
|
|
|
|
final updateProvider = NotifierProvider<UpdateNotifier, UpdateState>(
|
|
UpdateNotifier.new,
|
|
);
|
|
|
|
class UpdateNotifier extends Notifier<UpdateState> with SettingsPersistence {
|
|
static const _tag = 'Update';
|
|
static const _lastCheckKey = 'smplayer.update.last_check';
|
|
static const _silentThrottle = Duration(hours: 12);
|
|
|
|
final UpdateService _service;
|
|
CancelToken? _cancelToken;
|
|
|
|
DateTime? _lastSpeedSampleAt;
|
|
int _lastSpeedSampleBytes = 0;
|
|
|
|
UpdateNotifier({UpdateService? service})
|
|
: _service = service ?? UpdateService();
|
|
|
|
@override
|
|
UpdateState build() => const UpdateState();
|
|
|
|
Future<void> silentCheck() async {
|
|
final prefs = await getPrefs();
|
|
final lastCheck = prefs.getInt(_lastCheckKey);
|
|
final now = DateTime.now().millisecondsSinceEpoch;
|
|
if (lastCheck != null && now - lastCheck < _silentThrottle.inMilliseconds) {
|
|
AppLogger.debug(_tag, 'silent check throttled');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await prefs.setInt(_lastCheckKey, now);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.checking,
|
|
silent: true,
|
|
received: 0,
|
|
total: 0,
|
|
clearError: true,
|
|
clearInstallerPath: true,
|
|
);
|
|
final result = await _service.check();
|
|
if (result.hasUpdate && result.info != null) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.available,
|
|
info: result.info,
|
|
silent: true,
|
|
);
|
|
} else {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.idle,
|
|
silent: true,
|
|
clearInfo: true,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
AppLogger.warn(_tag, 'silent check failed', e);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.idle,
|
|
silent: true,
|
|
clearError: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> manualCheck() async {
|
|
try {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.checking,
|
|
silent: false,
|
|
received: 0,
|
|
total: 0,
|
|
clearError: true,
|
|
clearInstallerPath: true,
|
|
);
|
|
final result = await _service.check();
|
|
await persistField(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
|
|
if (result.hasUpdate && result.info != null) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.available,
|
|
info: result.info,
|
|
silent: false,
|
|
);
|
|
} else {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.upToDate,
|
|
silent: false,
|
|
clearInfo: true,
|
|
);
|
|
}
|
|
} catch (e, stack) {
|
|
AppLogger.error(_tag, 'manual check failed', e, stack);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
error: '检查更新失败,请稍后重试',
|
|
silent: false,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> startDownload() async {
|
|
final info = state.info;
|
|
final asset = info?.selectedAsset;
|
|
if (info == null || asset == null) return;
|
|
|
|
_cancelToken?.cancel('restart download');
|
|
final cancelToken = CancelToken();
|
|
_cancelToken = cancelToken;
|
|
|
|
_lastSpeedSampleAt = null;
|
|
_lastSpeedSampleBytes = 0;
|
|
|
|
try {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.downloading,
|
|
received: 0,
|
|
total: asset.size,
|
|
speedBytesPerSec: 0,
|
|
clearError: true,
|
|
clearInstallerPath: true,
|
|
);
|
|
final file = await _service.download(
|
|
asset,
|
|
cancelToken: cancelToken,
|
|
onReceiveProgress: (received, total) {
|
|
if (cancelToken.isCancelled) return;
|
|
state = state.copyWith(
|
|
status: UpdateStatus.downloading,
|
|
received: received,
|
|
total: total > 0 ? total : asset.size,
|
|
speedBytesPerSec: _sampleSpeed(received),
|
|
);
|
|
},
|
|
);
|
|
if (cancelToken.isCancelled) return;
|
|
state = state.copyWith(
|
|
status: UpdateStatus.readyToInstall,
|
|
received: state.total,
|
|
speedBytesPerSec: 0,
|
|
installerPath: file.path,
|
|
clearError: true,
|
|
);
|
|
} on UpdateVerificationException catch (e, stack) {
|
|
AppLogger.error(_tag, 'update verification failed', e, stack);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
speedBytesPerSec: 0,
|
|
error: e.message,
|
|
);
|
|
return;
|
|
} on DioException catch (e, stack) {
|
|
if (CancelToken.isCancel(e)) {
|
|
state = state.copyWith(
|
|
status: UpdateStatus.available,
|
|
received: 0,
|
|
total: asset.size,
|
|
speedBytesPerSec: 0,
|
|
clearError: true,
|
|
);
|
|
return;
|
|
}
|
|
AppLogger.error(_tag, 'download failed', e, stack);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
speedBytesPerSec: 0,
|
|
error: '下载失败,请稍后重试',
|
|
);
|
|
} catch (e, stack) {
|
|
AppLogger.error(_tag, 'download failed', e, stack);
|
|
state = state.copyWith(
|
|
status: UpdateStatus.error,
|
|
speedBytesPerSec: 0,
|
|
error: '下载失败,请稍后重试',
|
|
);
|
|
} finally {
|
|
if (identical(_cancelToken, cancelToken)) {
|
|
_cancelToken = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
double? _sampleSpeed(int received) {
|
|
final now = DateTime.now();
|
|
final lastAt = _lastSpeedSampleAt;
|
|
if (lastAt == null) {
|
|
_lastSpeedSampleAt = now;
|
|
_lastSpeedSampleBytes = received;
|
|
return null;
|
|
}
|
|
final elapsedMs = now.difference(lastAt).inMilliseconds;
|
|
if (elapsedMs < 500) return null;
|
|
final delta = received - _lastSpeedSampleBytes;
|
|
_lastSpeedSampleAt = now;
|
|
_lastSpeedSampleBytes = received;
|
|
return delta > 0 ? delta * 1000.0 / elapsedMs : 0.0;
|
|
}
|
|
|
|
void cancel() {
|
|
_cancelToken?.cancel('cancelled by user');
|
|
_cancelToken = null;
|
|
final info = state.info;
|
|
state = state.copyWith(
|
|
status: info == null ? UpdateStatus.idle : UpdateStatus.available,
|
|
received: 0,
|
|
total: info?.selectedAsset?.size ?? 0,
|
|
speedBytesPerSec: 0,
|
|
clearError: true,
|
|
);
|
|
}
|
|
|
|
Future<void> install() async {
|
|
final path = state.installerPath;
|
|
if (path == null || path.isEmpty) return;
|
|
|
|
try {
|
|
await _service.launchInstaller(path);
|
|
} catch (e, stack) {
|
|
AppLogger.error(_tag, 'launch installer failed', e, stack);
|
|
state = state.copyWith(status: UpdateStatus.error, error: '安装程序启动失败');
|
|
}
|
|
}
|
|
}
|