9f1d2317af
Android 8+ requires canRequestPackageInstalls() to return true even when REQUEST_INSTALL_PACKAGES is declared in the manifest. Add permission_handler and check/request Permission.requestInstallPackages before downloading; redirects user to Settings if not granted and shows a clear error message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
5.6 KiB
Dart
180 lines
5.6 KiB
Dart
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<UpdateState> {
|
|
@override
|
|
UpdateState build() => const UpdateState();
|
|
|
|
/// [repoUrl] is the Forgejo repo page URL, e.g.
|
|
/// "https://git.example.com/user/fabled_app"
|
|
Future<void> 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<dynamic>? ?? [])
|
|
.cast<Map<String, dynamic>>();
|
|
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<void> 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, UpdateState>(UpdateNotifier.new);
|