OTA Update attempt

This commit is contained in:
2026-03-11 22:28:38 +08:00
parent f8c79acbbc
commit 0bd2a94ece
12 changed files with 687 additions and 3 deletions
+136
View File
@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:io';
import 'package:ota_update/ota_update.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/app_version.dart';
/// Handles the business logic for fetching version information from the
/// ``app_versions`` table and downloading/installing a replacement APK.
///
/// This class is intentionally *not* coupled to any UI framework so that it can
/// be unittested easily. Widgets should only call into the public methods and
/// display whatever progress / error states they need.
class AppUpdateService {
AppUpdateService._();
/// Public singleton that other parts of the app can depend on. The private
/// constructor prevents instantiation from tests; they should mock the
/// individual methods instead.
static final instance = AppUpdateService._();
final SupabaseClient _client = Supabase.instance.client;
/// Fetches the most recent record from ``app_versions``. The table is
/// expected to contain a single row per release; we use the highest
/// ``version_code`` so that historical entries may be kept if desired.
Future<AppVersion?> _fetchLatestVersion() async {
try {
final Map<String, dynamic>? data = await _client
.from('app_versions')
.select()
.order('version_code', ascending: false)
.limit(1)
.maybeSingle();
if (data == null) return null;
return AppVersion.fromMap(data);
} catch (e) {
// rethrow so callers can handle/log
rethrow;
}
}
/// Compare the running build number with the latest data from the server and
/// return an [AppUpdateInfo] object describing the relationship. Errors
/// (network down, no supabase instance) are simply rethrown; the caller can
/// decide whether to ignore them or surface them to the user.
Future<AppUpdateInfo> checkForUpdate() async {
final pkg = await PackageInfo.fromPlatform();
final currentBuild = int.tryParse(pkg.buildNumber) ?? 0;
final serverVersion = await _fetchLatestVersion();
if (serverVersion == null) {
// table empty nothing to do
return AppUpdateInfo(
currentBuildNumber: currentBuild,
latestVersion: null,
isUpdateAvailable: false,
isForceUpdate: false,
);
}
final available = currentBuild < serverVersion.versionCode;
final force = currentBuild < serverVersion.minVersionRequired;
return AppUpdateInfo(
currentBuildNumber: currentBuild,
latestVersion: serverVersion,
isUpdateAvailable: available,
isForceUpdate: force,
);
}
/// Downloads and installs the APK at [downloadUrl] using `ota_update`.
/// Progress is surfaced via [onProgress] (0.01.0). The returned future
/// completes once the download has finished and installation has started.
///
/// Throws on permission denial or when the OTA stream reports an error.
Future<void> downloadAndInstallApk(
String downloadUrl, {
required void Function(double) onProgress,
}) async {
if (!Platform.isAndroid) {
throw UnsupportedError(
'Inapp updates currently only supported on Android',
);
}
final status = await Permission.requestInstallPackages.request();
if (!status.isGranted) {
throw Exception('installation permission denied');
}
final completer = Completer<void>();
StreamSubscription<OtaEvent>? sub;
try {
sub = OtaUpdate()
.execute(
downloadUrl,
destinationFilename:
'app_${DateTime.now().millisecondsSinceEpoch}.apk',
)
.listen(
(event) {
switch (event.status) {
case OtaStatus.DOWNLOADING:
final pct = double.tryParse(event.value ?? '0') ?? 0;
onProgress(pct / 100.0);
break;
case OtaStatus.INSTALLING:
if (!completer.isCompleted) completer.complete();
break;
default:
// treat all other statuses as failure; they provide a string
// description in `event.value`.
if (!completer.isCompleted) {
completer.completeError(
Exception('OTA update failed: ${event.status}'),
);
}
break;
}
},
onError: (e) {
if (!completer.isCompleted) completer.completeError(e);
},
);
await completer.future;
} finally {
if (sub != null) await sub.cancel();
}
}
}