fix:app检测更新补充逻辑
This commit is contained in:
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');
|
||||
|
||||
String _deviceId = '';
|
||||
String _packageName = '';
|
||||
String _version = '';
|
||||
String _build = '';
|
||||
String _deviceModel = '';
|
||||
@@ -68,6 +69,7 @@ class DeviceHeaderService {
|
||||
Future<void> _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<String, String> commonHeaders() => _compact({
|
||||
'X-Device-Id': _deviceId,
|
||||
'pkg': _packageName,
|
||||
'User-Version': _version,
|
||||
'User-BuildID': _build,
|
||||
'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_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<void>(
|
||||
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<void>(
|
||||
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<void> _startUpdate(Uri uri) async {
|
||||
static Future<void> 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<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 '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<UpgradeInfo> checkUpgrade() async {
|
||||
final response = await _dio.post<List<int>>(
|
||||
'/sys/upgrade',
|
||||
data: const <int>[], // 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 <int>[]);
|
||||
}
|
||||
@@ -62,18 +84,33 @@ class UpdateApiClient {
|
||||
required String source,
|
||||
}) async {
|
||||
await _dio.post<List<int>>(
|
||||
'/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<UpdateApiClient>(
|
||||
(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);
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user