@_nazmiforreal/flutter-ota 0.1.29 → 0.1.30

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.
Files changed (26) hide show
  1. package/bin/flutter-ota-linux-x64 +0 -0
  2. package/bin/flutter-ota.js +0 -0
  3. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
  4. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +754 -0
  5. package/dart-src/packages/cli-tools/.dart_tool/version +1 -0
  6. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -1
  7. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +17 -8
  8. package/dart-src/packages/cli-tools/pubspec.lock +636 -0
  9. package/dart-src/packages/cli-tools/pubspec.yaml +6 -6
  10. package/dart-src/packages/cli-tools/test/mocks/mock_pocketbase_client.dart +2 -1
  11. package/dart-src/root/analysis_options.yaml +3 -0
  12. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/FlutterPatcherPlugin.kt +1 -0
  13. package/dart-src/root/android/src/main/kotlin/com/flutter_patcher/flutter_patcher/PatcherConfig.kt +20 -0
  14. package/dart-src/root/lib/flutter_ota_kit.dart +31 -11
  15. package/dart-src/root/lib/src/aws_update_source.dart +1 -0
  16. package/dart-src/root/lib/src/cloudflare_update_source.dart +1 -0
  17. package/dart-src/root/lib/src/ota_progress_overlay.dart +196 -130
  18. package/dart-src/root/lib/src/patcher_channel.dart +14 -1
  19. package/dart-src/root/lib/src/pkg/plugin_core/src/request_update_bundle_state.dart +2 -1
  20. package/dart-src/root/lib/src/pkg/postgres/src/postgres_types.dart +2 -1
  21. package/dart-src/root/lib/src/pocketbase_update_source.dart +1 -0
  22. package/dart-src/root/lib/src/postgres_update_source.dart +1 -0
  23. package/dart-src/root/lib/src/shared_update_check.dart +30 -2
  24. package/dart-src/root/lib/src/supabase_update_source.dart +1 -0
  25. package/dart-src/root/pubspec.yaml +14 -14
  26. package/package.json +1 -1
@@ -30,6 +30,11 @@ internal object PatcherConfig {
30
30
  const val KEY_CRASH_COUNT = "crash_count"
31
31
  const val KEY_PATCH_LOADING = "patch_loading"
32
32
 
33
+ /** Random UUID generated on first read and persisted; seeds the rollout
34
+ * cohort on the Dart side. Survives patch updates and base-APK upgrades —
35
+ * only reset by uninstall / `pm clear`, like the rest of these prefs. */
36
+ private const val KEY_DEVICE_ID = "device_install_id"
37
+
33
38
  /** PID written at [com.flutter_patcher.flutter_patcher.CrashGuard.markBooting]; consumed
34
39
  * next cold start to look up `ActivityManager.getHistoricalProcessExitReasons` (API 30+).
35
40
  * Unused on API < 30 — that path uses the naive "patch_loading=true ⇒ crash" rule. */
@@ -87,6 +92,21 @@ internal object PatcherConfig {
87
92
  fun prefs(context: Context): SharedPreferences =
88
93
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
89
94
 
95
+ /**
96
+ * Stable per-install device identifier (generated once, persisted).
97
+ *
98
+ * Used as the seed for server-side rollout-cohort hashing so a device
99
+ * lands in a consistent cohort across releases. Never leaves the device —
100
+ * the app sends only the derived 0-999 cohort number.
101
+ */
102
+ fun deviceInstallId(context: Context): String {
103
+ val existing = prefs(context).getString(KEY_DEVICE_ID, null)
104
+ if (!existing.isNullOrEmpty()) return existing
105
+ val fresh = java.util.UUID.randomUUID().toString()
106
+ prefs(context).edit().putString(KEY_DEVICE_ID, fresh).apply()
107
+ return fresh
108
+ }
109
+
90
110
  internal fun encodeLoaderFieldCandidates(fields: List<String>): String {
91
111
  val normalized = fields
92
112
  .map { it.trim() }
@@ -756,7 +756,9 @@ class FlutterPatcher {
756
756
  ///
757
757
  /// Call [restart] after a successful rollback to activate the previous patch.
758
758
  static Future<RollbackOutcome> rollbackToPrevious() async {
759
- if (_notAndroidGuard('rollbackToPrevious')) return RollbackOutcome.fallbackToBase;
759
+ if (_notAndroidGuard('rollbackToPrevious')) {
760
+ return RollbackOutcome.fallbackToBase;
761
+ }
760
762
  try {
761
763
  final outcomeName = await PatcherChannel.rollbackToPrevious();
762
764
  return RollbackOutcome.values.byName(outcomeName);
@@ -815,8 +817,9 @@ class FlutterPatcher {
815
817
  final showOverlay = showUpdateUi && result.shouldForceUpdate;
816
818
  final fromVersion = await currentVersion;
817
819
  final patchHash = result.patch!.md5;
818
- final shortHash =
819
- patchHash.isNotEmpty ? patchHash.substring(0, patchHash.length.clamp(0, 8)) : null;
820
+ final shortHash = patchHash.isNotEmpty
821
+ ? patchHash.substring(0, patchHash.length.clamp(0, 8))
822
+ : null;
820
823
 
821
824
  // Extract channel/platform from whichever config is active
822
825
  String? channel;
@@ -878,7 +881,9 @@ class FlutterPatcher {
878
881
  if (error != null &&
879
882
  deterministic.contains(error) &&
880
883
  patchVersion.isNotEmpty) {
881
- _log('applyUpdate: patch failed (${error.name}), blacklisting $patchVersion');
884
+ _log(
885
+ 'applyUpdate: patch failed (${error.name}), blacklisting $patchVersion',
886
+ );
882
887
  try {
883
888
  await PatcherChannel.reportApplyFailure(
884
889
  version: patchVersion,
@@ -889,15 +894,22 @@ class FlutterPatcher {
889
894
  _log('applyUpdate: failed to blacklist $patchVersion: $e', s);
890
895
  }
891
896
  } else {
892
- _log('applyUpdate: patch failed (${error?.name}); transient, not '
893
- 'blacklisting (will retry next check)');
897
+ _log(
898
+ 'applyUpdate: patch failed (${error?.name}); transient, not '
899
+ 'blacklisting (will retry next check)',
900
+ );
894
901
  }
895
902
  }
896
903
 
897
904
  if (applied.ok && result.shouldForceUpdate) {
898
905
  handle?.end();
899
- // Wait for overlay to finish its minimum display time + success dwell
900
- await Future.delayed(const Duration(seconds: 3));
906
+ // Let the overlay finish its minimum display time + success dwell and
907
+ // fully unmount before killing the process. The old fixed 3s wait could
908
+ // restart while the install screen was still visible, because a fast
909
+ // download keeps the overlay up until the 7s minimum duration elapses.
910
+ await (handle?.dismissed ??
911
+ Future<void>.delayed(const Duration(seconds: 3)))
912
+ .timeout(const Duration(seconds: 12), onTimeout: () {});
901
913
  await restart();
902
914
  } else {
903
915
  handle?.end(hasError: !applied.ok, errorText: applied.message);
@@ -932,7 +944,9 @@ class FlutterPatcher {
932
944
  if (showUpdateUi) {
933
945
  OtaOverlayManager.instance.showRollbackToast(
934
946
  message: 'Update reverted — using previous version',
935
- previousVersion: outcome == RollbackOutcome.success ? 'previous' : 'base',
947
+ previousVersion: outcome == RollbackOutcome.success
948
+ ? 'previous'
949
+ : 'base',
936
950
  );
937
951
  }
938
952
  await restart();
@@ -953,7 +967,10 @@ class FlutterPatcher {
953
967
  final patchVersion = result.patch!.version;
954
968
  final patchMd5 = result.patch!.md5;
955
969
  try {
956
- if (await PatcherChannel.isVersionBlacklisted(patchVersion, md5: patchMd5)) {
970
+ if (await PatcherChannel.isVersionBlacklisted(
971
+ patchVersion,
972
+ md5: patchMd5,
973
+ )) {
957
974
  _log('checkAndApplyUpdates: $patchVersion is blacklisted, skip');
958
975
  return null;
959
976
  }
@@ -1039,7 +1056,10 @@ class FlutterPatcher {
1039
1056
  ///
1040
1057
  /// When [md5] is provided, the match uses both version and md5. Without md5,
1041
1058
  /// any entry with the same version is considered a match.
1042
- static Future<bool> isVersionBlacklisted(String version, {String md5 = ''}) async {
1059
+ static Future<bool> isVersionBlacklisted(
1060
+ String version, {
1061
+ String md5 = '',
1062
+ }) async {
1043
1063
  if (_notAndroidGuard('isVersionBlacklisted')) return false;
1044
1064
  try {
1045
1065
  return await PatcherChannel.isVersionBlacklisted(version, md5: md5);
@@ -91,6 +91,7 @@ class AwsUpdateSource {
91
91
  fingerprintHash: config.fingerprintHash,
92
92
  minBundleId: config.minBundleId,
93
93
  currentBundleId: currentBundleId,
94
+ cohort: config.cohort,
94
95
  timeout: timeout,
95
96
  );
96
97
  }
@@ -91,6 +91,7 @@ class CloudflareUpdateSource {
91
91
  fingerprintHash: config.fingerprintHash,
92
92
  minBundleId: config.minBundleId,
93
93
  currentBundleId: currentBundleId,
94
+ cohort: config.cohort,
94
95
  timeout: timeout,
95
96
  );
96
97
  }
@@ -275,36 +275,35 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
275
275
 
276
276
  // ── Title bar ─────────────────────────────────────────────────────────
277
277
  Widget _titleBar(OtaOverlayState s) => Container(
278
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
279
- decoration: const BoxDecoration(
280
- color: _C.head,
281
- borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
282
- border: Border(bottom: BorderSide(color: _C.border)),
278
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
279
+ decoration: const BoxDecoration(
280
+ color: _C.head,
281
+ borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
282
+ border: Border(bottom: BorderSide(color: _C.border)),
283
+ ),
284
+ child: Row(
285
+ children: [
286
+ _dot(_C.red),
287
+ const SizedBox(width: 6),
288
+ _dot(_C.yellow),
289
+ const SizedBox(width: 6),
290
+ _dot(_C.green),
291
+ const SizedBox(width: 12),
292
+ const Flexible(
293
+ child: Text(
294
+ 'flutter-ota · forced update',
295
+ overflow: TextOverflow.ellipsis,
296
+ style: TextStyle(color: _C.text, fontFamily: _mono, fontSize: 12),
297
+ ),
283
298
  ),
284
- child: Row(
285
- children: [
286
- _dot(_C.red),
287
- const SizedBox(width: 6),
288
- _dot(_C.yellow),
289
- const SizedBox(width: 6),
290
- _dot(_C.green),
291
- const SizedBox(width: 12),
292
- const Flexible(
293
- child: Text(
294
- 'flutter-ota · forced update',
295
- overflow: TextOverflow.ellipsis,
296
- style: TextStyle(
297
- color: _C.text, fontFamily: _mono, fontSize: 12),
298
- ),
299
- ),
300
- const SizedBox(width: 8),
301
- _pill(
302
- s.hasError ? 'FAILED' : 'RUNNING',
303
- s.hasError ? _C.red : _C.green,
304
- ),
305
- ],
299
+ const SizedBox(width: 8),
300
+ _pill(
301
+ s.hasError ? 'FAILED' : 'RUNNING',
302
+ s.hasError ? _C.red : _C.green,
306
303
  ),
307
- );
304
+ ],
305
+ ),
306
+ );
308
307
 
309
308
  // ── META ──────────────────────────────────────────────────────────────
310
309
  Widget _metaBlock(OtaOverlayState s) {
@@ -330,12 +329,15 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
330
329
  style: const TextStyle(fontFamily: _mono, fontSize: 13),
331
330
  children: [
332
331
  TextSpan(
333
- text: '${r[0].padRight(9)} ',
334
- style: const TextStyle(color: _C.dim)),
332
+ text: '${r[0].padRight(9)} ',
333
+ style: const TextStyle(color: _C.dim),
334
+ ),
335
335
  TextSpan(
336
- text: r[1],
337
- style: TextStyle(
338
- color: r[0] == 'version' ? _C.blue : _C.text)),
336
+ text: r[1],
337
+ style: TextStyle(
338
+ color: r[0] == 'version' ? _C.blue : _C.text,
339
+ ),
340
+ ),
339
341
  ],
340
342
  ),
341
343
  ),
@@ -367,18 +369,18 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
367
369
  final Color color = failed
368
370
  ? _C.red
369
371
  : done
370
- ? _C.green
371
- : active
372
- ? _C.blue
373
- : _C.faint;
372
+ ? _C.green
373
+ : active
374
+ ? _C.blue
375
+ : _C.faint;
374
376
 
375
377
  final String glyph = failed
376
378
  ? '✗'
377
379
  : done
378
- ? '✓'
379
- : active
380
- ? spinner
381
- : '·';
380
+ ? '✓'
381
+ : active
382
+ ? spinner
383
+ : '·';
382
384
 
383
385
  // Only the downloading step shows a percentage. Verify / install /
384
386
  // Only the download step shows a percentage. Other active phases show
@@ -390,8 +392,8 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
390
392
  } else if (done) {
391
393
  status = 'done';
392
394
  } else if (active) {
393
- final showPct = s.phase == PatchApplyPhase.downloading &&
394
- s.fraction != null;
395
+ final showPct =
396
+ s.phase == PatchApplyPhase.downloading && s.fraction != null;
395
397
  status = showPct ? _pct(s.fraction) : 'working…';
396
398
  } else {
397
399
  status = 'wait';
@@ -403,9 +405,14 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
403
405
  children: [
404
406
  SizedBox(
405
407
  width: 18,
406
- child: Text(glyph,
407
- style: TextStyle(
408
- color: color, fontFamily: _mono, fontSize: 14)),
408
+ child: Text(
409
+ glyph,
410
+ style: TextStyle(
411
+ color: color,
412
+ fontFamily: _mono,
413
+ fontSize: 14,
414
+ ),
415
+ ),
409
416
  ),
410
417
  const SizedBox(width: 8),
411
418
  Expanded(
@@ -441,8 +448,8 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
441
448
  // Single honest fraction for the bar:
442
449
  // - preparing (no phase / no fraction yet) → empty
443
450
  // - downloading with a known size → real fraction
444
- // - verifying / installing / finalizing / restarting → full (download is
445
- // already done, so the bar simply stays at 100% — no sliding animation)
451
+ // - verifying / finalizing / restarting → full (download is already
452
+ // done, so the bar rests at 100%)
446
453
  // - error → freeze wherever it was
447
454
  final double frac;
448
455
  if (s.hasError) {
@@ -458,32 +465,51 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
458
465
  frac = 0.0; // preparing
459
466
  }
460
467
 
461
- // Percent read-out only while it's a real download percentage.
462
- final bool showPct =
463
- !s.hasError && s.phase == PatchApplyPhase.downloading && s.fraction != null;
468
+ // The readout lives on its own line above the bar (right aligned) so the
469
+ // bar itself spans the full panel width and can visibly reach the end.
470
+ // It used to sit in a fixed column beside the bar, which (a) reserved
471
+ // 54px the bar could never grow into and (b) hid the percent as soon as
472
+ // the download phase ended, making the bar look stuck below 100%.
473
+ final String pctLabel;
474
+ if (s.hasError) {
475
+ pctLabel = '';
476
+ } else if (s.restarting ||
477
+ s.phase == PatchApplyPhase.verifying ||
478
+ s.phase == PatchApplyPhase.finalizing) {
479
+ pctLabel = _pct(1.0);
480
+ } else if (s.phase == PatchApplyPhase.downloading && s.fraction != null) {
481
+ pctLabel = _pct(s.fraction);
482
+ } else {
483
+ pctLabel = '';
484
+ }
464
485
 
465
486
  return _panelBox(
466
- child: Row(
487
+ child: Column(
488
+ key: const Key('ota-progress-panel'),
489
+ crossAxisAlignment: CrossAxisAlignment.stretch,
467
490
  children: [
468
- Expanded(
469
- child: _ProgressBar(
470
- fraction: frac,
471
- color: s.hasError ? _C.red : _C.blue,
491
+ SizedBox(
492
+ height: 16,
493
+ child: Align(
494
+ alignment: Alignment.centerRight,
495
+ child: Text(
496
+ pctLabel,
497
+ style: const TextStyle(
498
+ fontFamily: _mono,
499
+ fontSize: 13,
500
+ color: _C.text,
501
+ fontWeight: FontWeight.w700,
502
+ fontFeatures: [FontFeature.tabularFigures()],
503
+ ),
504
+ ),
472
505
  ),
473
506
  ),
474
- const SizedBox(width: 10),
507
+ const SizedBox(height: 4),
475
508
  SizedBox(
476
- width: 44,
477
- child: Text(
478
- showPct ? _pct(s.fraction) : '',
479
- textAlign: TextAlign.right,
480
- style: const TextStyle(
481
- fontFamily: _mono,
482
- fontSize: 13,
483
- color: _C.text,
484
- fontWeight: FontWeight.w700,
485
- fontFeatures: [FontFeature.tabularFigures()],
486
- ),
509
+ key: const Key('ota-progress-bar'),
510
+ child: _ProgressBar(
511
+ fraction: frac,
512
+ color: s.hasError ? _C.red : _C.blue,
487
513
  ),
488
514
  ),
489
515
  ],
@@ -522,7 +548,10 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
522
548
  child: Text(
523
549
  s.errorHint ?? 'Close the app and reopen to retry.',
524
550
  style: const TextStyle(
525
- color: _C.dim, fontFamily: _mono, fontSize: 12),
551
+ color: _C.dim,
552
+ fontFamily: _mono,
553
+ fontSize: 12,
554
+ ),
526
555
  ),
527
556
  ),
528
557
  if (showRetry)
@@ -530,25 +559,35 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
530
559
  onTap: widget.onRetry,
531
560
  child: Container(
532
561
  padding: const EdgeInsets.symmetric(
533
- horizontal: 14, vertical: 7),
562
+ horizontal: 14,
563
+ vertical: 7,
564
+ ),
534
565
  decoration: BoxDecoration(
535
566
  borderRadius: BorderRadius.circular(7),
536
567
  border: Border.all(color: _C.border),
537
568
  ),
538
- child: const Text('[ retry ]',
539
- style: TextStyle(
540
- color: _C.blue,
541
- fontFamily: _mono,
542
- fontSize: 13)),
569
+ child: const Text(
570
+ '[ retry ]',
571
+ style: TextStyle(
572
+ color: _C.blue,
573
+ fontFamily: _mono,
574
+ fontSize: 13,
575
+ ),
576
+ ),
543
577
  ),
544
578
  ),
545
579
  ],
546
580
  )
547
581
  : Row(
548
582
  children: [
549
- const Text('›',
550
- style: TextStyle(
551
- color: _C.green, fontFamily: _mono, fontSize: 13)),
583
+ const Text(
584
+ '›',
585
+ style: TextStyle(
586
+ color: _C.green,
587
+ fontFamily: _mono,
588
+ fontSize: 13,
589
+ ),
590
+ ),
552
591
  const SizedBox(width: 8),
553
592
  Expanded(
554
593
  child: Text(
@@ -556,7 +595,10 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
556
595
  ? s.message!
557
596
  : 'Applying update, please keep the app open…',
558
597
  style: const TextStyle(
559
- color: _C.dim, fontFamily: _mono, fontSize: 12),
598
+ color: _C.dim,
599
+ fontFamily: _mono,
600
+ fontSize: 12,
601
+ ),
560
602
  ),
561
603
  ),
562
604
  ],
@@ -566,52 +608,57 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
566
608
 
567
609
  // ── helpers ─────────────────────────────────────────────────────────
568
610
  Widget _sectionLabel(String t) => Row(
569
- children: [
570
- Text(t,
571
- style: const TextStyle(
572
- color: _C.dim,
573
- fontFamily: _mono,
574
- fontSize: 11,
575
- fontWeight: FontWeight.w700,
576
- letterSpacing: 1.5,
577
- )),
578
- const SizedBox(width: 10),
579
- const Expanded(child: Divider(color: _C.sep, height: 1)),
580
- ],
581
- );
611
+ children: [
612
+ Text(
613
+ t,
614
+ style: const TextStyle(
615
+ color: _C.dim,
616
+ fontFamily: _mono,
617
+ fontSize: 11,
618
+ fontWeight: FontWeight.w700,
619
+ letterSpacing: 1.5,
620
+ ),
621
+ ),
622
+ const SizedBox(width: 10),
623
+ const Expanded(child: Divider(color: _C.sep, height: 1)),
624
+ ],
625
+ );
582
626
 
583
627
  Widget _panelBox({required Widget child}) => Container(
584
- width: double.infinity,
585
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
586
- decoration: BoxDecoration(
587
- color: _C.inner,
588
- borderRadius: BorderRadius.circular(8),
589
- border: Border.all(color: _C.sep),
590
- ),
591
- child: child,
592
- );
628
+ width: double.infinity,
629
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
630
+ decoration: BoxDecoration(
631
+ color: _C.inner,
632
+ borderRadius: BorderRadius.circular(8),
633
+ border: Border.all(color: _C.sep),
634
+ ),
635
+ child: child,
636
+ );
593
637
 
594
638
  Widget _dot(Color c) => Container(
595
- width: 11,
596
- height: 11,
597
- decoration: BoxDecoration(color: c, shape: BoxShape.circle),
598
- );
639
+ width: 11,
640
+ height: 11,
641
+ decoration: BoxDecoration(color: c, shape: BoxShape.circle),
642
+ );
599
643
 
600
644
  Widget _pill(String t, Color c) => Container(
601
- padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
602
- decoration: BoxDecoration(
603
- color: c.withValues(alpha: 0.15),
604
- borderRadius: BorderRadius.circular(6),
605
- border: Border.all(color: c.withValues(alpha: 0.5)),
606
- ),
607
- child: Text(t,
608
- style: TextStyle(
609
- color: c,
610
- fontFamily: _mono,
611
- fontSize: 10,
612
- fontWeight: FontWeight.w700,
613
- letterSpacing: 0.5)),
614
- );
645
+ padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
646
+ decoration: BoxDecoration(
647
+ color: c.withValues(alpha: 0.15),
648
+ borderRadius: BorderRadius.circular(6),
649
+ border: Border.all(color: c.withValues(alpha: 0.5)),
650
+ ),
651
+ child: Text(
652
+ t,
653
+ style: TextStyle(
654
+ color: c,
655
+ fontFamily: _mono,
656
+ fontSize: 10,
657
+ fontWeight: FontWeight.w700,
658
+ letterSpacing: 0.5,
659
+ ),
660
+ ),
661
+ );
615
662
  }
616
663
 
617
664
  /// A single-line determinate progress bar that never wraps.
@@ -742,19 +789,20 @@ class _TerminalLogState extends State<_TerminalLog> {
742
789
  style: const TextStyle(fontFamily: _mono, fontSize: 12),
743
790
  children: [
744
791
  TextSpan(
745
- text: '${match.group(1)!.padRight(8)} ',
746
- style: TextStyle(color: color)),
792
+ text: '${match.group(1)!.padRight(8)} ',
793
+ style: TextStyle(color: color),
794
+ ),
747
795
  TextSpan(
748
- text: match.group(3),
749
- style: const TextStyle(color: _C.text)),
796
+ text: match.group(3),
797
+ style: const TextStyle(color: _C.text),
798
+ ),
750
799
  ],
751
800
  ),
752
801
  );
753
802
  } else {
754
803
  content = Text(
755
804
  line.text,
756
- style: TextStyle(
757
- fontFamily: _mono, fontSize: 12, color: color),
805
+ style: TextStyle(fontFamily: _mono, fontSize: 12, color: color),
758
806
  );
759
807
  }
760
808
 
@@ -781,6 +829,14 @@ class OtaOverlayManager {
781
829
  bool _disposed = false;
782
830
  DateTime? _startTime;
783
831
 
832
+ /// Completes once the active overlay entry has been removed. [begin] resets
833
+ /// it for every session; `FlutterPatcher.applyUpdate` awaits it so the
834
+ /// forced-update restart can never kill a still-visible install screen.
835
+ Completer<void>? _dismissed;
836
+
837
+ Future<void> get _dismissedFuture =>
838
+ _dismissed?.future ?? Future<void>.value();
839
+
784
840
  /// Last 10%-bucket we emitted a "download NN%" log line for, to throttle the
785
841
  /// feed. -1 = none yet.
786
842
  int _lastLoggedPctBucket = -1;
@@ -820,6 +876,7 @@ class OtaOverlayManager {
820
876
 
821
877
  _startTime = DateTime.now();
822
878
  _lastLoggedPctBucket = -1;
879
+ _dismissed = Completer<void>();
823
880
 
824
881
  final logs = <LogLine>[
825
882
  if (channel != null) LogLine('gray', 'channel $channel'),
@@ -943,7 +1000,12 @@ class OtaOverlayManager {
943
1000
  }
944
1001
 
945
1002
  void _end({bool hasError = false, String? errorText}) {
946
- if (_disposed || _entry == null) return;
1003
+ if (_disposed || _entry == null) {
1004
+ if (_dismissed != null && !_dismissed!.isCompleted) {
1005
+ _dismissed!.complete();
1006
+ }
1007
+ return;
1008
+ }
947
1009
  if (hasError) {
948
1010
  _log('red', 'error ${errorText ?? 'unknown'}');
949
1011
  _log('yellow', 'action rolling back to base apk');
@@ -977,6 +1039,9 @@ class OtaOverlayManager {
977
1039
  if (_disposed) return;
978
1040
  entry?.remove();
979
1041
  if (identical(_entry, entry)) _entry = null;
1042
+ if (_dismissed != null && !_dismissed!.isCompleted) {
1043
+ _dismissed!.complete();
1044
+ }
980
1045
  });
981
1046
  }
982
1047
 
@@ -1031,10 +1096,7 @@ class OtaOverlayManager {
1031
1096
  ),
1032
1097
  Text(
1033
1098
  'Restored: $previousVersion',
1034
- style: TextStyle(
1035
- color: Colors.grey[400],
1036
- fontSize: 12,
1037
- ),
1099
+ style: TextStyle(color: Colors.grey[400], fontSize: 12),
1038
1100
  ),
1039
1101
  ],
1040
1102
  ),
@@ -1064,4 +1126,8 @@ class OtaOverlayHandle {
1064
1126
 
1065
1127
  void end({bool hasError = false, String? errorText}) =>
1066
1128
  _manager._end(hasError: hasError, errorText: errorText);
1129
+
1130
+ /// Completes when the overlay has fully unmounted (dwell elapsed + removed),
1131
+ /// or immediately if no overlay session is active.
1132
+ Future<void> get dismissed => _manager._dismissedFuture;
1067
1133
  }
@@ -83,6 +83,16 @@ class PatcherChannel {
83
83
  return channel.invokeMethod<String>('currentVersion');
84
84
  }
85
85
 
86
+ /// Stable per-install device identifier, persisted natively (SharedPreferences).
87
+ ///
88
+ /// Generated once on first call and never changes for the app install (even
89
+ /// across patch updates and base APK upgrades), making it a suitable seed for
90
+ /// rollout cohort hashing. Throws [MissingPluginException] on non-Android
91
+ /// platforms and on Android bases compiled before this method existed.
92
+ static Future<String?> deviceId() async {
93
+ return channel.invokeMethod<String>('deviceId');
94
+ }
95
+
86
96
  /// Diagnostics from the last cold-start patch load.
87
97
  ///
88
98
  /// The native side returns `Map?` (see `BootDiagnosticStore`), deserialized by the
@@ -137,7 +147,10 @@ class PatcherChannel {
137
147
  }
138
148
 
139
149
  /// Checks whether a patch version (optionally with md5) is blacklisted.
140
- static Future<bool> isVersionBlacklisted(String version, {String md5 = ''}) async {
150
+ static Future<bool> isVersionBlacklisted(
151
+ String version, {
152
+ String md5 = '',
153
+ }) async {
141
154
  final found = await channel.invokeMethod<bool>('isVersionBlacklisted', {
142
155
  'version': version,
143
156
  'md5': md5,
@@ -1,4 +1,5 @@
1
- import 'package:flutter_ota_kit/src/pkg/core/flutter_ota_kit_core.dart' show Bundle;
1
+ import 'package:flutter_ota_kit/src/pkg/core/flutter_ota_kit_core.dart'
2
+ show Bundle;
2
3
 
3
4
  import 'bundle_unit_of_work.dart';
4
5
  import 'bundle_unit_of_work_store.dart';
@@ -1,4 +1,5 @@
1
- import 'package:flutter_ota_kit/src/pkg/core/flutter_ota_kit_core.dart' show Platform;
1
+ import 'package:flutter_ota_kit/src/pkg/core/flutter_ota_kit_core.dart'
2
+ show Platform;
2
3
 
3
4
  /// Database row for the `bundles` table (snake_case columns).
4
5
  ///