1
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
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?,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class AppUpdateInfo {
|
||||
}
|
||||
|
||||
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
|
||||
/// (无普通/强制之分)。
|
||||
/// (无普通/强制之分)。2026-09-03 起更新走浏览器下载 APK 手动安装。
|
||||
class UpdateChecker {
|
||||
final String baseUrl;
|
||||
final http.Client _client;
|
||||
@@ -52,12 +52,11 @@ class UpdateChecker {
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。
|
||||
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
|
||||
/// 已更新完成再次启动也不会反复提示。
|
||||
static bool needsUpdate(String server, String installed, String accepted) {
|
||||
/// 是否需要更新:服务器版本高于已装版本(2026-09-03 起更新改浏览器下载,
|
||||
/// 安装结果由系统安装器完成,App 无法感知,故不再记录「已确认接受版本」)
|
||||
static bool needsUpdate(String server, String installed) {
|
||||
if (server.isEmpty || installed.isEmpty) return false;
|
||||
return isNewer(server, installed) || isNewer(server, accepted);
|
||||
return isNewer(server, installed);
|
||||
}
|
||||
|
||||
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
|
||||
|
||||
@@ -1,219 +1,53 @@
|
||||
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 版本号不递增时也不反复提示)。
|
||||
/// 2026-09-03 改为浏览器下载:App 内 PackageInstaller 会话安装在小米等
|
||||
/// ROM 上点系统确认框后失败(下载/写入正常但最终安装被拒),而系统浏览器
|
||||
/// 下载 APK 后经通知栏走 ROM 自己的安装器可正常完成,故下载链接改由浏览器
|
||||
/// 打开,不再 App 内下载/安装。仅点「立即更新」按钮时打开浏览器,不自动
|
||||
/// 跳转;安装完成(versionName 追上服务器)后下次启动不再提示。
|
||||
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;
|
||||
|
||||
/// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
Timer? _installTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 自动更新:进页即自动下载并安装,无需手动点击(2026-09-01 用户需求)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _launch());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_installTimer?.cancel();
|
||||
_installSub?.cancel();
|
||||
_client.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _launch() async {
|
||||
if (_stage == _Stage.downloading ||
|
||||
_stage == _Stage.installing ||
|
||||
_stage == _Stage.finished) {
|
||||
Future<void> _openBrowser() async {
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri == null) {
|
||||
setState(() => _message = '下载链接无效,请联系管理员');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_stage = _Stage.idle;
|
||||
_message = null;
|
||||
});
|
||||
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();
|
||||
// 下载无总时长上限(APK 几十 MB 慢网可能数分钟);连接/响应头与
|
||||
// 数据流分别做 30s 停滞判定,避免断流黑洞永久卡死
|
||||
final res = await _client
|
||||
.send(http.Request('GET', Uri.parse(widget.url)))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
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.timeout(const Duration(seconds: 30))) {
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
if (mounted && total > 0) {
|
||||
setState(() => _progress = received / total * 100);
|
||||
}
|
||||
}
|
||||
await sink.close();
|
||||
// 原子落盘:下载完成后才重命名为正式文件,避免残留半包被当成完整 APK
|
||||
_apkPart.renameSync(_apkFile.path);
|
||||
final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!mounted) return;
|
||||
await _install();
|
||||
setState(() {
|
||||
_message = ok ? '已打开浏览器下载,完成后点击通知栏的安装提示即可更新' : '打开浏览器失败,请点「立即更新」重试';
|
||||
});
|
||||
} 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();
|
||||
// 安装超时兜底:确认框未处理/系统无回调时避免永久卡「安装中」
|
||||
_installTimer?.cancel();
|
||||
_installTimer = Timer(const Duration(seconds: 120), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装超时,请重试';
|
||||
});
|
||||
});
|
||||
_installSub = ApkInstaller.progress().listen((e) {
|
||||
if (!mounted) return;
|
||||
switch (e.event) {
|
||||
case 'progress':
|
||||
setState(() => _progress = e.progress.toDouble());
|
||||
break;
|
||||
case 'finished':
|
||||
_installTimer?.cancel();
|
||||
final ok = e.success == true;
|
||||
// 安装成功才记录已接受版本:失败/取消时下次启动仍提示重试
|
||||
if (ok) widget.onUpdateAccepted?.call();
|
||||
setState(() {
|
||||
_stage = ok ? _Stage.finished : _Stage.failed;
|
||||
_message = ok ? '安装完成,请从桌面打开新版应用' : '安装失败,请重试';
|
||||
});
|
||||
break;
|
||||
case 'failed':
|
||||
_installTimer?.cancel();
|
||||
debugPrint('UpdateScreen: install failed: ${e.error}');
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = e.error ?? '安装失败,请重试';
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, onError: (Object _) {
|
||||
_installTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装失败,请重试';
|
||||
});
|
||||
});
|
||||
final String result;
|
||||
try {
|
||||
result = await ApkInstaller.install(_apkFile.path);
|
||||
} catch (_) {
|
||||
// 原生安装通道异常(如会话创建失败):不捕获则 UI 永久停在「安装中」
|
||||
_installTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '安装启动失败,请重试';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (result == 'permission_required') {
|
||||
// 原生侧已拉起系统设置页;APK 已缓存,用户开启后返回再点直达安装
|
||||
_installTimer?.cancel();
|
||||
setState(() {
|
||||
_stage = _Stage.failed;
|
||||
_message = '请在系统设置中允许「安装未知应用」,返回后再次点击「立即更新」(APK 已缓存,无需重新下载)';
|
||||
});
|
||||
setState(() => _message = '打开浏览器失败,请点「立即更新」重试');
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
@@ -234,26 +68,22 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
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 ? '已完成' : '立即更新'),
|
||||
onPressed: _openBrowser,
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
label: const Text('立即更新'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(200, 48),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'新版本需在浏览器下载 APK 后手动安装\n安装完成后请重新打开应用',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -261,16 +91,12 @@ class _UpdateScreenState extends State<UpdateScreen> {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
height: 1.5,
|
||||
color: _stage == _Stage.failed
|
||||
? theme.colorScheme.error
|
||||
: Colors.green,
|
||||
color: _message!.startsWith('已打开')
|
||||
? Colors.green
|
||||
: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Text('不更新将无法继续使用',
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user