fix:app检测更新补充逻辑

This commit is contained in:
jingyun committed 2026-09-07 11:03:05 +08:00
1 parent 05e89b80bb
commit b4343ea528
5 files changed
+260 -56

No files matched your search

@@ -34,6 +34,7 @@ class DeviceHeaderService {
static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL'); static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL');
String _deviceId = ''; String _deviceId = '';
String _packageName = '';
String _version = ''; String _version = '';
String _build = ''; String _build = '';
String _deviceModel = ''; String _deviceModel = '';
@@ -68,6 +69,7 @@ class DeviceHeaderService {
Future<void> _initializeBase() async { Future<void> _initializeBase() async {
try { try {
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
_packageName = info.packageName.trim();
_version = info.version; _version = info.version;
_build = info.buildNumber; _build = info.buildNumber;
} catch (_) {} } catch (_) {}
@@ -244,6 +246,7 @@ class DeviceHeaderService {
/// 公共 header(不含 token)。11 /// 公共 header(不含 token)。11
Map<String, String> commonHeaders() => _compact({ Map<String, String> commonHeaders() => _compact({
'X-Device-Id': _deviceId, 'X-Device-Id': _deviceId,
'pkg': _packageName,
'User-Version': _version, 'User-Version': _version,
'User-BuildID': _build, 'User-BuildID': _build,
'User-Platform': _platform(), 'User-Platform': _platform(),
@@ -0,0 +1,43 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:flutter_app_update/result_model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final updateProgressProvider = StateProvider<double?>((_) => null);
final updateUrlProvider = StateProvider<String?>((_) => null);
final updateDialogVisibleProvider = StateProvider<bool>((_) => false);
bool _listenerRegistered = false;
/// The native download callback can outlive the update dialog, so it uses the
/// app's provider container instead of retaining a dialog WidgetRef.
void ensureUpdateProgressListener(BuildContext context) {
if (_listenerRegistered) return;
_listenerRegistered = true;
final container = ProviderScope.containerOf(context, listen: false);
AzhonAppUpdate.listener((result) => applyUpdateProgress(container, result));
}
@visibleForTesting
void applyUpdateProgress(ProviderContainer container, ResultModel result) {
switch (result.type) {
case ResultType.downloading:
if (result.progress == null || result.max == null || result.max == 0) {
container.read(updateProgressProvider.notifier).state = null;
break;
}
container.read(updateProgressProvider.notifier).state =
result.progress!.toDouble() / result.max!.toDouble();
break;
case ResultType.done:
container.read(updateProgressProvider.notifier).state = 1;
container.read(updateUrlProvider.notifier).state = result.apk;
break;
case ResultType.error:
case ResultType.cancel:
container.read(updateProgressProvider.notifier).state = null;
break;
default:
break;
}
}
@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_app_update/flutter_app_update.dart'; import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../data/update_api_client.dart'; import '../data/update_api_client.dart';
import '../presentation/update_dialog.dart';
import 'update_progress.dart';
enum UpgradeCheckSource { automatic, manual } enum UpgradeCheckSource { automatic, manual }
@@ -22,6 +23,8 @@ class UpdateService {
required UpgradeCheckSource source, required UpgradeCheckSource source,
VoidCallback? onLatest, VoidCallback? onLatest,
}) async { }) async {
if (ref.read(updateDialogVisibleProvider)) return;
final shouldShow = source == UpgradeCheckSource.automatic final shouldShow = source == UpgradeCheckSource.automatic
? info.show == 1 && info.hasPublishedUpdate ? info.show == 1 && info.hasPublishedUpdate
: info.hasPublishedUpdate; : info.hasPublishedUpdate;
@@ -32,62 +35,57 @@ class UpdateService {
final uri = Uri.tryParse(info.url); final uri = Uri.tryParse(info.url);
if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return; if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return;
final force = info.force == 1; final force = info.force == 1;
await showDialog<void>( ref.read(updateDialogVisibleProvider.notifier).state = true;
context: context, try {
barrierDismissible: !force, await showModalBottomSheet<void>(
builder: (dialogContext) => PopScope( context: context,
canPop: !force, useRootNavigator: true,
child: AlertDialog( isDismissible: !force,
title: Text( enableDrag: !force,
'发现新版本${info.latestVersion.isEmpty ? '' : ' ${info.latestVersion}'}', isScrollControlled: true,
), builder: (_) => UpdateDialog(upgrade: info, source: source),
content: Text(info.message.isEmpty ? '发现新版本,建议立即更新。' : info.message), );
actions: [ } finally {
if (!force) ref.read(updateProgressProvider.notifier).state = null;
TextButton( ref.read(updateUrlProvider.notifier).state = null;
onPressed: () => Navigator.pop(dialogContext), ref.read(updateDialogVisibleProvider.notifier).state = false;
child: const Text('稍后再说'), }
),
FilledButton(
onPressed: () async {
Navigator.pop(dialogContext);
final package = await PackageInfo.fromPlatform();
try {
await ref
.read(updateApiClientProvider)
.reportClick(
buildId: info.versionCode.isEmpty
? package.buildNumber
: info.versionCode,
source: source == UpgradeCheckSource.manual
? 'manual'
: 'auto',
);
} catch (_) {}
await _startUpdate(uri);
},
child: Text(Platform.isIOS ? '前往更新' : '下载更新'),
),
],
),
),
);
} }
static Future<void> _startUpdate(Uri uri) async { static Future<void> startUpdate(
String downloadUrl, {
bool showNotification = true,
}) async {
if (kIsWeb) return;
if (Platform.isIOS) { if (Platform.isIOS) {
await launchUrl(uri, mode: LaunchMode.externalApplication); await _openIosUpdateUrl(downloadUrl);
return; return;
} }
await AzhonAppUpdate.update( await AzhonAppUpdate.update(
UpdateModel( UpdateModel(
uri.toString(), downloadUrl,
'jinzhi-update.apk', 'jinzhi-update.apk',
'ic_launcher', 'ic_launcher',
'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC', 'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC',
showNotification: true, showNotification: showNotification,
showBgdToast: false, showBgdToast: false,
), ),
); );
} }
static Future<void> install(String url) async {
if (kIsWeb) return;
if (Platform.isIOS) {
await _openIosUpdateUrl(url);
return;
}
await AzhonAppUpdate.install(url);
}
static Future<void> _openIosUpdateUrl(String downloadUrl) async {
final uri = Uri.tryParse(downloadUrl);
if (uri != null && uri.scheme == 'https' && uri.hasAuthority) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
} }
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@@ -15,6 +16,7 @@ class UpgradeInfo {
required this.isLatestVersion, required this.isLatestVersion,
required this.latestVersion, required this.latestVersion,
required this.versionCode, required this.versionCode,
required this.hash,
}); });
final int show; final int show;
@@ -24,6 +26,7 @@ class UpgradeInfo {
final int isLatestVersion; final int isLatestVersion;
final String latestVersion; final String latestVersion;
final String versionCode; final String versionCode;
final String hash;
bool get hasPublishedUpdate => bool get hasPublishedUpdate =>
isLatestVersion != 1 && isLatestVersion != 1 &&
@@ -40,19 +43,38 @@ class UpgradeInfo {
isLatestVersion: fields.integer(5), isLatestVersion: fields.integer(5),
latestVersion: fields.string(6), latestVersion: fields.string(6),
versionCode: fields.string(7), versionCode: fields.string(7),
hash: fields.string(8),
); );
} }
} }
class UpdateApiClient { class UpdateApiClient {
UpdateApiClient(this._dio); UpdateApiClient(
this._dio, {
required String apiPathPrefix,
required String updatePackageName,
}) : _apiPathPrefix = _normalizePrefix(apiPathPrefix),
_updatePackageName = updatePackageName.trim();
final Dio _dio; final Dio _dio;
final String _apiPathPrefix;
final String _updatePackageName;
Options get _options {
final options = protobufOptions();
if (_updatePackageName.isEmpty) return options;
return options.copyWith(
headers: {...?options.headers, 'pkg': _updatePackageName},
);
}
Future<UpgradeInfo> checkUpgrade() async { Future<UpgradeInfo> checkUpgrade() async {
final response = await _dio.post<List<int>>( final response = await _dio.post<List<int>>(
'/sys/upgrade', _path('/sys/upgrade'),
data: const <int>[], // jinzhi.v1.GetUpgradeRequest // Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of
options: protobufOptions(), // JSON-encoding an empty Dart List as `[]` (Content-Length: 2).
data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest
options: _options,
); );
return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]); return UpgradeInfo.fromProtobuf(response.data ?? const <int>[]);
} }
@@ -62,18 +84,33 @@ class UpdateApiClient {
required String source, required String source,
}) async { }) async {
await _dio.post<List<int>>( await _dio.post<List<int>>(
'/active/upgrade/click', _path('/active/upgrade/click'),
data: _ProtoWriter() data: _ProtoWriter()
.string(1, buildId) .string(1, buildId)
.string(2, source) .string(2, source)
.toBytes(), // jinzhi.v1.UpgradeClickRequest .toBytes(), // jinzhi.v1.UpgradeClickRequest
options: protobufOptions(), options: _options,
); );
} }
String _path(String endpoint) => '$_apiPathPrefix$endpoint';
static String _normalizePrefix(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == '/') return '';
final withLeadingSlash = trimmed.startsWith('/') ? trimmed : '/$trimmed';
return withLeadingSlash.endsWith('/')
? withLeadingSlash.substring(0, withLeadingSlash.length - 1)
: withLeadingSlash;
}
} }
final updateApiClientProvider = Provider<UpdateApiClient>( final updateApiClientProvider = Provider<UpdateApiClient>(
(ref) => UpdateApiClient(ref.watch(dioProvider)), (ref) => UpdateApiClient(
ref.watch(dioProvider),
apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix,
updatePackageName: ref.watch(appConfigProvider).updatePackageName,
),
); );
class _ProtoFields { class _ProtoFields {
@@ -101,9 +138,7 @@ class _ProtoReader {
if (end > _bytes.length) { if (end > _bytes.length) {
throw const FormatException('Invalid protobuf'); throw const FormatException('Invalid protobuf');
} }
result._values[tag] = String.fromCharCodes( result._values[tag] = utf8.decode(_bytes.sublist(_index, end));
_bytes.sublist(_index, end),
);
_index = end; _index = end;
default: default:
throw const FormatException('Unsupported protobuf wire type'); throw const FormatException('Unsupported protobuf wire type');
@@ -128,7 +163,7 @@ class _ProtoReader {
class _ProtoWriter { class _ProtoWriter {
final _bytes = BytesBuilder(copy: false); final _bytes = BytesBuilder(copy: false);
_ProtoWriter string(int tag, String value) { _ProtoWriter string(int tag, String value) {
final bytes = value.codeUnits; final bytes = utf8.encode(value);
_varint((tag << 3) | 2); _varint((tag << 3) | 2);
_varint(bytes.length); _varint(bytes.length);
_bytes.add(bytes); _bytes.add(bytes);
@@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/theme/spacing.dart';
import '../application/update_progress.dart';
import '../application/update_service.dart';
import '../data/update_api_client.dart';
class UpdateDialog extends ConsumerStatefulWidget {
const UpdateDialog({super.key, required this.upgrade, required this.source});
final UpgradeInfo upgrade;
final UpgradeCheckSource source;
@override
ConsumerState<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends ConsumerState<UpdateDialog> {
bool _clickReported = false;
@override
Widget build(BuildContext context) {
ensureUpdateProgressListener(context);
final colorScheme = ShadTheme.of(context).colorScheme;
final progress = ref.watch(updateProgressProvider);
final forced = widget.upgrade.force == 1;
final canStart = progress == null || progress <= 0 || progress == 1;
final version = widget.upgrade.latestVersion.trim().isEmpty
? widget.upgrade.versionCode
: widget.upgrade.latestVersion;
return PopScope(
canPop: !forced,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 28, 22, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'发现新版本 V$version',
textAlign: TextAlign.center,
style: TextStyle(
color: colorScheme.foreground,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
if (widget.upgrade.message.trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
widget.upgrade.message,
style: TextStyle(
color: colorScheme.mutedForeground,
fontSize: 14,
height: 1.6,
),
),
],
const SizedBox(height: Spacing.sectionGap),
if (canStart)
FilledButton(
onPressed: () => _startUpdate(progress),
child: Text(progress == 1 ? '安装此版本' : '升级此版本'),
)
else
LinearProgressIndicator(
value: progress.clamp(0, 1),
color: colorScheme.primary,
backgroundColor: colorScheme.muted,
),
if (!forced && !canStart) ...[
const SizedBox(height: 10),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('转后台下载'),
),
],
if (!forced) ...[
const SizedBox(height: 6),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('稍后再说'),
),
],
],
),
),
),
);
}
Future<void> _startUpdate(double? progress) async {
if (!_clickReported) {
_clickReported = true;
unawaited(_reportClick());
}
final localPackage = ref.read(updateUrlProvider);
if (progress == 1 && localPackage != null && localPackage.isNotEmpty) {
await UpdateService.install(localPackage);
return;
}
await UpdateService.startUpdate(widget.upgrade.url);
}
Future<void> _reportClick() async {
if (widget.upgrade.versionCode.isEmpty) return;
try {
await ref
.read(updateApiClientProvider)
.reportClick(
buildId: widget.upgrade.versionCode,
source: widget.source == UpgradeCheckSource.manual
? 'manual'
: 'auto',
);
} catch (_) {}
}
}