A more robust self hosted OTA updates implementation

This commit is contained in:
2026-03-13 07:15:28 +08:00
parent 9267ebee2c
commit 9bbaf67fef
8 changed files with 852 additions and 164 deletions
+237 -42
View File
@@ -5,6 +5,8 @@ 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';
@@ -25,21 +27,56 @@ class 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 {
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);
// 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 so callers can handle/log
rethrow;
}
}
@@ -60,7 +97,11 @@ class AppUpdateService {
}
final pkg = await PackageInfo.fromPlatform();
final currentVersion = pkg.buildNumber.trim();
// 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();
@@ -114,46 +155,200 @@ class AppUpdateService {
final status = await Permission.requestInstallPackages.request();
if (!status.isGranted) {
throw Exception('installation permission denied');
// Open the system settings page so the user can enable "Install unknown apps"
try {
await _openUnknownAppSourcesSettings();
} catch (_) {}
throw Exception('installation permission denied; opened settings');
}
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:
// 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;
onProgress(pct / 100.0);
break;
case OtaStatus.INSTALLING:
try {
_setSmoothedProgress(pct / 100.0, onProgress);
} catch (_) {}
} else if (statusStr.endsWith('INSTALLING')) {
if (!completer.isCompleted) completer.complete();
break;
default:
// treat all other statuses as failure; they provide a string
// description in `event.value`.
} else if (statusStr.toLowerCase().contains('cancel')) {
if (!completer.isCompleted) {
completer.completeError(
Exception('OTA update failed: ${event.status}'),
Exception('OTA cancelled: ${event.value ?? ''}'),
);
}
break;
}
},
onError: (e) {
if (!completer.isCompleted) completer.completeError(e);
},
);
await completer.future;
} finally {
if (sub != null) await sub.cancel();
} 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;
}
});
}
}