From 9bed4fa6fbff976c52582b783698cd6796fca6b9 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 10 Aug 2025 03:21:34 +0800 Subject: [PATCH] :sparkles: Android install update --- lib/main.dart | 10 -- lib/screens/about.dart | 31 ++---- lib/services/update_service.dart | 170 +++++++++++++++++++++++++++---- lib/widgets/app_wrapper.dart | 2 + 4 files changed, 157 insertions(+), 56 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index a5a05bd..1c7abb5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,7 +30,6 @@ import 'package:image_picker_platform_interface/image_picker_platform_interface. import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect; -import 'package:island/services/update_service.dart'; @pragma('vm:entry-point') Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { @@ -144,15 +143,6 @@ void main() async { ), ), ); - - // Schedule update check shortly after startup, when a context is available. - // Uses the global overlay key to obtain a BuildContext safely. - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = globalOverlay.currentContext; - if (ctx != null) { - UpdateService().checkForUpdates(ctx); - } - }); } // Router will be provided through Riverpod diff --git a/lib/screens/about.dart b/lib/screens/about.dart index 533c306..1ee4bd6 100644 --- a/lib/screens/about.dart +++ b/lib/screens/about.dart @@ -7,12 +7,12 @@ import 'package:flutter/services.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/services/udid.native.dart'; +import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:island/services/update_service.dart'; -import 'package:island/widgets/content/sheet.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -205,33 +205,16 @@ class _AboutScreenState extends ConsumerState { // Fetch latest release and show the unified sheet final svc = UpdateService(); // Reuse service fetch + compare to decide content + showLoadingModal(context); final release = await svc.fetchLatestRelease(); + if (!context.mounted) return; + hideLoadingModal(context); if (release != null) { await svc.showUpdateSheet(context, release); } else { - // Fallback: show a simple sheet indicating no info - // Use your SheetScaffold for consistent styling - // Show a minimal message - // ignore: use_build_context_synchronously - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - backgroundColor: - Theme.of(context).colorScheme.surface, - builder: - (_) => const SheetScaffold( - titleText: 'Update', - child: Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - 'Unable to fetch release info at this time.', - ), - ), - ), - ), + showInfoAlert( + 'Currently cannot get update from the GitHub.', + 'Unable to check for updates', ); } }, diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 6821965..acbe561 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -1,19 +1,27 @@ import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_app_update/azhon_app_update.dart'; +import 'package:flutter_app_update/update_model.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:collection/collection.dart'; // Added for firstWhereOrNull import 'package:styled_widget/styled_widget.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:island/widgets/content/sheet.dart'; /// Data model for a GitHub release we care about class GithubReleaseInfo { - final String tagName; // e.g. 3.1.0+118 - final String name; // release title - final String body; // changelog markdown - final String htmlUrl; // release page + final String tagName; + final String name; + final String body; + final String htmlUrl; final DateTime createdAt; + final List assets; const GithubReleaseInfo({ required this.tagName, @@ -21,9 +29,28 @@ class GithubReleaseInfo { required this.body, required this.htmlUrl, required this.createdAt, + this.assets = const [], }); } +/// Data model for a GitHub release asset +class GithubReleaseAsset { + final String name; + final String browserDownloadUrl; + + const GithubReleaseAsset({ + required this.name, + required this.browserDownloadUrl, + }); + + factory GithubReleaseAsset.fromJson(Map json) { + return GithubReleaseAsset( + name: json['name'] as String, + browserDownloadUrl: json['browser_download_url'] as String, + ); + } +} + /// Parses version and build number from "x.y.z+build" class _ParsedVersion implements Comparable<_ParsedVersion> { final int major; @@ -85,31 +112,52 @@ class UpdateService { /// Checks GitHub for the latest release and compares against the current app version. /// If update is available, shows a bottom sheet with changelog and an action to open release page. Future checkForUpdates(BuildContext context) async { + log('[Update] Checking for updates...'); try { final release = await fetchLatestRelease(); - if (release == null) return; + if (release == null) { + log('[Update] No latest release found or could not fetch.'); + return; + } + log('[Update] Fetched latest release: ${release.tagName}'); final info = await PackageInfo.fromPlatform(); final localVersionStr = '${info.version}+${info.buildNumber}'; + log('[Update] Local app version: $localVersionStr'); final latest = _ParsedVersion.tryParse(release.tagName); final local = _ParsedVersion.tryParse(localVersionStr); if (latest == null || local == null) { + log( + '[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr', + ); // If parsing fails, do nothing silently return; } + log('[Update] Parsed versions. Latest: $latest, Local: $local'); final needsUpdate = latest.compareTo(local) > 0; - if (!needsUpdate) return; + if (!needsUpdate) { + log('[Update] App is up to date. No update needed.'); + return; + } + log('[Update] Update available! Latest: $latest, Local: $local'); - if (!context.mounted) return; + if (!context.mounted) { + log('[Update] Context not mounted, cannot show update sheet.'); + return; + } // Delay to ensure UI is ready (if called at startup) await Future.delayed(const Duration(milliseconds: 100)); - await showUpdateSheet(context, release); - } catch (_) { + if (context.mounted) { + await showUpdateSheet(context, release); + log('[Update] Update sheet shown.'); + } + } catch (e) { + log('[Update] Error checking for updates: $e'); // Ignore errors (network, api, etc.) return; } @@ -126,25 +174,62 @@ class UpdateService { context: context, isScrollControlled: true, useRootNavigator: true, - builder: - (ctx) => _UpdateSheet( - release: release, - onOpen: () async { - final uri = Uri.parse(release.htmlUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - }, - ), + builder: (ctx) { + String? androidUpdateUrl; + if (Platform.isAndroid) { + androidUpdateUrl = _getAndroidUpdateUrl(release.assets); + } + return _UpdateSheet( + release: release, + onOpen: () async { + final uri = Uri.parse(release.htmlUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + }, + androidUpdateUrl: androidUpdateUrl, + ); + }, ); } + String? _getAndroidUpdateUrl(List assets) { + final arm64 = assets.firstWhereOrNull( + (asset) => asset.name == 'app-arm64-v8a-release.apk', + ); + final armeabi = assets.firstWhereOrNull( + (asset) => asset.name == 'app-armeabi-v7a-release.apk', + ); + final x86_64 = assets.firstWhereOrNull( + (asset) => asset.name == 'app-x86_64-release.apk', + ); + + // Prioritize arm64, then armeabi, then x86_64 + if (arm64 != null) { + return arm64.browserDownloadUrl; + } else if (armeabi != null) { + return armeabi.browserDownloadUrl; + } else if (x86_64 != null) { + return x86_64.browserDownloadUrl; + } + return null; + } + /// Fetch the latest release info from GitHub. /// Public so other screens (e.g., About) can manually trigger update checks. Future fetchLatestRelease() async { + log( + '[Update] Fetching latest release from GitHub API: $_releasesLatestApi', + ); final resp = await _dio.get(_releasesLatestApi); - if (resp.statusCode != 200) return null; + if (resp.statusCode != 200) { + log( + '[Update] Failed to fetch latest release. Status code: ${resp.statusCode}', + ); + return null; + } final data = resp.data as Map; + log('[Update] Successfully fetched release data.'); final tagName = (data['tag_name'] ?? '').toString(); final name = (data['name'] ?? tagName).toString(); @@ -152,25 +237,52 @@ class UpdateService { final htmlUrl = (data['html_url'] ?? '').toString(); final createdAtStr = (data['created_at'] ?? '').toString(); final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now(); + final assetsData = + (data['assets'] as List?) + ?.map((e) => GithubReleaseAsset.fromJson(e as Map)) + .toList() ?? + []; - if (tagName.isEmpty || htmlUrl.isEmpty) return null; + if (tagName.isEmpty || htmlUrl.isEmpty) { + log( + '[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"', + ); + return null; + } + log('[Update] Returning GithubReleaseInfo for tag: $tagName'); return GithubReleaseInfo( tagName: tagName, name: name, body: body, htmlUrl: htmlUrl, createdAt: createdAt, + assets: assetsData, ); } } class _UpdateSheet extends StatelessWidget { - const _UpdateSheet({required this.release, required this.onOpen}); + const _UpdateSheet({ + required this.release, + required this.onOpen, + this.androidUpdateUrl, // Made nullable + }); + final String? androidUpdateUrl; // Changed to nullable final GithubReleaseInfo release; final VoidCallback onOpen; + Future installUpdate(String url) async { + UpdateModel model = UpdateModel( + url, + "solian-update-${release.tagName}.apk", + "ic_launcher", + 'https://apps.apple.com/us/app/solian/id6499032345', + ); + AzhonAppUpdate.update(model); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -208,7 +320,21 @@ class _UpdateSheet extends StatelessWidget { Column( children: [ Row( + spacing: 8, children: [ + if (!kIsWeb && + Platform.isAndroid && + androidUpdateUrl != null) + Expanded( + child: FilledButton.icon( + onPressed: () { + log(androidUpdateUrl!); + installUpdate(androidUpdateUrl!); + }, + icon: const Icon(Symbols.update), + label: const Text('Install update'), + ), + ), Expanded( child: FilledButton.icon( onPressed: onOpen, diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index fe84608..8bbf398 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/services/notify.dart'; import 'package:island/services/sharing_intent.dart'; +import 'package:island/services/update_service.dart'; import 'package:island/widgets/content/network_status_sheet.dart'; import 'package:island/widgets/tour/tour.dart'; @@ -21,6 +22,7 @@ class AppWrapper extends HookConsumerWidget { }); final sharingService = SharingIntentService(); sharingService.initialize(context); + UpdateService().checkForUpdates(context); return () { sharingService.dispose(); ntySubs?.cancel();