Harden unmanaged apps persistence with serialized writes, relay acceptance checks, and clearer updates-screen error handling

This commit is contained in:
franzap
2026-07-02 11:10:43 -03:00
parent 397492b509
commit 8c3b679ce7
5 changed files with 515 additions and 147 deletions
+104 -57
View File
@@ -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(<double>[
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<String>('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<void> _runUnmanagedAction(
BuildContext notificationContext,
BuildContext slidableContext,
Future<void> 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});
+236 -69
View File
@@ -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<Set<String>>((ref) {
class _UnmanagedAppsSnapshot {
const _UnmanagedAppsSnapshot(this.ids, this.createdAt);
final Set<String> 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<AppStack>(
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<void> Function(Set<String> 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<Set<String>> {
UnmanagedAppsNotifier(this._write, {DateTime Function()? clock})
: _clock = clock ?? DateTime.now,
super(const {});
static const _setEquality = SetEquality<String>();
final UnmanagedAppsWriter _write;
final DateTime Function() _clock;
Future<void> _writeQueue = Future.value();
Set<String> _lastPersisted = const {};
DateTime? _lastPersistedAt;
DateTime? _lastIssuedAt;
int _pendingWrites = 0;
void acceptPersisted(Set<String> 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<void> 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<String>.unmodifiable(updated);
final createdAt = _nextCreatedAt();
final result = Completer<void>();
_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<String> 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<UnmanagedAppsNotifier, Set<String>>((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<String> 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<void> _writeUnmanagedApps(
Ref ref,
Set<String> 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<void> 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<String> current = [];
try {
final existing = await storage.query(
RequestFilter<AppStack>(
authors: {devicePubkey},
tags: {'#d': {kUnmanagedAppsIdentifier}},
).toRequest(),
source: const LocalSource(),
subscriptionPrefix: 'app-unmanaged-apps-write',
);
final stack = existing.firstOrNull;
if (stack != null) {
current = List<String>.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);
}
+15 -21
View File
@@ -37,6 +37,7 @@ class CategorizedUpdates {
final List<App> manualUpdates;
final List<App> upToDateApps;
final List<PackageInfo> uncatalogedApps;
/// Apps the user has explicitly marked unmanaged (excluded from all other lists).
final List<PackageInfo> unmanagedApps;
final bool showSkeleton;
@@ -118,12 +119,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
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<Set<String>>(unmanagedAppsProvider, (prev, next) {
if (prev != next) unawaited(refreshFromLocal());
});
}
Future<void> _hydrateAndStartPolling() async {
@@ -411,18 +406,19 @@ final categorizedUpdatesProvider = Provider<CategorizedUpdates>((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<CategorizedUpdates>((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(),
+20
View File
@@ -20,6 +20,15 @@ cross-device sync.
- [x] Add unmanagedApps: List<PackageInfo> 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.
@@ -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 = <Set<String>>[];
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<void>();
final writes = <Set<String>>[];
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 = <DateTime>[];
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 = <DateTime>[];
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<UnmanagedAppsSaveException>()));
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);
});
});
}