@_nazmiforreal/flutter-ota 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -18
- package/bin/flutter-ota-linux-x64 +0 -0
- package/dart-src/packages/cli-tools/README.md +146 -2
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +36 -8
- package/dart-src/packages/cli-tools/pubspec.yaml +1 -1
- package/dart-src/root/README.md +193 -253
- package/dart-src/root/lib/src/ota_progress_overlay.dart +245 -46
- package/dart-src/root/pubspec.yaml +1 -1
- package/package.json +1 -1
|
@@ -30,6 +30,10 @@ class OtaOverlayState {
|
|
|
30
30
|
final List<LogLine> logs;
|
|
31
31
|
final bool canRetry;
|
|
32
32
|
|
|
33
|
+
/// True once the patch is installed and the app is about to cold-restart.
|
|
34
|
+
/// Drives the final "restarting" phase label + the completed steps list.
|
|
35
|
+
final bool restarting;
|
|
36
|
+
|
|
33
37
|
/// Which step index is currently active (0-based). Steps that are done
|
|
34
38
|
/// get a check; the active step gets the braille spinner.
|
|
35
39
|
final int activeStep;
|
|
@@ -53,6 +57,7 @@ class OtaOverlayState {
|
|
|
53
57
|
this.totalBytes = 0,
|
|
54
58
|
this.logs = const [],
|
|
55
59
|
this.canRetry = false,
|
|
60
|
+
this.restarting = false,
|
|
56
61
|
this.activeStep = 0,
|
|
57
62
|
});
|
|
58
63
|
|
|
@@ -74,6 +79,7 @@ class OtaOverlayState {
|
|
|
74
79
|
int? totalBytes,
|
|
75
80
|
List<LogLine>? logs,
|
|
76
81
|
bool? canRetry,
|
|
82
|
+
bool? restarting,
|
|
77
83
|
int? activeStep,
|
|
78
84
|
}) {
|
|
79
85
|
return OtaOverlayState(
|
|
@@ -95,6 +101,7 @@ class OtaOverlayState {
|
|
|
95
101
|
totalBytes: totalBytes ?? this.totalBytes,
|
|
96
102
|
logs: logs ?? this.logs,
|
|
97
103
|
canRetry: canRetry ?? this.canRetry,
|
|
104
|
+
restarting: restarting ?? this.restarting,
|
|
98
105
|
activeStep: activeStep ?? this.activeStep,
|
|
99
106
|
);
|
|
100
107
|
}
|
|
@@ -341,14 +348,21 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
|
|
|
341
348
|
// ── STEPS ─────────────────────────────────────────────────────────────
|
|
342
349
|
Widget _stepsBlock(OtaOverlayState s) {
|
|
343
350
|
final spinner = _braille[_spinnerIndex];
|
|
351
|
+
// When restarting, every step is complete. Otherwise the active index is
|
|
352
|
+
// clamped into range so an out-of-bounds activeStep can never leave the
|
|
353
|
+
// list looking stuck or skip the visible progression.
|
|
354
|
+
final int activeIndex = s.restarting
|
|
355
|
+
? _stepLabels.length
|
|
356
|
+
: s.activeStep.clamp(0, _stepLabels.length - 1);
|
|
357
|
+
|
|
344
358
|
return _panelBox(
|
|
345
359
|
child: Column(
|
|
346
360
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
347
361
|
children: List.generate(_stepLabels.length, (i) {
|
|
348
|
-
final done = i <
|
|
349
|
-
final active = i == s.
|
|
350
|
-
final failed = i ==
|
|
351
|
-
final pending = i >
|
|
362
|
+
final done = i < activeIndex;
|
|
363
|
+
final active = i == activeIndex && !s.hasError && !s.restarting;
|
|
364
|
+
final failed = i == activeIndex && s.hasError;
|
|
365
|
+
final pending = i > activeIndex;
|
|
352
366
|
|
|
353
367
|
final Color color = failed
|
|
354
368
|
? _C.red
|
|
@@ -366,13 +380,21 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
|
|
|
366
380
|
? spinner
|
|
367
381
|
: '·';
|
|
368
382
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
383
|
+
// Only the downloading step shows a percentage. Verify / install /
|
|
384
|
+
// finalize have no meaningful fraction, so they show a live "working"
|
|
385
|
+
// spinner instead of a frozen number — nothing looks hung.
|
|
386
|
+
final String status;
|
|
387
|
+
if (failed) {
|
|
388
|
+
status = 'error';
|
|
389
|
+
} else if (done) {
|
|
390
|
+
status = 'done';
|
|
391
|
+
} else if (active) {
|
|
392
|
+
final showPct = s.phase == PatchApplyPhase.downloading &&
|
|
393
|
+
s.fraction != null;
|
|
394
|
+
status = showPct ? _pct(s.fraction) : 'working $spinner';
|
|
395
|
+
} else {
|
|
396
|
+
status = 'wait';
|
|
397
|
+
}
|
|
376
398
|
|
|
377
399
|
return Padding(
|
|
378
400
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
@@ -415,11 +437,6 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
|
|
|
415
437
|
|
|
416
438
|
// ── PROGRESS ──────────────────────────────────────────────────────────
|
|
417
439
|
Widget _progressBlock(OtaOverlayState s) {
|
|
418
|
-
const total = 34;
|
|
419
|
-
final frac = (s.fraction ?? 0).clamp(0.0, 1.0);
|
|
420
|
-
final filled = (frac * total).round();
|
|
421
|
-
final bar = '${'█' * filled}${'░' * (total - filled)}';
|
|
422
|
-
|
|
423
440
|
final speed = (s.bytesPerSec != null && s.bytesPerSec! > 0)
|
|
424
441
|
? '${(s.bytesPerSec! / 1048576).toStringAsFixed(1)} MB/s'
|
|
425
442
|
: '—';
|
|
@@ -428,31 +445,54 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
|
|
|
428
445
|
s.bytesPerSec! > 0 &&
|
|
429
446
|
s.totalBytes > s.bytesReceived) {
|
|
430
447
|
final secs = ((s.totalBytes - s.bytesReceived) / s.bytesPerSec!).ceil();
|
|
431
|
-
eta = '${secs}s';
|
|
448
|
+
eta = secs >= 60 ? '${(secs / 60).ceil()}m' : '${secs}s';
|
|
432
449
|
}
|
|
433
450
|
final phase = s.hasError
|
|
434
451
|
? 'halted'
|
|
435
|
-
: (s.phase?.name ?? 'preparing');
|
|
452
|
+
: (s.restarting ? 'restarting' : (s.phase?.name ?? 'preparing'));
|
|
453
|
+
|
|
454
|
+
// A determinate fraction is only meaningful while downloading with a known
|
|
455
|
+
// Content-Length. Verifying / finalizing / unknown-size downloads render an
|
|
456
|
+
// indeterminate (sliding) bar so nothing ever looks stuck at 0%.
|
|
457
|
+
final bool determinate = !s.hasError &&
|
|
458
|
+
s.phase == PatchApplyPhase.downloading &&
|
|
459
|
+
s.fraction != null;
|
|
460
|
+
final double? frac = s.hasError
|
|
461
|
+
? (s.fraction ?? 0)
|
|
462
|
+
: (determinate ? s.fraction : null);
|
|
436
463
|
|
|
437
464
|
return _panelBox(
|
|
438
465
|
child: Column(
|
|
439
466
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
440
467
|
children: [
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
468
|
+
Row(
|
|
469
|
+
children: [
|
|
470
|
+
// The bar is a real widget (never wraps), and animates smoothly
|
|
471
|
+
// between values instead of snapping.
|
|
472
|
+
Expanded(
|
|
473
|
+
child: _ProgressBar(
|
|
474
|
+
fraction: frac,
|
|
475
|
+
color: s.hasError ? _C.red : _C.blue,
|
|
476
|
+
),
|
|
477
|
+
),
|
|
478
|
+
const SizedBox(width: 10),
|
|
479
|
+
SizedBox(
|
|
480
|
+
width: 44,
|
|
481
|
+
child: Text(
|
|
482
|
+
determinate ? _pct(s.fraction) : '',
|
|
483
|
+
textAlign: TextAlign.right,
|
|
484
|
+
style: const TextStyle(
|
|
485
|
+
fontFamily: _mono,
|
|
486
|
+
fontSize: 13,
|
|
487
|
+
color: _C.text,
|
|
488
|
+
fontWeight: FontWeight.w700,
|
|
489
|
+
fontFeatures: [FontFeature.tabularFigures()],
|
|
490
|
+
),
|
|
491
|
+
),
|
|
492
|
+
),
|
|
493
|
+
],
|
|
454
494
|
),
|
|
455
|
-
const SizedBox(height:
|
|
495
|
+
const SizedBox(height: 12),
|
|
456
496
|
Row(
|
|
457
497
|
children: [
|
|
458
498
|
_stat('speed', speed),
|
|
@@ -604,6 +644,133 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
|
|
|
604
644
|
);
|
|
605
645
|
}
|
|
606
646
|
|
|
647
|
+
/// A single-line progress bar that never wraps.
|
|
648
|
+
///
|
|
649
|
+
/// - When [fraction] is non-null it's a determinate bar that animates smoothly
|
|
650
|
+
/// between values (no snapping / jumping).
|
|
651
|
+
/// - When [fraction] is null it's an indeterminate sliding bar, so
|
|
652
|
+
/// verify/install/finalize (which have no meaningful percentage) never look
|
|
653
|
+
/// frozen at 0%.
|
|
654
|
+
///
|
|
655
|
+
/// It's a real painted widget with a fixed height inside an [Expanded], so it
|
|
656
|
+
/// always stays on one line regardless of width — unlike the old
|
|
657
|
+
/// character-string bar, which wrapped when it didn't fit.
|
|
658
|
+
class _ProgressBar extends StatefulWidget {
|
|
659
|
+
final double? fraction;
|
|
660
|
+
final Color color;
|
|
661
|
+
|
|
662
|
+
const _ProgressBar({required this.fraction, required this.color});
|
|
663
|
+
|
|
664
|
+
@override
|
|
665
|
+
State<_ProgressBar> createState() => _ProgressBarState();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
class _ProgressBarState extends State<_ProgressBar>
|
|
669
|
+
with SingleTickerProviderStateMixin {
|
|
670
|
+
late final AnimationController _indeterminate = AnimationController(
|
|
671
|
+
vsync: this,
|
|
672
|
+
duration: const Duration(milliseconds: 1200),
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// Smoothed determinate value so the bar eases toward the target instead of
|
|
676
|
+
// snapping. Monotonic — it never visually goes backwards.
|
|
677
|
+
double _shown = 0;
|
|
678
|
+
|
|
679
|
+
@override
|
|
680
|
+
void initState() {
|
|
681
|
+
super.initState();
|
|
682
|
+
_shown = widget.fraction ?? 0;
|
|
683
|
+
if (widget.fraction == null) _indeterminate.repeat();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
@override
|
|
687
|
+
void didUpdateWidget(covariant _ProgressBar old) {
|
|
688
|
+
super.didUpdateWidget(old);
|
|
689
|
+
if (widget.fraction == null) {
|
|
690
|
+
if (!_indeterminate.isAnimating) _indeterminate.repeat();
|
|
691
|
+
} else {
|
|
692
|
+
if (_indeterminate.isAnimating) _indeterminate.stop();
|
|
693
|
+
final target = widget.fraction!.clamp(0.0, 1.0);
|
|
694
|
+
if (target >= _shown) _shown = target; // forward-only
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
@override
|
|
699
|
+
void dispose() {
|
|
700
|
+
_indeterminate.dispose();
|
|
701
|
+
super.dispose();
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
static const _height = 8.0;
|
|
705
|
+
static const _track = Color(0xFF161C24);
|
|
706
|
+
|
|
707
|
+
@override
|
|
708
|
+
Widget build(BuildContext context) {
|
|
709
|
+
return ClipRRect(
|
|
710
|
+
borderRadius: BorderRadius.circular(99),
|
|
711
|
+
child: SizedBox(
|
|
712
|
+
height: _height,
|
|
713
|
+
child: ColoredBox(
|
|
714
|
+
color: _track,
|
|
715
|
+
child: widget.fraction == null
|
|
716
|
+
? _indeterminateBar()
|
|
717
|
+
: _determinateBar(),
|
|
718
|
+
),
|
|
719
|
+
),
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
Widget _determinateBar() {
|
|
724
|
+
return LayoutBuilder(
|
|
725
|
+
builder: (context, c) => TweenAnimationBuilder<double>(
|
|
726
|
+
// Ease toward the smoothed target; short duration keeps it responsive
|
|
727
|
+
// but never snappy.
|
|
728
|
+
tween: Tween(end: _shown),
|
|
729
|
+
duration: const Duration(milliseconds: 400),
|
|
730
|
+
curve: Curves.easeOut,
|
|
731
|
+
builder: (context, v, _) => Align(
|
|
732
|
+
alignment: Alignment.centerLeft,
|
|
733
|
+
child: Container(
|
|
734
|
+
width: (c.maxWidth * v.clamp(0.0, 1.0)),
|
|
735
|
+
decoration: BoxDecoration(
|
|
736
|
+
color: widget.color,
|
|
737
|
+
borderRadius: BorderRadius.circular(99),
|
|
738
|
+
),
|
|
739
|
+
),
|
|
740
|
+
),
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
Widget _indeterminateBar() {
|
|
746
|
+
return LayoutBuilder(
|
|
747
|
+
builder: (context, c) => AnimatedBuilder(
|
|
748
|
+
animation: _indeterminate,
|
|
749
|
+
builder: (context, _) {
|
|
750
|
+
const segment = 0.35; // 35% wide moving segment
|
|
751
|
+
final t = _indeterminate.value; // 0..1
|
|
752
|
+
final left = (t * (1 + segment)) - segment; // slides in from the left
|
|
753
|
+
return Align(
|
|
754
|
+
alignment: Alignment.centerLeft,
|
|
755
|
+
child: Padding(
|
|
756
|
+
padding: EdgeInsets.only(
|
|
757
|
+
left: (left.clamp(0.0, 1.0)) * c.maxWidth,
|
|
758
|
+
),
|
|
759
|
+
child: Container(
|
|
760
|
+
width: segment * c.maxWidth,
|
|
761
|
+
decoration: BoxDecoration(
|
|
762
|
+
color: widget.color,
|
|
763
|
+
borderRadius: BorderRadius.circular(99),
|
|
764
|
+
),
|
|
765
|
+
),
|
|
766
|
+
),
|
|
767
|
+
);
|
|
768
|
+
},
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
607
774
|
/// Scrollable terminal log view — auto-scrolls to bottom on new lines.
|
|
608
775
|
///
|
|
609
776
|
/// A line whose text has a leading token followed by 2+ spaces is rendered
|
|
@@ -706,6 +873,10 @@ class OtaOverlayManager {
|
|
|
706
873
|
int _lastBytes = 0;
|
|
707
874
|
DateTime? _lastBytesAt;
|
|
708
875
|
|
|
876
|
+
/// Last 10%-bucket we emitted a "download NN%" log line for, to throttle the
|
|
877
|
+
/// feed. -1 = none yet.
|
|
878
|
+
int _lastLoggedPctBucket = -1;
|
|
879
|
+
|
|
709
880
|
OverlayState? Function()? _resolver;
|
|
710
881
|
|
|
711
882
|
/// Minimum time the overlay stays visible (7 seconds).
|
|
@@ -742,6 +913,7 @@ class OtaOverlayManager {
|
|
|
742
913
|
_startTime = DateTime.now();
|
|
743
914
|
_lastBytes = 0;
|
|
744
915
|
_lastBytesAt = null;
|
|
916
|
+
_lastLoggedPctBucket = -1;
|
|
745
917
|
|
|
746
918
|
final logs = <LogLine>[
|
|
747
919
|
if (channel != null) LogLine('gray', 'channel $channel'),
|
|
@@ -775,9 +947,13 @@ class OtaOverlayManager {
|
|
|
775
947
|
void _log(String color, String text) {
|
|
776
948
|
if (_disposed) return;
|
|
777
949
|
final current = _state.value;
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
950
|
+
final next = [...current.logs, LogLine(color, text)];
|
|
951
|
+
// Cap the feed so a long session can't grow it without bound.
|
|
952
|
+
const maxLines = 200;
|
|
953
|
+
final capped = next.length > maxLines
|
|
954
|
+
? next.sublist(next.length - maxLines)
|
|
955
|
+
: next;
|
|
956
|
+
_state.value = current.copyWith(logs: capped);
|
|
781
957
|
}
|
|
782
958
|
|
|
783
959
|
/// Map phase to step index.
|
|
@@ -802,24 +978,37 @@ class OtaOverlayManager {
|
|
|
802
978
|
? '${(pct.clamp(0.0, 1.0) * 100).toStringAsFixed(0)}%'
|
|
803
979
|
: '';
|
|
804
980
|
|
|
805
|
-
|
|
981
|
+
// Step index derived from phase, but forced to move only forward so an
|
|
982
|
+
// out-of-order or duplicate native event can never make the steps jump
|
|
983
|
+
// backwards or flicker.
|
|
984
|
+
final phaseStep = _phaseToStep(progress.phase);
|
|
985
|
+
final newStep = phaseStep < s.activeStep ? s.activeStep : phaseStep;
|
|
806
986
|
|
|
807
|
-
// Compute download speed from byte deltas.
|
|
987
|
+
// Compute a smoothed download speed from byte deltas. A single sample can
|
|
988
|
+
// spike wildly (buffered reads), so we blend with the previous value.
|
|
808
989
|
int? bytesPerSec = s.bytesPerSec;
|
|
809
990
|
if (progress.phase == PatchApplyPhase.downloading &&
|
|
810
991
|
progress.bytesReceived > 0) {
|
|
811
992
|
final now = DateTime.now();
|
|
812
993
|
if (_lastBytesAt != null && progress.bytesReceived > _lastBytes) {
|
|
813
994
|
final dt = now.difference(_lastBytesAt!).inMilliseconds / 1000.0;
|
|
814
|
-
if (dt > 0) {
|
|
815
|
-
|
|
995
|
+
if (dt > 0.05) {
|
|
996
|
+
final sample =
|
|
997
|
+
((progress.bytesReceived - _lastBytes) / dt).round();
|
|
998
|
+
// Exponential moving average → the speed/ETA read-out stops jumping.
|
|
999
|
+
bytesPerSec = bytesPerSec == null || bytesPerSec == 0
|
|
1000
|
+
? sample
|
|
1001
|
+
: (bytesPerSec * 0.6 + sample * 0.4).round();
|
|
1002
|
+
_lastBytes = progress.bytesReceived;
|
|
1003
|
+
_lastBytesAt = now;
|
|
816
1004
|
}
|
|
1005
|
+
} else {
|
|
1006
|
+
_lastBytes = progress.bytesReceived;
|
|
1007
|
+
_lastBytesAt = now;
|
|
817
1008
|
}
|
|
818
|
-
_lastBytes = progress.bytesReceived;
|
|
819
|
-
_lastBytesAt = now;
|
|
820
1009
|
}
|
|
821
1010
|
|
|
822
|
-
// Log phase transitions with staggered
|
|
1011
|
+
// Log phase transitions once, with staggered detail lines.
|
|
823
1012
|
if (s.phase != progress.phase) {
|
|
824
1013
|
switch (progress.phase) {
|
|
825
1014
|
case PatchApplyPhase.downloading:
|
|
@@ -841,12 +1030,17 @@ class OtaOverlayManager {
|
|
|
841
1030
|
}
|
|
842
1031
|
}
|
|
843
1032
|
|
|
844
|
-
//
|
|
1033
|
+
// Throttle the "download NN%" log so a fast download doesn't spam dozens of
|
|
1034
|
+
// lines (which made the feed scroll frantically). One line per ~10%.
|
|
845
1035
|
if (progress.phase == PatchApplyPhase.downloading &&
|
|
846
1036
|
pctText.isNotEmpty &&
|
|
847
1037
|
progress.totalBytes > 0) {
|
|
848
|
-
final
|
|
849
|
-
|
|
1038
|
+
final bucket = ((progress.fraction ?? 0) * 10).floor();
|
|
1039
|
+
if (bucket != _lastLoggedPctBucket) {
|
|
1040
|
+
_lastLoggedPctBucket = bucket;
|
|
1041
|
+
final sizeMB = (progress.totalBytes / 1048576).toStringAsFixed(1);
|
|
1042
|
+
_log('gray', 'download ${sizeMB}MB $pctText');
|
|
1043
|
+
}
|
|
850
1044
|
}
|
|
851
1045
|
|
|
852
1046
|
_state.value = _state.value.copyWith(
|
|
@@ -884,11 +1078,16 @@ class OtaOverlayManager {
|
|
|
884
1078
|
final remaining = _minDuration - elapsed;
|
|
885
1079
|
final delay = remaining.isNegative ? Duration.zero : remaining;
|
|
886
1080
|
|
|
1081
|
+
// On success mark ALL steps complete + flag restarting, so the steps list
|
|
1082
|
+
// finishes cleanly (every ✓, phase = "restarting") instead of freezing on
|
|
1083
|
+
// whatever step the last native progress event happened to leave active.
|
|
887
1084
|
_state.value = _state.value.copyWith(
|
|
888
1085
|
hasError: hasError,
|
|
889
1086
|
errorText: errorText,
|
|
890
1087
|
canRetry: hasError,
|
|
891
|
-
|
|
1088
|
+
restarting: !hasError,
|
|
1089
|
+
fraction: hasError ? null : 1.0,
|
|
1090
|
+
activeStep: hasError ? _state.value.activeStep : _stepLabels.length,
|
|
892
1091
|
);
|
|
893
1092
|
|
|
894
1093
|
final entry = _entry;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
name: flutter_ota_kit
|
|
2
2
|
description: Open-source, self-hosted code push (OTA updates) for Flutter Android. Patches Dart AOT libapp.so and assets on cold start with integrity checks and crash rollback.
|
|
3
|
-
version: 0.2.
|
|
3
|
+
version: 0.2.1
|
|
4
4
|
homepage: https://github.com/HYPER12755/flutter-ota-kit
|
|
5
5
|
repository: https://github.com/HYPER12755/flutter-ota-kit
|
|
6
6
|
issue_tracker: https://github.com/HYPER12755/flutter-ota-kit/issues
|
package/package.json
CHANGED