diff --git a/lib/common/services/device_header_service.dart b/lib/common/services/device_header_service.dart index 440b5c9..8ed0077 100644 --- a/lib/common/services/device_header_service.dart +++ b/lib/common/services/device_header_service.dart @@ -34,6 +34,7 @@ class DeviceHeaderService { static const _umengChannel = String.fromEnvironment('UMENG_CHANNEL'); String _deviceId = ''; + String _packageName = ''; String _version = ''; String _build = ''; String _deviceModel = ''; @@ -68,6 +69,7 @@ class DeviceHeaderService { Future _initializeBase() async { try { final info = await PackageInfo.fromPlatform(); + _packageName = info.packageName.trim(); _version = info.version; _build = info.buildNumber; } catch (_) {} @@ -244,6 +246,7 @@ class DeviceHeaderService { /// 公共 header(不含 token)。11 Map commonHeaders() => _compact({ 'X-Device-Id': _deviceId, + 'pkg': _packageName, 'User-Version': _version, 'User-BuildID': _build, 'User-Platform': _platform(), diff --git a/lib/features/settings/application/update_progress.dart b/lib/features/settings/application/update_progress.dart new file mode 100644 index 0000000..a53cb34 --- /dev/null +++ b/lib/features/settings/application/update_progress.dart @@ -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((_) => null); +final updateUrlProvider = StateProvider((_) => null); +final updateDialogVisibleProvider = StateProvider((_) => 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; + } +} diff --git a/lib/features/settings/application/update_service.dart b/lib/features/settings/application/update_service.dart index 73830b9..202929d 100644 --- a/lib/features/settings/application/update_service.dart +++ b/lib/features/settings/application/update_service.dart @@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_app_update/flutter_app_update.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 '../data/update_api_client.dart'; +import '../presentation/update_dialog.dart'; +import 'update_progress.dart'; enum UpgradeCheckSource { automatic, manual } @@ -22,6 +23,8 @@ class UpdateService { required UpgradeCheckSource source, VoidCallback? onLatest, }) async { + if (ref.read(updateDialogVisibleProvider)) return; + final shouldShow = source == UpgradeCheckSource.automatic ? info.show == 1 && info.hasPublishedUpdate : info.hasPublishedUpdate; @@ -32,62 +35,57 @@ class UpdateService { final uri = Uri.tryParse(info.url); if (uri == null || uri.scheme != 'https' || !uri.hasAuthority) return; final force = info.force == 1; - await showDialog( - context: context, - barrierDismissible: !force, - builder: (dialogContext) => PopScope( - canPop: !force, - child: AlertDialog( - title: Text( - '发现新版本${info.latestVersion.isEmpty ? '' : ' ${info.latestVersion}'}', - ), - content: Text(info.message.isEmpty ? '发现新版本,建议立即更新。' : info.message), - actions: [ - if (!force) - TextButton( - onPressed: () => Navigator.pop(dialogContext), - 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 ? '前往更新' : '下载更新'), - ), - ], - ), - ), - ); + ref.read(updateDialogVisibleProvider.notifier).state = true; + try { + await showModalBottomSheet( + context: context, + useRootNavigator: true, + isDismissible: !force, + enableDrag: !force, + isScrollControlled: true, + builder: (_) => UpdateDialog(upgrade: info, source: source), + ); + } finally { + ref.read(updateProgressProvider.notifier).state = null; + ref.read(updateUrlProvider.notifier).state = null; + ref.read(updateDialogVisibleProvider.notifier).state = false; + } } - static Future _startUpdate(Uri uri) async { + static Future startUpdate( + String downloadUrl, { + bool showNotification = true, + }) async { + if (kIsWeb) return; if (Platform.isIOS) { - await launchUrl(uri, mode: LaunchMode.externalApplication); + await _openIosUpdateUrl(downloadUrl); return; } await AzhonAppUpdate.update( UpdateModel( - uri.toString(), + downloadUrl, 'jinzhi-update.apk', 'ic_launcher', 'https://apps.apple.com/search?term=%E9%87%91%E5%80%BC', - showNotification: true, + showNotification: showNotification, showBgdToast: false, ), ); } + + static Future install(String url) async { + if (kIsWeb) return; + if (Platform.isIOS) { + await _openIosUpdateUrl(url); + return; + } + await AzhonAppUpdate.install(url); + } + + static Future _openIosUpdateUrl(String downloadUrl) async { + final uri = Uri.tryParse(downloadUrl); + if (uri != null && uri.scheme == 'https' && uri.hasAuthority) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } } diff --git a/lib/features/settings/data/update_api_client.dart b/lib/features/settings/data/update_api_client.dart index 6f001d1..b692743 100644 --- a/lib/features/settings/data/update_api_client.dart +++ b/lib/features/settings/data/update_api_client.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:dio/dio.dart'; @@ -15,6 +16,7 @@ class UpgradeInfo { required this.isLatestVersion, required this.latestVersion, required this.versionCode, + required this.hash, }); final int show; @@ -24,6 +26,7 @@ class UpgradeInfo { final int isLatestVersion; final String latestVersion; final String versionCode; + final String hash; bool get hasPublishedUpdate => isLatestVersion != 1 && @@ -40,19 +43,38 @@ class UpgradeInfo { isLatestVersion: fields.integer(5), latestVersion: fields.string(6), versionCode: fields.string(7), + hash: fields.string(8), ); } } class UpdateApiClient { - UpdateApiClient(this._dio); + UpdateApiClient( + this._dio, { + required String apiPathPrefix, + required String updatePackageName, + }) : _apiPathPrefix = _normalizePrefix(apiPathPrefix), + _updatePackageName = updatePackageName.trim(); + 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 checkUpgrade() async { final response = await _dio.post>( - '/sys/upgrade', - data: const [], // jinzhi.v1.GetUpgradeRequest - options: protobufOptions(), + _path('/sys/upgrade'), + // Uint8List ensures Dio sends the empty protobuf as 0 bytes instead of + // JSON-encoding an empty Dart List as `[]` (Content-Length: 2). + data: Uint8List(0), // jinzhi.v1.GetUpgradeRequest + options: _options, ); return UpgradeInfo.fromProtobuf(response.data ?? const []); } @@ -62,18 +84,33 @@ class UpdateApiClient { required String source, }) async { await _dio.post>( - '/active/upgrade/click', + _path('/active/upgrade/click'), data: _ProtoWriter() .string(1, buildId) .string(2, source) .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( - (ref) => UpdateApiClient(ref.watch(dioProvider)), + (ref) => UpdateApiClient( + ref.watch(dioProvider), + apiPathPrefix: ref.watch(appConfigProvider).apiPathPrefix, + updatePackageName: ref.watch(appConfigProvider).updatePackageName, + ), ); class _ProtoFields { @@ -101,9 +138,7 @@ class _ProtoReader { if (end > _bytes.length) { throw const FormatException('Invalid protobuf'); } - result._values[tag] = String.fromCharCodes( - _bytes.sublist(_index, end), - ); + result._values[tag] = utf8.decode(_bytes.sublist(_index, end)); _index = end; default: throw const FormatException('Unsupported protobuf wire type'); @@ -128,7 +163,7 @@ class _ProtoReader { class _ProtoWriter { final _bytes = BytesBuilder(copy: false); _ProtoWriter string(int tag, String value) { - final bytes = value.codeUnits; + final bytes = utf8.encode(value); _varint((tag << 3) | 2); _varint(bytes.length); _bytes.add(bytes); diff --git a/lib/features/settings/presentation/update_dialog.dart b/lib/features/settings/presentation/update_dialog.dart new file mode 100644 index 0000000..0dd1612 --- /dev/null +++ b/lib/features/settings/presentation/update_dialog.dart @@ -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 createState() => _UpdateDialogState(); +} + +class _UpdateDialogState extends ConsumerState { + 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 _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 _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 (_) {} + } +}