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
+205 -77
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'),
),
],
),
),
),
),