import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:archive/archive.dart'; 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:island/widgets/content/markdown.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:process_run/process_run.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; final String name; final String body; final String htmlUrl; final DateTime createdAt; final List assets; const GithubReleaseInfo({ required this.tagName, required this.name, 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; final int minor; final int patch; final int build; const _ParsedVersion(this.major, this.minor, this.patch, this.build); static _ParsedVersion? tryParse(String input) { // Expect format like 0.0.0+00 (build after '+'). Allow missing build as 0. final partsPlus = input.split('+'); final core = partsPlus[0].trim(); final buildStr = partsPlus.length > 1 ? partsPlus[1].trim() : '0'; final coreParts = core.split('.'); if (coreParts.length != 3) return null; final major = int.tryParse(coreParts[0]) ?? 0; final minor = int.tryParse(coreParts[1]) ?? 0; final patch = int.tryParse(coreParts[2]) ?? 0; final build = int.tryParse(buildStr) ?? 0; return _ParsedVersion(major, minor, patch, build); } @override int compareTo(_ParsedVersion other) { if (major != other.major) return major.compareTo(other.major); if (minor != other.minor) return minor.compareTo(other.minor); if (patch != other.patch) return patch.compareTo(other.patch); return build.compareTo(other.build); } @override String toString() => '$major.$minor.$patch+$build'; } class UpdateService { UpdateService({Dio? dio, this.useProxy = false}) : _dio = dio ?? Dio( BaseOptions( headers: { // Identify the app to GitHub; avoids some rate-limits and adds clarity 'Accept': 'application/vnd.github+json', 'User-Agent': 'solian-update-checker', }, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 15), ), ); final Dio _dio; final bool useProxy; static const _proxyBaseUrl = 'https://ghfast.top/'; static const _releasesLatestApi = 'https://api.github.com/repos/solsynth/solian/releases/latest'; /// 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) { 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) { log('[Update] App is up to date. No update needed.'); return; } log('[Update] Update available! Latest: $latest, Local: $local'); 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)); 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; } } /// Manually show the update sheet with a provided release. /// Useful for About page or testing. Future showUpdateSheet( BuildContext context, GithubReleaseInfo release, ) async { if (!context.mounted) return; await showModalBottomSheet( context: context, isScrollControlled: true, useRootNavigator: true, builder: (ctx) { String? androidUpdateUrl; String? windowsUpdateUrl; if (Platform.isAndroid) { androidUpdateUrl = _getAndroidUpdateUrl(release.assets); } if (Platform.isWindows) { windowsUpdateUrl = _getWindowsUpdateUrl(); } return _UpdateSheet( release: release, onOpen: () async { final uri = Uri.parse(release.htmlUrl); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } }, androidUpdateUrl: androidUpdateUrl, windowsUpdateUrl: windowsUpdateUrl, useProxy: useProxy, // Pass the useProxy flag ); }, ); } 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 'https://fs.solsynth.dev/d/official/solian/${arm64.name}'; } else if (armeabi != null) { return 'https://fs.solsynth.dev/d/official/solian/${armeabi.name}'; } else if (x86_64 != null) { return 'https://fs.solsynth.dev/d/official/solian/${x86_64.name}'; } return null; } String _getWindowsUpdateUrl() { return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip'; } /// Downloads the Windows installer ZIP file Future _downloadWindowsInstaller(String url) async { try { log('[Update] Starting Windows installer download from: $url'); final tempDir = await getTemporaryDirectory(); final fileName = 'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip'; final filePath = path.join(tempDir.path, fileName); final response = await _dio.download( url, filePath, onReceiveProgress: (received, total) { if (total != -1) { log( '[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%', ); } }, ); if (response.statusCode == 200) { log('[Update] Windows installer downloaded successfully to: $filePath'); return filePath; } else { log( '[Update] Failed to download Windows installer. Status: ${response.statusCode}', ); return null; } } catch (e) { log('[Update] Error downloading Windows installer: $e'); return null; } } /// Extracts the ZIP file to a temporary directory Future _extractWindowsInstaller(String zipPath) async { try { log('[Update] Extracting Windows installer from: $zipPath'); final tempDir = await getTemporaryDirectory(); final extractDir = path.join( tempDir.path, 'solian-installer-${DateTime.now().millisecondsSinceEpoch}', ); final zipFile = File(zipPath); final bytes = await zipFile.readAsBytes(); final archive = ZipDecoder().decodeBytes(bytes); for (final file in archive) { final filename = file.name; if (file.isFile) { final data = file.content as List; final filePath = path.join(extractDir, filename); await Directory(path.dirname(filePath)).create(recursive: true); await File(filePath).writeAsBytes(data); } else { final dirPath = path.join(extractDir, filename); await Directory(dirPath).create(recursive: true); } } log('[Update] Windows installer extracted successfully to: $extractDir'); return extractDir; } catch (e) { log('[Update] Error extracting Windows installer: $e'); return null; } } /// Runs the setup.exe file Future _runWindowsInstaller(String extractDir) async { try { log('[Update] Running Windows installer from: $extractDir'); final setupExePath = path.join(extractDir, 'setup.exe'); if (!await File(setupExePath).exists()) { log('[Update] setup.exe not found in extracted directory'); return false; } final shell = Shell(); final results = await shell.run(setupExePath); final result = results.first; if (result.exitCode == 0) { log('[Update] Windows installer completed successfully'); return true; } else { log( '[Update] Windows installer failed with exit code: ${result.exitCode}', ); log('[Update] Installer output: ${result.stdout}'); log('[Update] Installer errors: ${result.stderr}'); return false; } } catch (e) { log('[Update] Error running Windows installer: $e'); return false; } } /// Performs automatic Windows update: download, extract, and install Future _performAutomaticWindowsUpdate( BuildContext context, String url, ) async { if (!context.mounted) return; // Show progress dialog showDialog( context: context, barrierDismissible: false, builder: (context) => const AlertDialog( title: Text('Installing Update'), content: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Downloading installer...'), ], ), ), ); try { // Step 1: Download if (!context.mounted) return; Navigator.of(context).pop(); // Close progress dialog showDialog( context: context, barrierDismissible: false, builder: (context) => const AlertDialog( title: Text('Installing Update'), content: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Extracting installer...'), ], ), ), ); final zipPath = await _downloadWindowsInstaller(url); if (zipPath == null) { if (!context.mounted) return; Navigator.of(context).pop(); _showErrorDialog(context, 'Failed to download installer'); return; } // Step 2: Extract if (!context.mounted) return; Navigator.of(context).pop(); // Close progress dialog showDialog( context: context, barrierDismissible: false, builder: (context) => const AlertDialog( title: Text('Installing Update'), content: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Running installer...'), ], ), ), ); final extractDir = await _extractWindowsInstaller(zipPath); if (extractDir == null) { if (!context.mounted) return; Navigator.of(context).pop(); _showErrorDialog(context, 'Failed to extract installer'); return; } // Step 3: Run installer if (!context.mounted) return; Navigator.of(context).pop(); // Close progress dialog final success = await _runWindowsInstaller(extractDir); if (!context.mounted) return; if (success) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Update Complete'), content: const Text( 'The application has been updated successfully. Please restart the application.', ), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); // Close the update sheet Navigator.of(context).pop(); }, child: const Text('OK'), ), ], ), ); } else { _showErrorDialog(context, 'Failed to run installer'); } // Cleanup try { await File(zipPath).delete(); await Directory(extractDir).delete(recursive: true); } catch (e) { log('[Update] Error cleaning up temporary files: $e'); } } catch (e) { if (!context.mounted) return; Navigator.of(context).pop(); // Close any open dialogs _showErrorDialog(context, 'Update failed: $e'); } } void _showErrorDialog(BuildContext context, String message) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Update Failed'), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ), ); } /// Fetch the latest release info from GitHub. /// Public so other screens (e.g., About) can manually trigger update checks. Future fetchLatestRelease() async { final apiEndpoint = useProxy ? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}' : _releasesLatestApi; log( '[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)', ); final resp = await _dio.get(apiEndpoint); 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(); final body = (data['body'] ?? '').toString(); 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) { 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 StatefulWidget { const _UpdateSheet({ required this.release, required this.onOpen, this.androidUpdateUrl, this.windowsUpdateUrl, this.useProxy = false, }); final String? androidUpdateUrl; final String? windowsUpdateUrl; final bool useProxy; final GithubReleaseInfo release; final VoidCallback onOpen; @override State<_UpdateSheet> createState() => _UpdateSheetState(); } class _UpdateSheetState extends State<_UpdateSheet> { late bool _useProxy; @override void initState() { super.initState(); _useProxy = widget.useProxy; } Future _installUpdate(String url) async { String downloadUrl = url; if (_useProxy) { final fileName = url.split('/').last; downloadUrl = 'https://fs.solsynth.dev/d/rainyun02/solian/$fileName'; } UpdateModel model = UpdateModel( downloadUrl, "solian-update-${widget.release.tagName}.apk", "launcher_icon", 'https://apps.apple.com/us/app/solian/id6499032345', ); AzhonAppUpdate.update(model); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return SheetScaffold( titleText: 'Update available', child: Padding( padding: EdgeInsets.only( bottom: 16 + MediaQuery.of(context).padding.bottom, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.release.name, style: theme.textTheme.titleMedium, ).bold(), Text(widget.release.tagName).fontSize(12), ], ).padding(vertical: 16, horizontal: 16), const Divider(height: 1), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 16, ), child: MarkdownTextContent( content: widget.release.body.isEmpty ? 'No changelog provided.' : widget.release.body, ), ), ), if (!kIsWeb && Platform.isAndroid) SwitchListTile( title: const Text('Use secondary source for download'), value: _useProxy, onChanged: (value) { setState(() { _useProxy = value; }); }, ).padding(horizontal: 8), Column( children: [ Row( spacing: 8, children: [ if (!kIsWeb && Platform.isAndroid && widget.androidUpdateUrl != null) Expanded( child: FilledButton.icon( onPressed: () { log(widget.androidUpdateUrl!); _installUpdate(widget.androidUpdateUrl!); }, icon: const Icon(Symbols.update), label: const Text('Install update'), ), ), if (!kIsWeb && Platform.isWindows && widget.windowsUpdateUrl != null) Expanded( child: FilledButton.icon( onPressed: () { // Access the UpdateService instance to call the automatic update method final updateService = UpdateService( useProxy: widget.useProxy, ); updateService._performAutomaticWindowsUpdate( context, widget.windowsUpdateUrl!, ); }, icon: const Icon(Symbols.update), label: const Text('Install update'), ), ), Expanded( child: FilledButton.icon( onPressed: widget.onOpen, icon: const Icon(Icons.open_in_new), label: const Text('Open release page'), ), ), ], ), ], ).padding(horizontal: 16), ], ), ), ); } }