A more robust self hosted OTA updates implementation

This commit is contained in:
Marc Rejohn Castillano 2026-03-13 07:15:28 +08:00
parent 9267ebee2c
commit 9bbaf67fef
8 changed files with 852 additions and 164 deletions

View File

@ -71,6 +71,16 @@
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="location"
tools:replace="android:foregroundServiceType" />
<!-- FileProvider for OTA plugin (ota_update) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.ota_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ota_update_file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@ -1,5 +1,49 @@
package com.example.tasq
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
class MainActivity : FlutterFragmentActivity()
class MainActivity : FlutterFragmentActivity() {
private val CHANNEL = "tasq/ota"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, _ ->
when (call.method) {
"openUnknownSources" -> {
try {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
intent.data = Uri.parse("package:" + applicationContext.packageName)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
applicationContext.startActivity(intent)
} catch (e: Exception) {
// ignore and return
}
}
"installApk" -> {
try {
val path = call.argument<String>("path") ?: return@setMethodCallHandler
val file = File(path)
val authority = applicationContext.packageName + ".ota_update_provider"
val uri = FileProvider.getUriForFile(applicationContext, authority, file)
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
applicationContext.startActivity(intent)
} catch (e: Exception) {
// ignore - caller will surface error
}
}
else -> {
// no-op
}
}
}
}
}

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Allow access to common app storage locations used by OTA plugin -->
<external-path name="external_files" path="." />
<external-files-path name="external_files_app" path="." />
<cache-path name="cache" path="." />
<external-cache-path name="external_cache" path="." />
<files-path name="files" path="." />
</paths>

View File

@ -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<void> 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<UpdateCheckWrapper> createState() => _UpdateCheckWrapperState();
}
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
bool _done = false;
AppUpdateInfo? _info;
@override
void initState() {
super.initState();
_performCheck();
}
Future<void> _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 {

View File

@ -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<AppUpdateScreen> {
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<String> _logs = [];
Timer? _progressTimer;
Timer? _startDelayTimer;
String? _error;
@override
@ -70,12 +78,42 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
_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<String, dynamic>) {
_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<AppUpdateScreen> {
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<AppUpdateScreen> {
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<AppUpdateScreen> {
}
}
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<AppUpdateScreen> {
}, 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<AppUpdateScreen> {
_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<AppUpdateScreen> {
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<double>(
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'),
),
],
),
),
),
),

View File

@ -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<UpdateCheckingScreen> createState() => _UpdateCheckingScreenState();
}
class _UpdateCheckingScreenState extends State<UpdateCheckingScreen> {
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;
}

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;
}
});
}
}

View File

@ -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<UpdateDialog> {
double _progress = 0;
double _realProgress = 0.0;
bool _downloading = false;
bool _failed = false;
List<dynamic>? _notesDelta;
String? _notesPlain;
Future<void> _startDownload() async {
setState(() {
@ -29,7 +35,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
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<UpdateDialog> {
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<UpdateDialog> {
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<double>(
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<UpdateDialog> {
}
List<Widget> _buildActions() {
if (_downloading) {
// don't show any actions while the apk is being fetched
return <Widget>[];
}
final actions = <Widget>[];
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<dynamic> delta, TextStyle? baseStyle) {
final children = <TextSpan>[];
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();
}
}