A more robust self hosted OTA updates implementation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user