Android install update

This commit is contained in:
2025-08-10 03:21:34 +08:00
parent e6255a340b
commit 9bed4fa6fb
4 changed files with 157 additions and 56 deletions

View File

@@ -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:flutter_native_splash/flutter_native_splash.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect; import 'package:flutter_langdetect/flutter_langdetect.dart' as langdetect;
import 'package:island/services/update_service.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _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 // Router will be provided through Riverpod

View File

@@ -7,12 +7,12 @@ import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/udid.native.dart'; import 'package:island/services/udid.native.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/services/update_service.dart'; import 'package:island/services/update_service.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@@ -205,33 +205,16 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
// Fetch latest release and show the unified sheet // Fetch latest release and show the unified sheet
final svc = UpdateService(); final svc = UpdateService();
// Reuse service fetch + compare to decide content // Reuse service fetch + compare to decide content
showLoadingModal(context);
final release = await svc.fetchLatestRelease(); final release = await svc.fetchLatestRelease();
if (!context.mounted) return;
hideLoadingModal(context);
if (release != null) { if (release != null) {
await svc.showUpdateSheet(context, release); await svc.showUpdateSheet(context, release);
} else { } else {
// Fallback: show a simple sheet indicating no info showInfoAlert(
// Use your SheetScaffold for consistent styling 'Currently cannot get update from the GitHub.',
// Show a minimal message 'Unable to check for updates',
// 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.',
),
),
),
),
); );
} }
}, },

View File

@@ -1,19 +1,27 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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:package_info_plus/package_info_plus.dart';
import 'package:collection/collection.dart'; // Added for firstWhereOrNull
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
/// Data model for a GitHub release we care about /// Data model for a GitHub release we care about
class GithubReleaseInfo { class GithubReleaseInfo {
final String tagName; // e.g. 3.1.0+118 final String tagName;
final String name; // release title final String name;
final String body; // changelog markdown final String body;
final String htmlUrl; // release page final String htmlUrl;
final DateTime createdAt; final DateTime createdAt;
final List<GithubReleaseAsset> assets;
const GithubReleaseInfo({ const GithubReleaseInfo({
required this.tagName, required this.tagName,
@@ -21,9 +29,28 @@ class GithubReleaseInfo {
required this.body, required this.body,
required this.htmlUrl, required this.htmlUrl,
required this.createdAt, 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<String, dynamic> 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" /// Parses version and build number from "x.y.z+build"
class _ParsedVersion implements Comparable<_ParsedVersion> { class _ParsedVersion implements Comparable<_ParsedVersion> {
final int major; final int major;
@@ -85,31 +112,52 @@ class UpdateService {
/// Checks GitHub for the latest release and compares against the current app version. /// 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. /// If update is available, shows a bottom sheet with changelog and an action to open release page.
Future<void> checkForUpdates(BuildContext context) async { Future<void> checkForUpdates(BuildContext context) async {
log('[Update] Checking for updates...');
try { try {
final release = await fetchLatestRelease(); 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 info = await PackageInfo.fromPlatform();
final localVersionStr = '${info.version}+${info.buildNumber}'; final localVersionStr = '${info.version}+${info.buildNumber}';
log('[Update] Local app version: $localVersionStr');
final latest = _ParsedVersion.tryParse(release.tagName); final latest = _ParsedVersion.tryParse(release.tagName);
final local = _ParsedVersion.tryParse(localVersionStr); final local = _ParsedVersion.tryParse(localVersionStr);
if (latest == null || local == null) { if (latest == null || local == null) {
log(
'[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
);
// If parsing fails, do nothing silently // If parsing fails, do nothing silently
return; return;
} }
log('[Update] Parsed versions. Latest: $latest, Local: $local');
final needsUpdate = latest.compareTo(local) > 0; 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) // Delay to ensure UI is ready (if called at startup)
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
await showUpdateSheet(context, release); await showUpdateSheet(context, release);
} catch (_) { log('[Update] Update sheet shown.');
}
} catch (e) {
log('[Update] Error checking for updates: $e');
// Ignore errors (network, api, etc.) // Ignore errors (network, api, etc.)
return; return;
} }
@@ -126,8 +174,12 @@ class UpdateService {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
builder: builder: (ctx) {
(ctx) => _UpdateSheet( String? androidUpdateUrl;
if (Platform.isAndroid) {
androidUpdateUrl = _getAndroidUpdateUrl(release.assets);
}
return _UpdateSheet(
release: release, release: release,
onOpen: () async { onOpen: () async {
final uri = Uri.parse(release.htmlUrl); final uri = Uri.parse(release.htmlUrl);
@@ -135,16 +187,49 @@ class UpdateService {
await launchUrl(uri, mode: LaunchMode.externalApplication); await launchUrl(uri, mode: LaunchMode.externalApplication);
} }
}, },
), androidUpdateUrl: androidUpdateUrl,
); );
},
);
}
String? _getAndroidUpdateUrl(List<GithubReleaseAsset> 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. /// Fetch the latest release info from GitHub.
/// Public so other screens (e.g., About) can manually trigger update checks. /// Public so other screens (e.g., About) can manually trigger update checks.
Future<GithubReleaseInfo?> fetchLatestRelease() async { Future<GithubReleaseInfo?> fetchLatestRelease() async {
log(
'[Update] Fetching latest release from GitHub API: $_releasesLatestApi',
);
final resp = await _dio.get(_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<String, dynamic>; final data = resp.data as Map<String, dynamic>;
log('[Update] Successfully fetched release data.');
final tagName = (data['tag_name'] ?? '').toString(); final tagName = (data['tag_name'] ?? '').toString();
final name = (data['name'] ?? tagName).toString(); final name = (data['name'] ?? tagName).toString();
@@ -152,25 +237,52 @@ class UpdateService {
final htmlUrl = (data['html_url'] ?? '').toString(); final htmlUrl = (data['html_url'] ?? '').toString();
final createdAtStr = (data['created_at'] ?? '').toString(); final createdAtStr = (data['created_at'] ?? '').toString();
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now(); final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
final assetsData =
(data['assets'] as List<dynamic>?)
?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
.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( return GithubReleaseInfo(
tagName: tagName, tagName: tagName,
name: name, name: name,
body: body, body: body,
htmlUrl: htmlUrl, htmlUrl: htmlUrl,
createdAt: createdAt, createdAt: createdAt,
assets: assetsData,
); );
} }
} }
class _UpdateSheet extends StatelessWidget { 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 GithubReleaseInfo release;
final VoidCallback onOpen; final VoidCallback onOpen;
Future<void> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -208,7 +320,21 @@ class _UpdateSheet extends StatelessWidget {
Column( Column(
children: [ children: [
Row( Row(
spacing: 8,
children: [ 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( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: onOpen, onPressed: onOpen,

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.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/content/network_status_sheet.dart';
import 'package:island/widgets/tour/tour.dart'; import 'package:island/widgets/tour/tour.dart';
@@ -21,6 +22,7 @@ class AppWrapper extends HookConsumerWidget {
}); });
final sharingService = SharingIntentService(); final sharingService = SharingIntentService();
sharingService.initialize(context); sharingService.initialize(context);
UpdateService().checkForUpdates(context);
return () { return () {
sharingService.dispose(); sharingService.dispose();
ntySubs?.cancel(); ntySubs?.cancel();