355 lines
13 KiB
Dart
355 lines
13 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: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 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;
|
||
|
||
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 re‑thrown; 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.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) {
|
||
// 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;
|
||
}
|
||
});
|
||
}
|
||
}
|