239 lines
7.8 KiB
Dart
239 lines
7.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
import 'installer.dart';
|
|
|
|
/// 强制更新页:检测到新版本时的全屏阻塞页。
|
|
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)。
|
|
/// 点「立即更新」在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
|
|
/// 会话安装(显示安装进度),不再跳浏览器。
|
|
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
|
|
/// 使 APK 版本号不递增时也不反复提示)。
|
|
class UpdateScreen extends StatefulWidget {
|
|
final String version;
|
|
final String url;
|
|
final String notes;
|
|
final VoidCallback? onUpdateAccepted;
|
|
|
|
const UpdateScreen({
|
|
super.key,
|
|
required this.version,
|
|
required this.url,
|
|
this.notes = '',
|
|
this.onUpdateAccepted,
|
|
});
|
|
|
|
@override
|
|
State<UpdateScreen> createState() => _UpdateScreenState();
|
|
}
|
|
|
|
enum _Stage { idle, downloading, installing, finished, failed }
|
|
|
|
class _UpdateScreenState extends State<UpdateScreen> {
|
|
final http.Client _client = http.Client();
|
|
final File _apkFile = File('${Directory.systemTemp.path}/observer-latest.apk');
|
|
final File _apkPart =
|
|
File('${Directory.systemTemp.path}/observer-latest.apk.part');
|
|
|
|
_Stage _stage = _Stage.idle;
|
|
|
|
/// 进度百分比 0-100;null = 总量未知(不确定进度条)
|
|
double? _progress;
|
|
String? _message;
|
|
StreamSubscription<InstallEvent>? _installSub;
|
|
|
|
@override
|
|
void dispose() {
|
|
_installSub?.cancel();
|
|
_client.close();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _launch() async {
|
|
if (_stage == _Stage.downloading ||
|
|
_stage == _Stage.installing ||
|
|
_stage == _Stage.finished) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_stage = _Stage.idle;
|
|
_message = null;
|
|
});
|
|
widget.onUpdateAccepted?.call();
|
|
if (!Platform.isAndroid) {
|
|
// 更新检查本就仅 Android 触发,这里兜底非 Android 走浏览器
|
|
final uri = Uri.tryParse(widget.url);
|
|
if (uri == null) return;
|
|
try {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
} catch (_) {}
|
|
return;
|
|
}
|
|
// APK 已下载完成(下载是原子落盘,.part 改名后文件才存在)→ 跳过下载直接安装
|
|
if (_apkFile.existsSync()) {
|
|
await _install();
|
|
return;
|
|
}
|
|
await _download();
|
|
}
|
|
|
|
Future<void> _download() async {
|
|
setState(() {
|
|
_stage = _Stage.downloading;
|
|
_progress = 0;
|
|
});
|
|
try {
|
|
if (_apkPart.existsSync()) _apkPart.deleteSync();
|
|
final res = await _client.send(http.Request('GET', Uri.parse(widget.url)));
|
|
if (res.statusCode != 200) {
|
|
throw HttpException('HTTP ${res.statusCode}');
|
|
}
|
|
final total = res.contentLength ?? -1;
|
|
final sink = _apkPart.openWrite();
|
|
var received = 0;
|
|
await for (final chunk in res.stream) {
|
|
sink.add(chunk);
|
|
received += chunk.length;
|
|
if (mounted && total > 0) {
|
|
setState(() => _progress = received / total * 100);
|
|
}
|
|
}
|
|
await sink.close();
|
|
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
|
|
_apkPart.renameSync(_apkFile.path);
|
|
if (!mounted) return;
|
|
await _install();
|
|
} catch (_) {
|
|
if (_apkPart.existsSync()) _apkPart.deleteSync();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stage = _Stage.failed;
|
|
_progress = null;
|
|
_message = '下载失败,请检查网络后重试';
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _install() async {
|
|
setState(() {
|
|
_stage = _Stage.installing;
|
|
_progress = 0;
|
|
_message = null;
|
|
});
|
|
await _installSub?.cancel();
|
|
_installSub = ApkInstaller.progress().listen((e) {
|
|
if (!mounted) return;
|
|
switch (e.event) {
|
|
case 'progress':
|
|
setState(() => _progress = e.progress.toDouble());
|
|
break;
|
|
case 'finished':
|
|
final ok = e.success == true;
|
|
setState(() {
|
|
_stage = ok ? _Stage.finished : _Stage.failed;
|
|
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
|
|
});
|
|
break;
|
|
case 'failed':
|
|
setState(() {
|
|
_stage = _Stage.failed;
|
|
_message = e.error ?? '安装失败,请重试';
|
|
});
|
|
break;
|
|
}
|
|
}, onError: (Object _) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_stage = _Stage.failed;
|
|
_message = '安装失败,请重试';
|
|
});
|
|
});
|
|
final result = await ApkInstaller.install(_apkFile.path);
|
|
if (!mounted) return;
|
|
if (result == 'permission_required') {
|
|
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
|
|
setState(() {
|
|
_stage = _Stage.failed;
|
|
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final busy = _stage == _Stage.downloading || _stage == _Stage.installing;
|
|
final progressText = _stage == _Stage.downloading
|
|
? (_progress == null ? '下载中…' : '下载中 ${_progress!.round()}%')
|
|
: (_progress == null ? '安装中…' : '安装中 ${_progress!.round()}%');
|
|
|
|
return PopScope(
|
|
canPop: false,
|
|
child: Scaffold(
|
|
body: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.system_update_alt,
|
|
size: 64, color: theme.colorScheme.primary),
|
|
const SizedBox(height: 16),
|
|
Text('发现新版本 ${widget.version}',
|
|
style: theme.textTheme.headlineSmall),
|
|
const SizedBox(height: 12),
|
|
if (widget.notes.isNotEmpty)
|
|
Text(widget.notes,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(height: 1.6)),
|
|
const SizedBox(height: 24),
|
|
if (busy) ...[
|
|
LinearProgressIndicator(
|
|
value: _progress == null ? null : _progress! / 100,
|
|
minHeight: 6,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(progressText),
|
|
const SizedBox(height: 24),
|
|
],
|
|
FilledButton.icon(
|
|
onPressed: busy || _stage == _Stage.finished
|
|
? null
|
|
: _launch,
|
|
icon: const Icon(Icons.download),
|
|
label: Text(_stage == _Stage.finished ? '已完成' : '立即更新'),
|
|
style: FilledButton.styleFrom(
|
|
minimumSize: const Size(200, 48),
|
|
textStyle: const TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
if (_message != null) ...[
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
_message!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
height: 1.5,
|
|
color: _stage == _Stage.failed
|
|
? theme.colorScheme.error
|
|
: Colors.green,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
Text('不更新将无法继续使用',
|
|
style: theme.textTheme.bodySmall
|
|
?.copyWith(color: theme.colorScheme.error)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|