From 8c3b679ce7125bca86ee7b998e2aada4058cc2ac Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 2 Jul 2026 11:10:43 -0300 Subject: [PATCH] Harden unmanaged apps persistence with serialized writes, relay acceptance checks, and clearer updates-screen error handling --- lib/screens/updates_screen.dart | 161 +++++---- lib/services/unmanaged_apps_service.dart | 305 ++++++++++++++---- lib/services/updates_service.dart | 36 +-- spec/work/WORK-010-unmanaged-apps.md | 20 ++ .../services/unmanaged_apps_service_test.dart | 140 ++++++++ 5 files changed, 515 insertions(+), 147 deletions(-) create mode 100644 test/services/unmanaged_apps_service_test.dart diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index b9118e2..264bd55 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -5,7 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/router.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/unmanaged_apps_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/updates_service.dart'; @@ -15,11 +17,12 @@ import 'package:zapstore/widgets/batch_progress_banner.dart'; import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/app_card.dart'; -class UpdatesScreen extends ConsumerWidget { +class UpdatesScreen extends HookConsumerWidget { const UpdatesScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final scrollController = useScrollController(); final categorized = ref.watch(categorizedUpdatesProvider); if (categorized.showSkeleton) { @@ -34,7 +37,10 @@ class UpdatesScreen extends ConsumerWidget { return Scaffold( body: Padding( padding: const EdgeInsets.only(top: 16), - child: _UpdatesList(categorized: categorized), + child: _UpdatesList( + categorized: categorized, + scrollController: scrollController, + ), ), ); } @@ -100,8 +106,9 @@ class _LastCheckedIndicator extends HookConsumerWidget { ? 'Checking for updates...' : 'Last checked: ${_formatRelativeTime(lastCheckTime)}'; - final mutedColor = - Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5); + final mutedColor = Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5); return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -167,9 +174,13 @@ class _LastCheckedIndicator extends HookConsumerWidget { } class _UpdatesList extends ConsumerWidget { - const _UpdatesList({required this.categorized}); + const _UpdatesList({ + required this.categorized, + required this.scrollController, + }); final CategorizedUpdates categorized; + final ScrollController scrollController; @override Widget build(BuildContext context, WidgetRef ref) { @@ -229,10 +240,26 @@ class _UpdatesList extends ConsumerWidget { children: [ ColorFiltered( colorFilter: const ColorFilter.matrix([ - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, ]), child: const Text('\u{1F389}', style: TextStyle(fontSize: 48)), ), @@ -264,6 +291,8 @@ class _UpdatesList extends ConsumerWidget { strokeWidth: 0, onRefresh: () => ref.read(updatePollerProvider.notifier).checkNow(), child: CustomScrollView( + key: const PageStorageKey('updates-list'), + controller: scrollController, slivers: [ if (allUpdates.length > 1) SliverToBoxAdapter(child: UpdateAllRow(allUpdates: allUpdates)), @@ -394,53 +423,51 @@ class _AppSection extends ConsumerWidget { final hasDeviceKey = ref.watch(devicePubkeyProvider) != null; return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == 0) { - return _SectionHeader( - icon: icon, - title: title, - count: apps.length, - trailing: headerTrailing, - ); - } - final app = apps[index - 1]; - final card = AppCard( - key: ValueKey('${keyPrefix}_${app.identifier}'), - app: app, - showUpdateArrow: showUpdateArrow, - showUpdateButton: showUpdateButton, - showZapEncouragement: showZapEncouragement, - showDescription: false, + delegate: SliverChildBuilderDelegate((context, index) { + if (index == 0) { + return _SectionHeader( + icon: icon, + title: title, + count: apps.length, + trailing: headerTrailing, ); + } + final app = apps[index - 1]; + final card = AppCard( + key: ValueKey('${keyPrefix}_${app.identifier}'), + app: app, + showUpdateArrow: showUpdateArrow, + showUpdateButton: showUpdateButton, + showZapEncouragement: showZapEncouragement, + showDescription: false, + ); - if (!hasDeviceKey) return card; + if (!hasDeviceKey) return card; - return Slidable( - key: ValueKey('slidable_${keyPrefix}_${app.identifier}'), - endActionPane: ActionPane( - motion: const BehindMotion(), - extentRatio: 0.22, - children: [ - SlidableAction( - onPressed: (_) => toggleUnmanagedApp( - ref, - app.identifier, - unmanage: true, - ), - backgroundColor: Colors.orange.shade800, - foregroundColor: Colors.white, - icon: Icons.do_not_disturb_on_outlined, - label: 'Unmanage', - borderRadius: BorderRadius.circular(16), + return Slidable( + key: ValueKey('slidable_${keyPrefix}_${app.identifier}'), + endActionPane: ActionPane( + motion: const BehindMotion(), + extentRatio: 0.22, + children: [ + SlidableAction( + autoClose: false, + onPressed: (actionContext) => _runUnmanagedAction( + context, + actionContext, + () => toggleUnmanagedApp(ref, app.identifier, unmanage: true), ), - ], - ), - child: card, - ); - }, - childCount: apps.length + 1, - ), + backgroundColor: Colors.orange.shade800, + foregroundColor: Colors.white, + icon: Icons.do_not_disturb_on_outlined, + label: 'Unmanage', + borderRadius: BorderRadius.circular(16), + ), + ], + ), + child: card, + ); + }, childCount: apps.length + 1), ); } } @@ -481,10 +508,7 @@ class _SectionHeader extends StatelessWidget { Text(title, style: context.textTheme.titleMedium), const SizedBox(width: 8), CountBadge(count: count, color: AppColors.darkPillBackground), - if (trailing != null) ...[ - const SizedBox(width: 4), - trailing!, - ], + if (trailing != null) ...[const SizedBox(width: 4), trailing!], ], ), if (hint != null) ...[ @@ -567,7 +591,12 @@ class _SlidablePackageCard extends ConsumerWidget { extentRatio: 0.22, children: [ SlidableAction( - onPressed: (_) => onAction(ref), + autoClose: false, + onPressed: (actionContext) => _runUnmanagedAction( + context, + actionContext, + () => onAction(ref), + ), backgroundColor: actionColor, foregroundColor: Colors.white, icon: actionIcon, @@ -581,6 +610,24 @@ class _SlidablePackageCard extends ConsumerWidget { } } +Future _runUnmanagedAction( + BuildContext notificationContext, + BuildContext slidableContext, + Future Function() action, +) async { + try { + await Slidable.of(slidableContext)?.close(); + await action(); + } catch (error) { + final context = rootNavigatorKey.currentContext ?? notificationContext; + if (!context.mounted) return; + final message = error is UnmanagedAppsPublishException + ? 'Saved locally, but could not sync unmanaged apps' + : 'Could not update unmanaged apps'; + context.showError(message, technicalDetails: error.toString()); + } +} + class _PackageCard extends StatelessWidget { const _PackageCard({required this.packageInfo}); diff --git a/lib/services/unmanaged_apps_service.dart b/lib/services/unmanaged_apps_service.dart index b77c85f..f8cb1e4 100644 --- a/lib/services/unmanaged_apps_service.dart +++ b/lib/services/unmanaged_apps_service.dart @@ -1,100 +1,267 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/log_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; -/// Reactive set of package IDs the user has chosen as unmanaged. -/// -/// Backed by an encrypted AppStack signed by the device key. Auto-decrypted -/// by EncryptableModel since the device signer is always registered. -final unmanagedAppsProvider = Provider>((ref) { +class _UnmanagedAppsSnapshot { + const _UnmanagedAppsSnapshot(this.ids, this.createdAt); + + final Set ids; + final DateTime? createdAt; +} + +/// Returns `null` while an encrypted stack is still being decrypted. Keeping +/// that state distinct from an empty stack prevents the UI from briefly +/// forgetting every unmanaged app after each write. +final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>(( + ref, +) { final devicePubkey = ref.watch(devicePubkeyProvider); - if (devicePubkey == null) return const {}; + if (devicePubkey == null) { + return const _UnmanagedAppsSnapshot({}, null); + } - final state = ref.watch( + final stackState = ref.watch( query( authors: {devicePubkey}, tags: { '#d': {kUnmanagedAppsIdentifier}, }, - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: true, - ), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), subscriptionPrefix: 'app-unmanaged-apps', ), ); - final stack = switch (state) { - StorageLoading() => null, - StorageError() => null, + final stack = switch (stackState) { + StorageLoading() || StorageError() => null, StorageData(:final models) => models.firstOrNull, }; - if (stack == null) return const {}; - return stack.privateAppIds.toSet(); + if (stack == null) { + return stackState is StorageData + ? const _UnmanagedAppsSnapshot({}, null) + : null; + } + if (!stack.isDecrypted) return null; + + return _UnmanagedAppsSnapshot(stack.privateAppIds.toSet(), stack.createdAt); }); +typedef UnmanagedAppsWriter = + Future Function(Set appIds, DateTime createdAt); + +/// Owns the decrypted unmanaged set and serializes writes. +/// +/// State changes optimistically so moving a card between sections does not +/// wait for SQLite, encryption, or relay I/O. Writes are queued to ensure a +/// second action includes the first action even while its publish is pending. +class UnmanagedAppsNotifier extends StateNotifier> { + UnmanagedAppsNotifier(this._write, {DateTime Function()? clock}) + : _clock = clock ?? DateTime.now, + super(const {}); + + static const _setEquality = SetEquality(); + + final UnmanagedAppsWriter _write; + final DateTime Function() _clock; + Future _writeQueue = Future.value(); + Set _lastPersisted = const {}; + DateTime? _lastPersistedAt; + DateTime? _lastIssuedAt; + int _pendingWrites = 0; + + void acceptPersisted(Set appIds, DateTime? createdAt) { + if (createdAt == null || + _lastPersistedAt == null || + !createdAt.isBefore(_lastPersistedAt!)) { + _lastPersisted = Set.unmodifiable(appIds); + _lastPersistedAt = createdAt ?? _lastPersistedAt; + } + + if (_pendingWrites > 0) return; + if (createdAt != null && + _lastIssuedAt != null && + createdAt.isBefore(_lastIssuedAt!)) { + return; + } + _setState(appIds); + } + + Future toggle(String appId, {required bool unmanage}) { + final updated = {...state}; + if (unmanage) { + updated.add(appId); + } else { + updated.remove(appId); + } + if (_setEquality.equals(updated, state)) return Future.value(); + + _setState(updated); + final snapshot = Set.unmodifiable(updated); + final createdAt = _nextCreatedAt(); + final result = Completer(); + _pendingWrites++; + + _writeQueue = _writeQueue.then((_) async { + try { + await _write(snapshot, createdAt); + result.complete(); + } catch (error, stackTrace) { + LogService.I.warn( + 'failed to persist unmanaged-apps stack', + tag: 'unmanaged-apps', + err: error, + stack: stackTrace, + ); + if (error is UnmanagedAppsSaveException && _pendingWrites == 1) { + _lastIssuedAt = _lastPersistedAt; + _setState(_lastPersisted); + } + result.completeError(error, stackTrace); + } finally { + _pendingWrites--; + } + }); + + return result.future; + } + + DateTime _nextCreatedAt() { + final now = _clock(); + final candidate = DateTime.fromMillisecondsSinceEpoch( + (now.millisecondsSinceEpoch ~/ 1000) * 1000, + isUtc: now.isUtc, + ); + final baseline = switch ((_lastIssuedAt, _lastPersistedAt)) { + (final issued?, final persisted?) => + issued.isAfter(persisted) ? issued : persisted, + (final issued?, null) => issued, + (null, final persisted?) => persisted, + (null, null) => null, + }; + final next = baseline != null && !candidate.isAfter(baseline) + ? baseline.add(const Duration(seconds: 1)) + : candidate; + _lastIssuedAt = next; + return next; + } + + void _setState(Set appIds) { + if (!_setEquality.equals(state, appIds)) { + state = Set.unmodifiable(appIds); + } + } +} + +/// Reactive set of package IDs the user has chosen as unmanaged. +/// +/// Backed by an encrypted AppStack signed by the device key. +final unmanagedAppsProvider = + StateNotifierProvider>((ref) { + final notifier = UnmanagedAppsNotifier( + (appIds, createdAt) => + _writeUnmanagedApps(ref, appIds, createdAt: createdAt), + ); + ref.listen<_UnmanagedAppsSnapshot?>(_persistedUnmanagedAppsProvider, ( + _, + snapshot, + ) { + if (snapshot != null) { + notifier.acceptPersisted(snapshot.ids, snapshot.createdAt); + } + }, fireImmediately: true); + return notifier; + }); + +class UnmanagedAppsSaveException implements Exception { + const UnmanagedAppsSaveException(this.message); + + final String message; + + @override + String toString() => message; +} + +class UnmanagedAppsPublishException implements Exception { + const UnmanagedAppsPublishException(this.message); + + final String message; + + @override + String toString() => message; +} + +bool wasUnmanagedStackAccepted(PublishResponse response, String eventId) { + return response.results[eventId]?.any((result) => result.accepted) ?? false; +} + +PartialAppStack createUnmanagedAppsStack({ + required Set appIds, + required String platform, + required DateTime createdAt, +}) { + final stack = PartialAppStack.withEncryptedApps( + name: 'Unmanaged Apps', + identifier: kUnmanagedAppsIdentifier, + apps: appIds.toList(), + platform: platform, + ); + stack.event.createdAt = createdAt; + return stack; +} + +Future _writeUnmanagedApps( + Ref ref, + Set appIds, { + required DateTime createdAt, +}) async { + final devicePubkey = ref.read(devicePubkeyProvider); + if (devicePubkey == null) { + throw const UnmanagedAppsSaveException('Device key is unavailable.'); + } + + final signer = ref.read(Signer.signerProvider(devicePubkey)); + if (signer == null) { + throw const UnmanagedAppsSaveException('Device signer is unavailable.'); + } + + final storage = ref.read(storageNotifierProvider.notifier); + final platform = ref.read(packageManagerProvider.notifier).platform; + final partial = createUnmanagedAppsStack( + appIds: appIds, + platform: platform, + createdAt: createdAt, + ); + + final signed = await partial.signWith(signer); + final saved = await storage.save({signed}); + if (!saved) { + throw const UnmanagedAppsSaveException( + 'Could not save the unmanaged apps list locally.', + ); + } + + final response = await storage.publish({signed}, relays: 'AppCatalog'); + if (!wasUnmanagedStackAccepted(response, signed.id)) { + throw const UnmanagedAppsPublishException( + 'Saved locally, but no AppCatalog relay accepted the event.', + ); + } +} + /// Toggles [appId] in the unmanaged encrypted stack. /// Pass [unmanage: true] to add, [unmanage: false] to remove. Future toggleUnmanagedApp( WidgetRef ref, String appId, { required bool unmanage, -}) async { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return; - - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) return; - - final storage = ref.read(storageNotifierProvider.notifier); - - List current = []; - try { - final existing = await storage.query( - RequestFilter( - authors: {devicePubkey}, - tags: {'#d': {kUnmanagedAppsIdentifier}}, - ).toRequest(), - source: const LocalSource(), - subscriptionPrefix: 'app-unmanaged-apps-write', - ); - final stack = existing.firstOrNull; - if (stack != null) { - current = List.from(stack.privateAppIds); - } - } catch (e, st) { - LogService.I.warn( - 'could not read existing unmanaged-apps stack', - tag: 'unmanaged-apps', - err: e, - stack: st, - ); - } - - final updated = unmanage - ? [...current.where((id) => id != appId), appId] - : current.where((id) => id != appId).toList(); - - try { - final partial = PartialAppStack.withEncryptedApps( - name: 'Unmanaged Apps', - identifier: kUnmanagedAppsIdentifier, - apps: updated, - ); - final signed = await partial.signWith(signer); - await storage.save({signed}); - unawaited(storage.publish({signed}, relays: 'AppCatalog')); - } catch (e, st) { - LogService.I.warn( - 'failed to save unmanaged-apps stack', - tag: 'unmanaged-apps', - err: e, - stack: st, - ); - } +}) { + return ref + .read(unmanagedAppsProvider.notifier) + .toggle(appId, unmanage: unmanage); } diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index c320d38..99a41ab 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -37,6 +37,7 @@ class CategorizedUpdates { final List manualUpdates; final List upToDateApps; final List uncatalogedApps; + /// Apps the user has explicitly marked unmanaged (excluded from all other lists). final List unmanagedApps; final bool showSkeleton; @@ -118,12 +119,6 @@ class UpdatePollerNotifier extends StateNotifier { unawaited(_hydrateAndStartPolling()); } }, fireImmediately: true); - - // When the unmanaged set changes, refresh immediately so the UI and - // the next relay fetch both use the updated set. - ref.listen>(unmanagedAppsProvider, (prev, next) { - if (prev != next) unawaited(refreshFromLocal()); - }); } Future _hydrateAndStartPolling() async { @@ -411,18 +406,19 @@ final categorizedUpdatesProvider = Provider((ref) { automaticUpdates: const [], manualUpdates: const [], upToDateApps: const [], - uncatalogedApps: installed.values - .where( - (pkg) => - !catalogedIds.contains(pkg.appId) && - !unmanagedIds.contains(pkg.appId), - ) - .toList() - ..sort( - (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( - (b.name ?? b.appId).toLowerCase(), - ), - ), + uncatalogedApps: + installed.values + .where( + (pkg) => + !catalogedIds.contains(pkg.appId) && + !unmanagedIds.contains(pkg.appId), + ) + .toList() + ..sort( + (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( + (b.name ?? b.appId).toLowerCase(), + ), + ), unmanagedApps: unmanagedAppsEarly, ); } @@ -493,9 +489,7 @@ final categorizedUpdatesProvider = Provider((ref) { ); final unmanagedApps = - installed.values - .where((pkg) => unmanagedIds.contains(pkg.appId)) - .toList() + installed.values.where((pkg) => unmanagedIds.contains(pkg.appId)).toList() ..sort( (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( (b.name ?? b.appId).toLowerCase(), diff --git a/spec/work/WORK-010-unmanaged-apps.md b/spec/work/WORK-010-unmanaged-apps.md index bb9eba1..f705857 100644 --- a/spec/work/WORK-010-unmanaged-apps.md +++ b/spec/work/WORK-010-unmanaged-apps.md @@ -20,6 +20,15 @@ cross-device sync. - [x] Add unmanagedApps: List to CategorizedUpdates - [x] Wrap app cards in Slidable on updates screen (swipe-left -> Unmanage) - [x] Add "Unmanaged Apps" section at bottom with swipe-left -> "Manage" action +- [x] Serialize optimistic writes so each replacement includes prior actions +- [x] Keep one Updates scroll controller while cards change sections +- [x] Close the slide action before reclassifying its card +- [x] Coalesce installed-package scans and suppress unchanged map emissions +- [x] Run native installed-package enumeration off Android's main thread +- [x] Cover overlapping installed-package scan coalescing +- [x] Include the platform tag and require explicit relay acceptance +- [x] Surface local-save and relay-publish failures to the user +- [x] Cover accumulation, overlapping writes, timestamps, tags, and failures - [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section - [ ] Extend native package scan with Android installer-source metadata - [ ] Default apps installed by known third-party app stores to unmanaged @@ -37,6 +46,17 @@ cross-device sync. - Third-party app stores should default unmanaged; Zapstore, package installer, file manager, browser, shell, and unknown/manual flows should default managed - Automatic defaults need a persistent "user has overridden this package/install" signal; otherwise tapping Manage would be undone by the next package scan - Cataloged unmanaged apps should render from App metadata when local catalog data exists, and fall back to PackageInfo only when uncataloged +- Imperative storage queries return encrypted stacks before post-load decryption, + so writes must use notifier-owned decrypted state rather than re-reading + `privateAppIds` from that path. +- Parameterized replacement writes use strictly increasing whole-second + timestamps to avoid same-second replacement collisions. +- An unmanaged-app action succeeds remotely only when an AppCatalog relay + explicitly accepts the signed device-key event. +- Installed-package scans are single-flight and do not emit a fresh installed + map when Android reports no changes. +- Android package enumeration runs on a lifecycle-owned worker so package, + signature, and installer-source reads cannot block rendering. ## Implementation Notes - Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs. diff --git a/test/services/unmanaged_apps_service_test.dart b/test/services/unmanaged_apps_service_test.dart new file mode 100644 index 0000000..db68d44 --- /dev/null +++ b/test/services/unmanaged_apps_service_test.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/unmanaged_apps_service.dart'; + +void main() { + setUpAll(() { + Model.register( + kind: 30267, + constructor: AppStack.fromMap, + partialConstructor: PartialAppStack.fromMap, + ); + }); + + group('UnmanagedAppsNotifier', () { + test('second unmanage includes the first app', () async { + final writes = >[]; + final notifier = UnmanagedAppsNotifier((appIds, _) async { + writes.add(appIds); + }); + addTearDown(notifier.dispose); + + await notifier.toggle('app.one', unmanage: true); + await notifier.toggle('app.two', unmanage: true); + + expect(notifier.state, {'app.one', 'app.two'}); + expect(writes, [ + {'app.one'}, + {'app.one', 'app.two'}, + ]); + }); + + test('serializes overlapping writes and keeps optimistic state', () async { + final firstWrite = Completer(); + final writes = >[]; + final notifier = UnmanagedAppsNotifier((appIds, _) async { + writes.add(appIds); + if (writes.length == 1) await firstWrite.future; + }); + addTearDown(notifier.dispose); + + final first = notifier.toggle('app.one', unmanage: true); + final second = notifier.toggle('app.two', unmanage: true); + + expect(notifier.state, {'app.one', 'app.two'}); + firstWrite.complete(); + await Future.wait([first, second]); + + expect(writes, [ + {'app.one'}, + {'app.one', 'app.two'}, + ]); + }); + + test('uses increasing event seconds for rapid replacements', () async { + final timestamps = []; + final now = DateTime.utc(2026, 7, 10, 20, 0, 0, 900); + final notifier = UnmanagedAppsNotifier( + (_, createdAt) async => timestamps.add(createdAt), + clock: () => now, + ); + addTearDown(notifier.dispose); + + await notifier.toggle('app.one', unmanage: true); + await notifier.toggle('app.two', unmanage: true); + + expect(timestamps, [ + DateTime.utc(2026, 7, 10, 20), + DateTime.utc(2026, 7, 10, 20, 0, 1), + ]); + }); + + test('writes after a newer persisted replacement', () async { + final timestamps = []; + final now = DateTime.utc(2026, 7, 10, 20); + final notifier = UnmanagedAppsNotifier( + (_, createdAt) async => timestamps.add(createdAt), + clock: () => now, + )..acceptPersisted({'app.remote'}, now.add(const Duration(seconds: 5))); + addTearDown(notifier.dispose); + + await notifier.toggle('app.local', unmanage: true); + + expect(timestamps, [now.add(const Duration(seconds: 6))]); + expect(notifier.state, {'app.remote', 'app.local'}); + }); + + test('rolls back optimistic state when local save fails', () async { + final persistedAt = DateTime.utc(2026, 7, 10, 19); + final notifier = UnmanagedAppsNotifier( + (_, _) async => throw const UnmanagedAppsSaveException('save failed'), + )..acceptPersisted({'app.one'}, persistedAt); + addTearDown(notifier.dispose); + + final write = notifier.toggle('app.two', unmanage: true); + expect(notifier.state, {'app.one', 'app.two'}); + + await expectLater(write, throwsA(isA())); + expect(notifier.state, {'app.one'}); + }); + }); + + test('unmanaged stack includes relay-required coordinate tags', () { + final createdAt = DateTime.utc(2026, 7, 10, 20); + final stack = createUnmanagedAppsStack( + appIds: {'app.one', 'app.two'}, + platform: 'android-arm64-v8a', + createdAt: createdAt, + ); + + expect(stack.identifier, kUnmanagedAppsIdentifier); + expect(stack.platform, 'android-arm64-v8a'); + expect(stack.privateAppIds.toSet(), {'app.one', 'app.two'}); + expect(stack.event.createdAt, createdAt); + expect(stack.event.containsTag('h'), isFalse); + }); + + group('wasUnmanagedStackAccepted', () { + test('requires explicit acceptance for the stack event', () { + final accepted = PublishResponse() + ..addEvent( + 'stack-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: true, + ); + final rejected = PublishResponse() + ..addEvent( + 'stack-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: false, + ); + + expect(wasUnmanagedStackAccepted(accepted, 'stack-id'), isTrue); + expect(wasUnmanagedStackAccepted(rejected, 'stack-id'), isFalse); + expect(wasUnmanagedStackAccepted(PublishResponse(), 'stack-id'), isFalse); + }); + }); +}