137 lines
4.7 KiB
Dart
137 lines
4.7 KiB
Dart
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 unit‑tested 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 re‑thrown; 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.0–1.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(
|
||
'In‑app 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();
|
||
}
|
||
}
|
||
}
|