This commit is contained in:
2026-08-26 09:48:26 +08:00
parent 6cac15a14d
commit 54d343b739
16 changed files with 448 additions and 24 deletions
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter/services.dart';
/// 安装进度事件(原生 PackageInstaller 会话回调经 EventChannel 回传)
class InstallEvent {
/// progress / finished / failed
final String event;
/// event=progress 时的安装进度 0-100
final int progress;
/// event=finished 时是否安装成功
final bool? success;
/// event=failed 时的错误描述
final String? error;
const InstallEvent({
required this.event,
this.progress = 0,
this.success,
this.error,
});
}
/// App 内安装 APK:原生侧 PackageInstaller 会话安装(InstallerChannel.kt
class ApkInstaller {
static const _method = MethodChannel('observer/installer');
static const _progress = EventChannel('observer/installer/progress');
/// 提交安装。返回 installing(已进入安装流程)/ permission_required
/// (未允许「安装未知应用」,原生侧已拉起系统设置页)。
static Future<String> install(String path) =>
_method.invokeMethod<String>('install', {'path': path}).then(
(v) => v ?? 'installing',
);
/// 安装进度流:progress(0-100) → finished(success) / failed(error)
static Stream<InstallEvent> progress() {
return _progress.receiveBroadcastStream().map((e) {
final m = e as Map;
return InstallEvent(
event: m['event'] as String? ?? '',
progress: (m['progress'] as num?)?.toInt() ?? 0,
success: m['success'] as bool?,
error: m['error'] as String?,
);
});
}
}
+174 -13
View File
@@ -1,12 +1,19 @@
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 边缘滑动均不可退出)
/// 「立即更新」跳转系统浏览器下载 APK。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出)
/// 「立即更新」在 App 内流式下载 APK(显示下载进度)→ PackageInstaller
/// 会话安装(显示安装进度),不再跳浏览器。
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
class UpdateScreen extends StatelessWidget {
class UpdateScreen extends StatefulWidget {
final String version;
final String url;
final String notes;
@@ -20,20 +27,150 @@ class UpdateScreen extends StatelessWidget {
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 {
onUpdateAccepted?.call();
final uri = Uri.tryParse(url);
if (uri == null) return;
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 {
await launchUrl(uri, mode: LaunchMode.externalApplication);
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(
@@ -46,23 +183,47 @@ class UpdateScreen extends StatelessWidget {
Icon(Icons.system_update_alt,
size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text('发现新版本 $version',
Text('发现新版本 ${widget.version}',
style: theme.textTheme.headlineSmall),
const SizedBox(height: 12),
if (notes.isNotEmpty)
Text(notes,
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: _launch,
onPressed: busy || _stage == _Stage.finished
? null
: _launch,
icon: const Icon(Icons.download),
label: const Text('立即更新'),
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