tasq/lib/services/app_update_service.dart

355 lines
13 KiB
Dart
Raw Permalink 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:pub_semver/pub_semver.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.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;
static const MethodChannel _platform = MethodChannel('tasq/ota');
// Smoothed progress state — the service smooths raw percent updates into
// small animated increments so UI progress displays feel responsive.
double _smoothedProgress = 0.0;
double _smoothedTarget = 0.0;
Timer? _smoothedTimer;
Future<void> _openUnknownAppSourcesSettings() async {
try {
await _platform.invokeMethod('openUnknownSources');
} on PlatformException {
// ignore if platform method is not implemented
}
}
/// 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 {
// Request all rows so we can compare semantic versions locally. The
// Supabase client returns a List for select() (maybeSingle returns a Map).
final data = await _client.from('app_versions').select();
// The Supabase client returns a PostgrestList (List-like). Convert to
// a Dart List and pick the highest semantic version.
final list = data as List;
if (list.isEmpty) return null;
Version? best;
Map<String, dynamic>? bestRow;
for (final item in list) {
if (item is Map) {
final map = Map<String, dynamic>.from(item);
final v = map['version_code']?.toString() ?? '';
try {
final parsed = Version.parse(v);
if (best == null || parsed > best) {
best = parsed;
bestRow = map;
}
} catch (_) {
// ignore non-semver rows
}
}
}
if (bestRow != null) return AppVersion.fromMap(bestRow);
// fallback to the first row
return AppVersion.fromMap(Map<String, dynamic>.from(list.first));
} catch (e) {
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 {
// only run on Android devices; web and other platforms skip
if (!Platform.isAndroid) {
return AppUpdateInfo(
currentBuildNumber: '',
latestVersion: null,
isUpdateAvailable: false,
isForceUpdate: false,
);
}
final pkg = await PackageInfo.fromPlatform();
// prefer the version name which commonly holds semantic versions like
// "1.2.3". fall back to buildNumber when version is empty.
final ver = pkg.version.trim();
final build = pkg.buildNumber.trim();
final currentVersion = ver.isNotEmpty ? ver : build;
final serverVersion = await _fetchLatestVersion();
if (serverVersion == null) {
return AppUpdateInfo(
currentBuildNumber: currentVersion,
latestVersion: null,
isUpdateAvailable: false,
isForceUpdate: false,
);
}
// compare using semantic versioning where possible
Version safeParse(String s) {
try {
return Version.parse(s);
} catch (_) {
return Version(0, 0, 0);
}
}
final vCurrent = safeParse(currentVersion);
final vLatest = safeParse(serverVersion.versionCode);
final vMin = safeParse(serverVersion.minVersionRequired);
final available = vCurrent < vLatest;
final force = vCurrent < vMin;
return AppUpdateInfo(
currentBuildNumber: currentVersion,
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) {
// Open the system settings page so the user can enable "Install unknown apps"
try {
await _openUnknownAppSourcesSettings();
} catch (_) {}
throw Exception('installation permission denied; opened settings');
}
// Wrap OTA download in a retry loop to handle transient network issues
// (socket closed / timeout) that the underlying okhttp client may throw.
const int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
final completer = Completer<void>();
StreamSubscription<OtaEvent>? sub;
try {
debugPrint('OTA: starting attempt $attempt/$maxAttempts');
sub = OtaUpdate()
.execute(
downloadUrl,
destinationFilename:
'app_${DateTime.now().millisecondsSinceEpoch}.apk',
)
.listen(
(event) {
final statusStr = event.status.toString();
// DOWNLOADING usually reports percent in event.value
if (statusStr.endsWith('DOWNLOADING')) {
final pct = double.tryParse(event.value ?? '0') ?? 0;
try {
_setSmoothedProgress(pct / 100.0, onProgress);
} catch (_) {}
} else if (statusStr.endsWith('INSTALLING')) {
if (!completer.isCompleted) completer.complete();
} else if (statusStr.toLowerCase().contains('cancel')) {
if (!completer.isCompleted) {
completer.completeError(
Exception('OTA cancelled: ${event.value ?? ''}'),
);
}
} else if (statusStr.toLowerCase().contains('permission') ||
(event.value ?? '').toLowerCase().contains('permission')) {
try {
_openUnknownAppSourcesSettings();
} catch (_) {}
if (!completer.isCompleted) {
completer.completeError(
Exception(
'OTA permission not granted: ${event.value ?? ''}',
),
);
}
} else {
// Only treat events containing known network/error keywords as fatal
final val = (event.value ?? '').toLowerCase();
if (val.contains('timeout') ||
val.contains('socket') ||
val.contains('closed') ||
statusStr.toLowerCase().contains('error')) {
if (!completer.isCompleted) {
completer.completeError(
Exception(
'OTA update failed ($statusStr): ${event.value ?? ''}',
),
);
}
} else {
// Unknown non-error status — ignore to avoid premature failure.
}
}
},
onError: (e) {
if (!completer.isCompleted) completer.completeError(e);
},
);
await completer.future;
debugPrint('OTA: attempt $attempt succeeded');
return;
} catch (e, st) {
debugPrint('OTA: attempt $attempt failed: $e\n$st');
// If last attempt, rethrow so caller can handle/report it.
if (attempt == maxAttempts) rethrow;
final msg = e.toString();
// Retry only on network/timeouts/socket-related failures.
if (msg.toLowerCase().contains('timeout') ||
msg.toLowerCase().contains('socket') ||
msg.toLowerCase().contains('closed')) {
final backoff = Duration(seconds: 2 * attempt);
debugPrint('OTA: retrying after ${backoff.inSeconds}s backoff');
await Future.delayed(backoff);
continue; // next attempt
}
// For non-network errors, do not retry.
rethrow;
} finally {
if (sub != null) await sub.cancel();
_smoothedTimer?.cancel();
_smoothedTimer = null;
}
}
// If we reach here all ota_update attempts failed; try a Dart HTTP download
// to a temp file and ask native code to install it via FileProvider.
try {
debugPrint('OTA: falling back to Dart HTTP download');
final filePath = await _downloadApkToFile(downloadUrl, onProgress);
try {
await _platform.invokeMethod('installApk', {'path': filePath});
} on PlatformException catch (e) {
throw Exception('Failed to invoke native installer: ${e.message}');
}
} catch (e) {
rethrow;
}
}
Future<String> _downloadApkToFile(
String url,
void Function(double) onProgress,
) async {
final uri = Uri.parse(url);
final client = HttpClient();
client.connectionTimeout = const Duration(minutes: 5);
final req = await client.getUrl(uri);
final resp = await req.close();
if (resp.statusCode != 200) {
throw Exception('Download failed: HTTP ${resp.statusCode}');
}
final tempDir = Directory.systemTemp.createTempSync('ota_');
final file = File(
'${tempDir.path}/app_${DateTime.now().millisecondsSinceEpoch}.apk',
);
final sink = file.openWrite();
final contentLength = resp.contentLength;
int received = 0;
try {
await for (final chunk in resp) {
received += chunk.length;
sink.add(chunk);
if (contentLength > 0) {
try {
_setSmoothedProgress(received / contentLength, onProgress);
} catch (_) {}
}
}
} finally {
await sink.close();
client.close(force: true);
_smoothedTimer?.cancel();
_smoothedTimer = null;
_smoothedProgress = 0.0;
}
return file.path;
}
void _setSmoothedProgress(double target, void Function(double) onProgress) {
// Clamp
target = target.clamp(0.0, 1.0);
// If target is less than or equal to current, update immediately.
if (target <= _smoothedProgress) {
_smoothedTarget = target;
_smoothedProgress = target;
try {
onProgress(_smoothedProgress);
} catch (_) {}
return;
}
// Otherwise, set the target and ensure a single periodic timer is running
// which will nudge _smoothedProgress toward _smoothedTarget. This avoids
// canceling/creating timers on every small update which caused batching.
_smoothedTarget = target;
if (_smoothedTimer != null) return;
const tickMs = 100;
_smoothedTimer = Timer.periodic(const Duration(milliseconds: tickMs), (t) {
// Move a fraction toward the target for a smooth ease-out feel.
final remaining = _smoothedTarget - _smoothedProgress;
final step = (remaining * 0.5).clamp(0.002, 0.05);
_smoothedProgress = (_smoothedProgress + step).clamp(0.0, 1.0);
try {
onProgress(_smoothedProgress);
} catch (_) {}
// Stop when we're very close to the target.
if ((_smoothedTarget - _smoothedProgress) < 0.001) {
_smoothedProgress = _smoothedTarget;
try {
onProgress(_smoothedProgress);
} catch (_) {}
_smoothedTimer?.cancel();
_smoothedTimer = null;
}
});
}
}