From 9bbaf67fef7450c142a02ce79f979442389e40ad Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Fri, 13 Mar 2026 07:15:28 +0800 Subject: [PATCH] A more robust self hosted OTA updates implementation --- android/app/src/main/AndroidManifest.xml | 10 + .../kotlin/com/example/tasq/MainActivity.kt | 46 ++- .../main/res/xml/ota_update_file_paths.xml | 9 + lib/main.dart | 70 ++++- lib/screens/admin/app_update_screen.dart | 282 +++++++++++++----- lib/screens/update_check_screen.dart | 127 ++++++++ lib/services/app_update_service.dart | 279 ++++++++++++++--- lib/widgets/update_dialog.dart | 193 ++++++++++-- 8 files changed, 852 insertions(+), 164 deletions(-) create mode 100644 android/app/src/main/res/xml/ota_update_file_paths.xml create mode 100644 lib/screens/update_check_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3d8fcdf0..77118dae 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -71,6 +71,16 @@ android:name="id.flutter.flutter_background_service.BackgroundService" android:foregroundServiceType="location" tools:replace="android:foregroundServiceType" /> + + + + + + + + + + diff --git a/lib/main.dart b/lib/main.dart index c5c7d5f6..6bee3a02 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,12 +4,14 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:pdfrx/pdfrx.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'screens/update_check_screen.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'firebase_options.dart'; // removed unused imports import 'app.dart'; +import 'theme/app_theme.dart'; import 'providers/notifications_provider.dart'; import 'providers/notification_navigation_provider.dart'; import 'utils/app_time.dart'; @@ -19,6 +21,7 @@ import 'services/notification_service.dart'; import 'services/notification_bridge.dart'; import 'services/background_location_service.dart'; import 'services/app_update_service.dart'; +import 'models/app_version.dart'; import 'widgets/update_dialog.dart'; import 'utils/navigation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -593,29 +596,66 @@ Future main() async { runApp( UncontrolledProviderScope( container: _globalProviderContainer, - child: const NotificationBridge(child: TasqApp()), + child: const UpdateCheckWrapper(), ), ); - // perform update check once the first frame has rendered; errors are - // intentionally swallowed so a network outage doesn't block startup. - WidgetsBinding.instance.addPostFrameCallback((_) async { + // Post-startup registration removed: token registration is handled + // centrally in the auth state change listener to avoid duplicate inserts. +} + +/// Wrapper shown at app launch; performs update check and displays +/// [UpdateCheckingScreen] until complete. +class UpdateCheckWrapper extends StatefulWidget { + const UpdateCheckWrapper({super.key}); + + @override + State createState() => _UpdateCheckWrapperState(); +} + +class _UpdateCheckWrapperState extends State { + bool _done = false; + AppUpdateInfo? _info; + + @override + void initState() { + super.initState(); + _performCheck(); + } + + Future _performCheck() async { try { - final info = await AppUpdateService.instance.checkForUpdate(); - if (info.isUpdateAvailable) { - showDialog( - context: globalNavigatorKey.currentContext!, - barrierDismissible: !info.isForceUpdate, - builder: (_) => UpdateDialog(info: info), - ); - } + _info = await AppUpdateService.instance.checkForUpdate(); } catch (e) { debugPrint('update check failed: $e'); } - }); + if (mounted) { + setState(() => _done = true); + if (_info?.isUpdateAvailable == true) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showDialog( + context: globalNavigatorKey.currentContext!, + barrierDismissible: !_info!.isForceUpdate, + builder: (_) => UpdateDialog(info: _info!), + ); + }); + } + } + } - // Post-startup registration removed: token registration is handled - // centrally in the auth state change listener to avoid duplicate inserts. + @override + Widget build(BuildContext context) { + if (!_done) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: ThemeMode.system, + home: const UpdateCheckingScreen(), + ); + } + return const NotificationBridge(child: TasqApp()); + } } class NotificationSoundObserver extends ProviderObserver { diff --git a/lib/screens/admin/app_update_screen.dart b/lib/screens/admin/app_update_screen.dart index 996c3b65..28cab8ae 100644 --- a/lib/screens/admin/app_update_screen.dart +++ b/lib/screens/admin/app_update_screen.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; /// A simple admin-only web page allowing the upload of a new APK and the /// associated metadata. After the APK is uploaded to the "apk_updates" @@ -23,14 +25,20 @@ class _AppUpdateScreenState extends ConsumerState { final _versionController = TextEditingController(); final _minController = TextEditingController(); final _notesController = TextEditingController(); + quill.QuillController? _quillController; + // We store release notes as plain text for compatibility; existing + // Quill delta JSON in the database will be parsed and displayed by + // the Android update dialog renderer. Uint8List? _apkBytes; String? _apkName; bool _isUploading = false; - double _progress = 0.0; // 0..1 + double? _progress; // null => indeterminate, otherwise 0..1 + double _realProgress = 0.0; // actual numeric progress for display (0..1) String? _eta; final List _logs = []; Timer? _progressTimer; + Timer? _startDelayTimer; String? _error; @override @@ -70,12 +78,42 @@ class _AppUpdateScreenState extends ConsumerState { _versionController.text = bestRow['version_code']?.toString() ?? ''; _minController.text = bestRow['min_version_required']?.toString() ?? ''; - _notesController.text = bestRow['release_notes'] ?? ''; + final rn = bestRow['release_notes'] ?? ''; + if (rn is String && rn.trim().isNotEmpty) { + try { + final parsed = jsonDecode(rn); + if (parsed is List) { + final doc = quill.Document.fromJson(parsed); + _quillController = quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } else { + _notesController.text = rn.toString(); + } + } catch (_) { + _notesController.text = rn.toString(); + } + } } } else if (rows is Map) { _versionController.text = rows['version_code']?.toString() ?? ''; _minController.text = rows['min_version_required']?.toString() ?? ''; - _notesController.text = rows['release_notes'] ?? ''; + final rn = rows['release_notes'] ?? ''; + try { + final parsed = jsonDecode(rn); + if (parsed is List) { + final doc = quill.Document.fromJson(parsed); + _quillController = quill.QuillController( + document: doc, + selection: const TextSelection.collapsed(offset: 0), + ); + } else { + _notesController.text = rn as String; + } + } catch (_) { + _notesController.text = rn as String; + } } } catch (_) {} } @@ -110,11 +148,16 @@ class _AppUpdateScreenState extends ConsumerState { final vcode = _versionController.text.trim(); final minReq = _minController.text.trim(); - final notes = _notesController.text; + String notes; + if (_quillController != null) { + notes = jsonEncode(_quillController!.document.toDelta().toJson()); + } else { + notes = _notesController.text; + } setState(() { _isUploading = true; - _progress = 0.0; + _progress = null; // show indeterminate while we attempt to start _eta = null; _logs.clear(); _error = null; @@ -134,17 +177,28 @@ class _AppUpdateScreenState extends ConsumerState { final path = filename; _logs.add('Starting upload to bucket apk_updates, path: $path'); final stopwatch = Stopwatch()..start(); - // we cannot track real progress from the SDK, so fake a linear update + // Show an indeterminate bar briefly while the upload is negotiating; + // after a short delay, switch to a determinate (fake) progress based on + // the payload size so the UI feels responsive. final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30); - _progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) { - final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0; + _startDelayTimer?.cancel(); + _startDelayTimer = Timer(const Duration(milliseconds: 700), () { + // keep the bar indeterminate, but start updating the numeric progress setState(() { - _progress = (elapsed / estimatedSeconds).clamp(0.0, 0.9); - final remaining = (estimatedSeconds - elapsed).clamp( - 0.0, - double.infinity, - ); - _eta = '${remaining.toStringAsFixed(1)}s'; + _progress = null; + _realProgress = 0.0; + }); + _progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) { + final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0; + final pct = (elapsed / estimatedSeconds).clamp(0.0, 0.95); + setState(() { + _realProgress = pct; + final remaining = (estimatedSeconds - elapsed).clamp( + 0.0, + double.infinity, + ); + _eta = '${remaining.toStringAsFixed(1)}s'; + }); }); }); @@ -168,7 +222,7 @@ class _AppUpdateScreenState extends ConsumerState { } } setState(() { - _progress = 0.95; + _realProgress = 0.95; }); // retrieve public URL; various SDK versions return different structures dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path); @@ -209,6 +263,7 @@ class _AppUpdateScreenState extends ConsumerState { }, onConflict: 'version_code'); await client.from('app_versions').delete().neq('version_code', vcode); setState(() { + _realProgress = 1.0; _progress = 1.0; }); if (mounted) { @@ -220,6 +275,8 @@ class _AppUpdateScreenState extends ConsumerState { _logs.add('Error during upload: $e'); setState(() => _error = e.toString()); } finally { + _startDelayTimer?.cancel(); + _startDelayTimer = null; _progressTimer?.cancel(); _progressTimer = null; setState(() => _isUploading = false); @@ -236,72 +293,143 @@ class _AppUpdateScreenState extends ConsumerState { return Scaffold( appBar: AppBar(title: const Text('APK Update Uploader')), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - controller: _versionController, - decoration: const InputDecoration( - labelText: 'Version (e.g. 1.2.3)', - ), - keyboardType: TextInputType.text, - validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - TextFormField( - controller: _minController, - decoration: const InputDecoration( - labelText: 'Min Version (e.g. 0.1.1)', - ), - keyboardType: TextInputType.text, - validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, - ), - TextFormField( - controller: _notesController, - decoration: const InputDecoration(labelText: 'Release Notes'), - maxLines: 3, - ), - const SizedBox(height: 12), - Row( - children: [ - ElevatedButton( - onPressed: _isUploading ? null : _pickApk, - child: const Text('Select APK'), - ), - const SizedBox(width: 8), - Expanded(child: Text(_apkName ?? 'no file chosen')), - ], - ), - const SizedBox(height: 20), - if (_isUploading) ...[ - LinearProgressIndicator(value: _progress), - if (_eta != null) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text('ETA: $_eta'), - ), - const SizedBox(height: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 150), - child: ListView( - shrinkWrap: true, - children: _logs.map((l) => Text(l)).toList(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _versionController, + decoration: const InputDecoration( + labelText: 'Version (e.g. 1.2.3)', + ), + keyboardType: TextInputType.text, + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + TextFormField( + controller: _minController, + decoration: const InputDecoration( + labelText: 'Min Version (e.g. 0.1.1)', + ), + keyboardType: TextInputType.text, + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + // Release notes: use Quill rich editor when available (web) + if (_quillController != null || kIsWeb) ...[ + // toolbar omitted (package version may not export it) + const SizedBox(height: 8), + SizedBox( + height: 200, + child: quill.QuillEditor.basic( + controller: + _quillController ?? + quill.QuillController.basic(), + ), + ), + ] else ...[ + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Release Notes', + ), + maxLines: 3, + ), + ], + const SizedBox(height: 12), + Row( + children: [ + ElevatedButton( + onPressed: _isUploading ? null : _pickApk, + child: const Text('Select APK'), + ), + const SizedBox(width: 8), + Expanded(child: Text(_apkName ?? 'no file chosen')), + ], + ), + const SizedBox(height: 20), + if (_isUploading) ...[ + // keep the animated indeterminate bar while showing the + // numeric progress percentage on top (smoothly animated). + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: double.infinity, + child: LinearProgressIndicator(value: _progress), + ), + // Smoothly animate the displayed percentage so updates feel fluid + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: _realProgress), + duration: const Duration(milliseconds: 300), + builder: (context, value, child) { + final pct = (value * 100).clamp(0.0, 100.0); + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surface.withAlpha(153), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}% ', + style: Theme.of( + context, + ).textTheme.bodyMedium, + ), + ); + }, + ), + ], + ), + if (_eta != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text('ETA: $_eta'), + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 150), + child: ListView( + shrinkWrap: true, + children: _logs.map((l) => Text(l)).toList(), + ), + ), + const SizedBox(height: 12), + ], + if (_error != null) + Text( + _error!, + style: const TextStyle(color: Colors.red), + ), + ElevatedButton( + onPressed: _isUploading ? null : _submit, + child: _isUploading + ? const CircularProgressIndicator() + : const Text('Save'), + ), + ], ), ), - const SizedBox(height: 12), - ], - if (_error != null) - Text(_error!, style: const TextStyle(color: Colors.red)), - ElevatedButton( - onPressed: _isUploading ? null : _submit, - child: _isUploading - ? const CircularProgressIndicator() - : const Text('Save'), ), - ], + ), ), ), ), diff --git a/lib/screens/update_check_screen.dart b/lib/screens/update_check_screen.dart new file mode 100644 index 00000000..e086acb8 --- /dev/null +++ b/lib/screens/update_check_screen.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// Simple binary rain animation with a label for the update check splash. +class UpdateCheckingScreen extends StatefulWidget { + const UpdateCheckingScreen({super.key}); + + @override + State createState() => _UpdateCheckingScreenState(); +} + +class _UpdateCheckingScreenState extends State { + static const int cols = 20; + final List<_Drop> _drops = []; + Timer? _timer; + final Random _rng = Random(); + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(milliseconds: 50), (_) { + _tick(); + }); + } + + void _tick() { + setState(() { + if (_rng.nextDouble() < 0.3 || _drops.isEmpty) { + _drops.add( + _Drop( + col: _rng.nextInt(cols), + y: 0.0, + speed: _rng.nextDouble() * 4 + 2, + ), + ); + } + _drops.removeWhere((d) => d.y > MediaQuery.of(context).size.height); + for (final d in _drops) { + d.y += d.speed; + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: cs.surface, + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 40), + Hero( + tag: 'tasq-logo', + child: Image.asset('assets/tasq_ico.png', height: 80, width: 80), + ), + const SizedBox(height: 24), + Expanded( + child: CustomPaint( + size: Size.infinite, + painter: _BinaryRainPainter(_drops, cols, cs.primary), + ), + ), + const SizedBox(height: 24), + Text( + 'Checking for updates...', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: cs.onSurface.withAlpha((0.75 * 255).round()), + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ); + } +} + +class _Drop { + int col; + double y; + double speed; + _Drop({required this.col, required this.y, required this.speed}); +} + +class _BinaryRainPainter extends CustomPainter { + static const double fontSize = 16; + final List<_Drop> drops; + final int cols; + final Color textColor; + + _BinaryRainPainter(this.drops, this.cols, this.textColor); + + @override + void paint(Canvas canvas, Size size) { + // paint variable not needed when drawing text + final textStyle = TextStyle( + color: textColor, + fontSize: fontSize, + fontFeatures: const [FontFeature.tabularFigures()], + ); + final cellW = size.width / cols; + + for (final d in drops) { + final text = (Random().nextBool() ? '1' : '0'); + final tp = TextPainter( + text: TextSpan(text: text, style: textStyle), + textAlign: TextAlign.center, + textDirection: TextDirection.ltr, + )..layout(); + final x = d.col * cellW + (cellW - tp.width) / 2; + final y = d.y; + tp.paint(canvas, Offset(x, y)); + } + } + + @override + bool shouldRepaint(covariant _BinaryRainPainter old) => true; +} diff --git a/lib/services/app_update_service.dart b/lib/services/app_update_service.dart index 3b557f2e..b2f92e37 100644 --- a/lib/services/app_update_service.dart +++ b/lib/services/app_update_service.dart @@ -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 _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 _fetchLatestVersion() async { try { - final Map? 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? bestRow; + for (final item in list) { + if (item is Map) { + final map = Map.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.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(); - StreamSubscription? 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(); + StreamSubscription? 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 _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; + } + }); + } } diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 1768317d..43f9f431 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +// We render Quill deltas here without depending on the flutter_quill editor +// API to avoid analyzer/API mismatches; we support common inline styles. import '../models/app_version.dart'; import '../services/app_update_service.dart'; @@ -16,9 +20,11 @@ class UpdateDialog extends StatefulWidget { } class _UpdateDialogState extends State { - double _progress = 0; + double _realProgress = 0.0; bool _downloading = false; bool _failed = false; + List? _notesDelta; + String? _notesPlain; Future _startDownload() async { setState(() { @@ -29,7 +35,7 @@ class _UpdateDialogState extends State { try { await AppUpdateService.instance.downloadAndInstallApk( widget.info.latestVersion!.downloadUrl, - onProgress: (p) => setState(() => _progress = p), + onProgress: (p) => setState(() => _realProgress = p), ); // once the installer launches the app is likely to be stopped; we // don't pop the dialog explicitly. @@ -49,6 +55,20 @@ class _UpdateDialogState extends State { Widget build(BuildContext context) { final notes = widget.info.latestVersion?.releaseNotes ?? ''; + // parse release notes into a Quill delta list if possible + if (_notesDelta == null && _notesPlain == null && notes.isNotEmpty) { + try { + final parsed = jsonDecode(notes); + if (parsed is List) { + _notesDelta = parsed; + } else { + _notesPlain = notes; + } + } catch (_) { + _notesPlain = notes; + } + } + // WillPopScope is deprecated in newer Flutter versions but PopScope // has a different API; to avoid breaking changes we continue to use the // old widget and suppress the warning. @@ -65,19 +85,52 @@ class _UpdateDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (notes.isNotEmpty) ...[Text(notes), const SizedBox(height: 12)], - if (_downloading) - Column( - children: [ - LinearProgressIndicator(value: _progress), - const SizedBox(height: 8), - Text('${(_progress * 100).toStringAsFixed(0)}%'), - ], + // Render release notes: prefer Quill delta if available + if (_notesDelta != null) + SizedBox( + height: 250, + child: SingleChildScrollView( + child: RichText( + text: _deltaToTextSpan( + _notesDelta!, + Theme.of(context).textTheme.bodyMedium, + ), + ), + ), ), + if (_notesDelta == null && + _notesPlain != null && + _notesPlain!.isNotEmpty) + SizedBox( + height: 250, + child: SingleChildScrollView( + child: SelectableText(_notesPlain!), + ), + ), + const SizedBox(height: 12), + if (_downloading) ...[ + SizedBox( + width: double.infinity, + child: const LinearProgressIndicator(value: null), + ), + const SizedBox(height: 8), + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: _realProgress), + duration: const Duration(milliseconds: 300), + builder: (context, value, child) { + return Text( + '${(value * 100).toStringAsFixed(value * 100 >= 10 ? 0 : 1)}%', + ); + }, + ), + ], if (_failed) - const Text( - 'An error occurred while downloading. Please try again.', - style: TextStyle(color: Colors.red), + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + 'An error occurred while downloading. Please try again.', + style: TextStyle(color: Colors.red), + ), ), ], ), @@ -87,26 +140,108 @@ class _UpdateDialogState extends State { } List _buildActions() { - if (_downloading) { - // don't show any actions while the apk is being fetched - return []; - } + final actions = []; - if (widget.info.isForceUpdate) { - return [ - FilledButton( - onPressed: _startDownload, - child: const Text('Update Now'), + if (!widget.info.isForceUpdate && !_downloading) { + actions.add( + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Later'), ), - ]; + ); } - return [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Later'), + actions.add( + FilledButton( + onPressed: _downloading ? null : _startDownload, + child: _downloading + ? const CircularProgressIndicator() + : const Text('Update Now'), ), - FilledButton(onPressed: _startDownload, child: const Text('Update Now')), - ]; + ); + + return actions; + } + + TextSpan _deltaToTextSpan(List delta, TextStyle? baseStyle) { + final children = []; + + TextStyle styleFromAttributes(Map? attrs) { + var s = baseStyle ?? const TextStyle(); + if (attrs == null) return s; + if (attrs['header'] != null) { + final level = attrs['header'] is int ? attrs['header'] as int : 1; + s = s.copyWith( + fontSize: 18.0 - (level - 1) * 2, + fontWeight: FontWeight.bold, + ); + } + if (attrs['bold'] == true) s = s.copyWith(fontWeight: FontWeight.bold); + if (attrs['italic'] == true) s = s.copyWith(fontStyle: FontStyle.italic); + if (attrs['underline'] == true) { + s = s.copyWith(decoration: TextDecoration.underline); + } + if (attrs['strike'] == true) { + s = s.copyWith(decoration: TextDecoration.lineThrough); + } + if (attrs['color'] is String) { + try { + final col = attrs['color'] as String; + // simple support for hex colors like #rrggbb + if (col.startsWith('#') && (col.length == 7 || col.length == 9)) { + final hex = col.replaceFirst('#', ''); + final value = int.parse(hex, radix: 16); + s = s.copyWith( + color: Color((hex.length == 6 ? 0xFF000000 : 0) | value), + ); + } + } catch (_) {} + } + return s; + } + + for (final op in delta) { + if (op is Map && op.containsKey('insert')) { + final insert = op['insert']; + final attrs = op['attributes'] as Map?; + String text; + if (insert is String) { + text = insert; + } else if (insert is Map && insert.containsKey('image')) { + // render image as alt text placeholder + text = '[image]'; + } else { + text = insert.toString(); + } + + children.add(TextSpan(text: text, style: styleFromAttributes(attrs))); + } + } + + return TextSpan(children: children, style: baseStyle); + } + + @override + void initState() { + super.initState(); + // Pre-parse release notes so heavy JSON parsing doesn't block UI later + final notes = widget.info.latestVersion?.releaseNotes ?? ''; + if (notes.isNotEmpty) { + try { + final parsed = jsonDecode(notes); + if (parsed is List) { + _notesDelta = parsed; + } else { + _notesPlain = notes; + } + } catch (_) { + _notesPlain = notes; + } + } + } + + @override + void dispose() { + super.dispose(); } }