import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:open_file/open_file.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; enum UpdateStatus { idle, checking, available, downloading, upToDate, error } class UpdateState { final UpdateStatus status; final String? currentVersion; final String? latestVersion; final String? downloadUrl; final double downloadProgress; final String? errorMessage; const UpdateState({ this.status = UpdateStatus.idle, this.currentVersion, this.latestVersion, this.downloadUrl, this.downloadProgress = 0.0, this.errorMessage, }); UpdateState copyWith({ UpdateStatus? status, String? currentVersion, String? latestVersion, String? downloadUrl, double? downloadProgress, String? errorMessage, }) => UpdateState( status: status ?? this.status, currentVersion: currentVersion ?? this.currentVersion, latestVersion: latestVersion ?? this.latestVersion, downloadUrl: downloadUrl ?? this.downloadUrl, downloadProgress: downloadProgress ?? this.downloadProgress, errorMessage: errorMessage ?? this.errorMessage, ); } class UpdateNotifier extends Notifier { @override UpdateState build() => const UpdateState(); /// [repoUrl] is the Forgejo repo page URL, e.g. /// "https://git.example.com/user/fabled_app" Future check(String repoUrl) async { state = state.copyWith(status: UpdateStatus.checking); try { final packageInfo = await PackageInfo.fromPlatform(); // Combine versionName + buildNumber to match the YY.MM.DD.N tag format. final currentVersion = '${packageInfo.version}.${packageInfo.buildNumber}'; // Parse repo URL → Forgejo API endpoint final uri = Uri.parse(repoUrl); final parts = uri.pathSegments.where((s) => s.isNotEmpty).toList(); if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; final apiUrl = '${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; final response = await Dio().get(apiUrl); final tagName = (response.data['tag_name'] as String? ?? '').trim(); final latestVersion = tagName.startsWith('v') ? tagName.substring(1) : tagName; if (_isNewer(latestVersion, currentVersion)) { final assets = (response.data['assets'] as List? ?? []) .cast>(); final apk = assets.firstWhere( (a) => (a['name'] as String? ?? '').endsWith('.apk'), orElse: () => {}, ); if (apk.isNotEmpty) { state = state.copyWith( status: UpdateStatus.available, currentVersion: currentVersion, latestVersion: latestVersion, downloadUrl: apk['browser_download_url'] as String?, ); return; } } state = state.copyWith( status: UpdateStatus.upToDate, currentVersion: currentVersion, latestVersion: latestVersion, ); } catch (e) { state = state.copyWith( status: UpdateStatus.error, errorMessage: e.toString(), ); } } Future downloadAndInstall() async { if (state.downloadUrl == null) return; // Android 8+ requires explicit per-app "Install unknown apps" approval // beyond the manifest declaration. Check and redirect to Settings if needed. final installPermission = await Permission.requestInstallPackages.status; if (!installPermission.isGranted) { final result = await Permission.requestInstallPackages.request(); if (!result.isGranted) { state = state.copyWith( status: UpdateStatus.error, errorMessage: 'Grant "Install unknown apps" permission for Fabled in Settings, then try again.', ); return; } } state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); try { final dir = await getExternalStorageDirectory() ?? await getTemporaryDirectory(); final path = '${dir.path}/fabled_update.apk'; await Dio().download( state.downloadUrl!, path, onReceiveProgress: (received, total) { if (total > 0) { state = state.copyWith(downloadProgress: received / total); } }, ); final result = await OpenFile.open( path, type: 'application/vnd.android.package-archive', ); if (result.type == ResultType.done) { // Installer launched — reset to idle so the dialog closes naturally. state = const UpdateState(); } else { state = state.copyWith( status: UpdateStatus.error, errorMessage: 'Could not open installer: ${result.message}', ); } } catch (e) { state = state.copyWith( status: UpdateStatus.error, errorMessage: e.toString(), ); } } void dismiss() => state = const UpdateState(); bool _isNewer(String latest, String current) { try { final l = latest.split('.').map(int.parse).toList(); final c = current.split('.').map(int.parse).toList(); for (var i = 0; i < l.length && i < c.length; i++) { if (l[i] > c[i]) return true; if (l[i] < c[i]) return false; } return l.length > c.length; } catch (_) { return false; } } } final updateProvider = NotifierProvider(UpdateNotifier.new);