tasq/lib/services/app_update_service.dart

137 lines
4.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}
}