mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Harden unmanaged apps persistence with serialized writes, relay acceptance checks, and clearer updates-screen error handling
This commit is contained in:
+104
-57
@@ -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});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user