import 'dart:io'; 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, downloading, readyToInstall, upToDate, error, } class UpdateState { final UpdateStatus status; final String? currentVersion; final String? latestVersion; final String? downloadUrl; final double downloadProgress; final String? errorMessage; final String? apkPath; const UpdateState({ this.status = UpdateStatus.idle, this.currentVersion, this.latestVersion, this.downloadUrl, this.downloadProgress = 0.0, this.errorMessage, this.apkPath, }); UpdateState copyWith({ UpdateStatus? status, String? currentVersion, String? latestVersion, String? downloadUrl, double? downloadProgress, String? errorMessage, String? apkPath, }) => 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, apkPath: apkPath ?? this.apkPath, ); } class UpdateNotifier extends Notifier { @override UpdateState build() => const UpdateState(); Future check(String repoUrl) async { state = state.copyWith(status: UpdateStatus.checking); try { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = '${packageInfo.version}.${packageInfo.buildNumber}'; 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) { final downloadUrl = apk['browser_download_url'] as String?; state = state.copyWith( currentVersion: currentVersion, latestVersion: latestVersion, downloadUrl: downloadUrl, ); await _downloadInBackground(); return; } } state = state.copyWith( status: UpdateStatus.upToDate, currentVersion: currentVersion, latestVersion: latestVersion, ); } catch (e) { state = state.copyWith( status: UpdateStatus.error, errorMessage: e.toString(), ); } } Future _downloadInBackground() async { if (state.downloadUrl == null) return; state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); try { final dir = await _apkDir(); await _cleanupApks(dir); final path = '${dir.path}/fabled_${state.latestVersion}.apk'; await Dio().download( state.downloadUrl!, path, onReceiveProgress: (received, total) { if (total > 0) { state = state.copyWith(downloadProgress: received / total); } }, ); final file = File(path); if (!await file.exists() || await file.length() == 0) { state = state.copyWith( status: UpdateStatus.error, errorMessage: 'Download failed — file is empty', ); return; } state = state.copyWith( status: UpdateStatus.readyToInstall, apkPath: path, downloadProgress: 1.0, ); } catch (e) { state = state.copyWith( status: UpdateStatus.error, errorMessage: e.toString(), ); } } Future install() async { final path = state.apkPath; if (path == null) return; 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; } } try { final result = await OpenFile.open( path, type: 'application/vnd.android.package-archive', ); if (result.type == ResultType.done) { 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(), ); } } /// Remove any previously cached APKs. Future cleanup() async { final dir = await _apkDir(); await _cleanupApks(dir); } void dismiss() => state = const UpdateState(); Future _apkDir() async { return await getExternalStorageDirectory() ?? await getTemporaryDirectory(); } Future _cleanupApks(Directory dir) async { try { final entries = dir.listSync(); for (final entry in entries) { if (entry is File && entry.path.endsWith('.apk')) { await entry.delete(); } } } catch (_) {} } 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);