70a3279192
Update provider: auto-downloads APK in background after finding a newer
version, prompts via snackbar only when ready, cleans up old APKs on
startup. Replaces modal dialog with dismissible snackbar.
Chat bubble: resolve relative image URLs (/api/images/{id}) against
server base URL with auth cookies so search_images results render on
the phone app.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
6.5 KiB
Dart
234 lines
6.5 KiB
Dart
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<UpdateState> {
|
|
@override
|
|
UpdateState build() => const UpdateState();
|
|
|
|
Future<void> 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<dynamic>? ?? [])
|
|
.cast<Map<String, dynamic>>();
|
|
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<void> _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<void> 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<void> cleanup() async {
|
|
final dir = await _apkDir();
|
|
await _cleanupApks(dir);
|
|
}
|
|
|
|
void dismiss() => state = const UpdateState();
|
|
|
|
Future<Directory> _apkDir() async {
|
|
return await getExternalStorageDirectory() ??
|
|
await getTemporaryDirectory();
|
|
}
|
|
|
|
Future<void> _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, UpdateState>(UpdateNotifier.new);
|