mirror of
https://github.com/zapstore/zapstore.git
synced 2026-09-14 03:05:06 +00:00
Misc UI fixes
This commit is contained in:
@@ -7,7 +7,9 @@ import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/utils/nostr_route.dart';
|
||||
import 'package:zapstore/utils/stack_app_ids.dart';
|
||||
import 'package:zapstore/widgets/app_card.dart';
|
||||
import 'package:zapstore/widgets/app_stack_container.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/comments_section.dart';
|
||||
import 'package:zapstore/widgets/common/badges.dart';
|
||||
@@ -75,65 +77,144 @@ class _AppStackContentWithApps extends ConsumerWidget {
|
||||
if (isEncrypted && !stack.isDecrypted) {
|
||||
return _AppStackContent(
|
||||
stack: stack,
|
||||
apps: const [],
|
||||
entries: const [],
|
||||
errorMessage:
|
||||
'This private stack could not be decrypted on this device',
|
||||
);
|
||||
}
|
||||
|
||||
// Get app addressable IDs from either tags (public) or decrypted content (private)
|
||||
final appAddressableIds = isEncrypted
|
||||
? stack.privateAppIds.toSet()
|
||||
// Public stacks use `a` tags (addressable IDs). Private stacks may store
|
||||
// addressable IDs (Saved Apps) or bare package IDs (Unmanaged Apps).
|
||||
final orderedIds = isEncrypted
|
||||
? stack.privateAppIds
|
||||
: stack.event
|
||||
.getTagSetValues('a')
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.toSet();
|
||||
.toList();
|
||||
|
||||
if (appAddressableIds.isEmpty) {
|
||||
return _AppStackContent(stack: stack, apps: const []);
|
||||
if (orderedIds.isEmpty) {
|
||||
return _AppStackContent(stack: stack, entries: const []);
|
||||
}
|
||||
|
||||
final authors = <String>{};
|
||||
final identifiers = <String>{};
|
||||
for (final id in appAddressableIds) {
|
||||
final parts = id.split(':');
|
||||
if (parts.length >= 3) {
|
||||
authors.add(parts[1]);
|
||||
identifiers.add(parts.skip(2).join(':'));
|
||||
}
|
||||
}
|
||||
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: authors,
|
||||
tags: {
|
||||
'#d': identifiers,
|
||||
'#f': {'android-arm64-v8a'},
|
||||
},
|
||||
and: (app) => {
|
||||
app.latestRelease.query(
|
||||
and: (release) => {
|
||||
release.latestMetadata.query(),
|
||||
release.latestAsset.query(),
|
||||
},
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-stack-apps-${stack.identifier}',
|
||||
),
|
||||
final (:addressableIds, :packageIds) = partitionStackAppIds(orderedIds);
|
||||
final (:authors, :identifiers) = decomposeAddressableIds(addressableIds);
|
||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||
final installed = ref.watch(
|
||||
packageManagerProvider.select((s) => s.installed),
|
||||
);
|
||||
|
||||
// Key by addressable ID and preserve the original stack order
|
||||
final appsMap = {for (final app in appsState.models) app.id: app};
|
||||
final orderedApps = appAddressableIds
|
||||
.map((id) => appsMap[id])
|
||||
.whereType<App>()
|
||||
.toList();
|
||||
final addressableState = addressableIds.isNotEmpty
|
||||
? ref.watch(
|
||||
query<App>(
|
||||
authors: authors,
|
||||
tags: {
|
||||
'#d': identifiers,
|
||||
'#f': {platform},
|
||||
},
|
||||
and: (app) => {
|
||||
app.latestRelease.query(
|
||||
and: (release) => {
|
||||
release.latestMetadata.query(),
|
||||
release.latestAsset.query(),
|
||||
},
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'app-stack-apps-${stack.identifier}',
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
return _AppStackContent(stack: stack, apps: orderedApps);
|
||||
final packageState = packageIds.isNotEmpty
|
||||
? ref.watch(
|
||||
query<App>(
|
||||
tags: {
|
||||
'#d': packageIds.toSet(),
|
||||
'#f': {platform},
|
||||
},
|
||||
and: (app) => {
|
||||
app.latestRelease.query(
|
||||
and: (release) => {
|
||||
release.latestMetadata.query(),
|
||||
release.latestAsset.query(),
|
||||
},
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'app-stack-pkgs-${stack.identifier}',
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
final appsByAddressableId = {
|
||||
for (final app in addressableState?.models ?? const <App>[]) app.id: app,
|
||||
};
|
||||
final appsByPackageId = <String, App>{};
|
||||
for (final app in packageState?.models ?? const <App>[]) {
|
||||
appsByPackageId.putIfAbsent(app.identifier, () => app);
|
||||
}
|
||||
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: orderedIds,
|
||||
foundAddressableIds: appsByAddressableId.keys.toSet(),
|
||||
foundPackageIds: appsByPackageId.keys.toSet(),
|
||||
);
|
||||
|
||||
final entries = <_StackAppEntry>[
|
||||
for (final resolution in resolutions)
|
||||
switch (resolution.kind) {
|
||||
StackAppResolveKind.catalogAddressable => _CatalogedStackAppEntry(
|
||||
appsByAddressableId[resolution.rawId]!,
|
||||
),
|
||||
StackAppResolveKind.catalogPackage => _CatalogedStackAppEntry(
|
||||
appsByPackageId[resolution.rawId]!,
|
||||
),
|
||||
StackAppResolveKind.packageFallback => _PackageStackAppEntry(
|
||||
installed[resolution.rawId] ??
|
||||
PackageInfo(
|
||||
appId: resolution.rawId,
|
||||
version: 'Unknown',
|
||||
versionCode: null,
|
||||
),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return _AppStackContent(stack: stack, entries: entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved row for the stack detail list.
|
||||
sealed class _StackAppEntry {
|
||||
const _StackAppEntry();
|
||||
|
||||
String get key;
|
||||
}
|
||||
|
||||
class _CatalogedStackAppEntry extends _StackAppEntry {
|
||||
const _CatalogedStackAppEntry(this.app);
|
||||
|
||||
final App app;
|
||||
|
||||
@override
|
||||
String get key => app.identifier;
|
||||
}
|
||||
|
||||
class _PackageStackAppEntry extends _StackAppEntry {
|
||||
const _PackageStackAppEntry(this.packageInfo);
|
||||
|
||||
final PackageInfo packageInfo;
|
||||
|
||||
@override
|
||||
String get key => packageInfo.appId;
|
||||
}
|
||||
|
||||
class _ErrorScaffold extends StatelessWidget {
|
||||
final String message;
|
||||
const _ErrorScaffold({required this.message});
|
||||
@@ -203,35 +284,39 @@ class _NotFoundScaffold extends StatelessWidget {
|
||||
/// Internal widget that displays stack details
|
||||
class _AppStackContent extends HookConsumerWidget {
|
||||
final AppStack stack;
|
||||
final List<App> apps;
|
||||
final List<_StackAppEntry> entries;
|
||||
final String? errorMessage;
|
||||
|
||||
const _AppStackContent({
|
||||
required this.stack,
|
||||
required this.apps,
|
||||
required this.entries,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Query author profile from social relays
|
||||
final authorState = ref.watch(
|
||||
query<Profile>(
|
||||
authors: {stack.pubkey},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'social', 'vertex'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
),
|
||||
subscriptionPrefix: 'app-stack-profile',
|
||||
),
|
||||
);
|
||||
final author = authorState.models.firstOrNull;
|
||||
final isAuthorLoading = authorState is StorageLoading && author == null;
|
||||
final isPrivate = stack.content.isNotEmpty;
|
||||
|
||||
// Author is only shown for public stacks; skip the query for private ones.
|
||||
final authorState = isPrivate
|
||||
? null
|
||||
: ref.watch(
|
||||
query<Profile>(
|
||||
authors: {stack.pubkey},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'social', 'vertex'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
),
|
||||
subscriptionPrefix: 'app-stack-profile',
|
||||
),
|
||||
);
|
||||
final author = authorState?.models.firstOrNull;
|
||||
final isAuthorLoading =
|
||||
authorState is StorageLoading && author == null;
|
||||
|
||||
// Sort apps: uninstalled first, keeping original order otherwise
|
||||
final packageManager = ref.watch(packageManagerProvider.notifier);
|
||||
final sortedApps = _sortAppsUninstalledFirst(apps, packageManager);
|
||||
final totalApps = sortedApps.length;
|
||||
final sortedEntries = _sortEntriesUninstalledFirst(entries, packageManager);
|
||||
final totalApps = sortedEntries.length;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
@@ -251,6 +336,18 @@ class _AppStackContent extends HookConsumerWidget {
|
||||
isAuthorLoading: isAuthorLoading,
|
||||
),
|
||||
),
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
// Apps section header with count badge
|
||||
Padding(
|
||||
@@ -271,13 +368,17 @@ class _AppStackContent extends HookConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Apps list
|
||||
if (sortedApps.isEmpty)
|
||||
if (sortedEntries.isEmpty)
|
||||
_EmptyAppsPlaceholder()
|
||||
else
|
||||
...sortedApps.map(
|
||||
(app) =>
|
||||
AppCard(app: app, showUpdateArrow: app.hasUpdate),
|
||||
),
|
||||
...sortedEntries.map((entry) => switch (entry) {
|
||||
_CatalogedStackAppEntry(:final app) => AppCard(
|
||||
app: app,
|
||||
showUpdateArrow: app.hasUpdate,
|
||||
),
|
||||
_PackageStackAppEntry(:final packageInfo) =>
|
||||
_StackPackageCard(packageInfo: packageInfo),
|
||||
}),
|
||||
// Comments section - hidden for private/encrypted stacks
|
||||
if (stack.content.isEmpty)
|
||||
Padding(
|
||||
@@ -297,19 +398,20 @@ class _AppStackContent extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Sort apps so uninstalled ones come first, keeping original order otherwise
|
||||
List<App> _sortAppsUninstalledFirst(
|
||||
List<App> apps,
|
||||
/// Sort so uninstalled entries come first, keeping original order otherwise.
|
||||
List<_StackAppEntry> _sortEntriesUninstalledFirst(
|
||||
List<_StackAppEntry> entries,
|
||||
PackageManager packageManager,
|
||||
) {
|
||||
final uninstalled = <App>[];
|
||||
final installed = <App>[];
|
||||
final uninstalled = <_StackAppEntry>[];
|
||||
final installed = <_StackAppEntry>[];
|
||||
|
||||
for (final app in apps) {
|
||||
if (packageManager.isInstalled(app.identifier)) {
|
||||
installed.add(app);
|
||||
for (final entry in entries) {
|
||||
final id = entry.key;
|
||||
if (packageManager.isInstalled(id)) {
|
||||
installed.add(entry);
|
||||
} else {
|
||||
uninstalled.add(app);
|
||||
uninstalled.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +419,86 @@ class _AppStackContent extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback card for bare package IDs with no catalog App metadata.
|
||||
class _StackPackageCard extends StatelessWidget {
|
||||
const _StackPackageCard({required this.packageInfo});
|
||||
|
||||
final PackageInfo packageInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.android,
|
||||
color: AppColors.darkOnSurfaceSecondary,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
packageInfo.name ?? packageInfo.appId,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
packageInfo.appId,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.darkOnSurfaceSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 9,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.darkPillBackground,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
packageInfo.version,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack header with name and author
|
||||
class _StackHeader extends StatelessWidget {
|
||||
const _StackHeader({
|
||||
@@ -360,17 +542,18 @@ class _StackHeader extends StatelessWidget {
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Published by author - always show, with fallback to npub
|
||||
AuthorContainer(
|
||||
profile: author,
|
||||
pubkey: stack.pubkey,
|
||||
beforeText: 'Published by',
|
||||
oneLine: true,
|
||||
size: 14,
|
||||
isLoading: isAuthorLoading,
|
||||
onTap: () => pushUser(context, stack.pubkey),
|
||||
),
|
||||
if (!_isEncrypted) ...[
|
||||
const SizedBox(height: 8),
|
||||
AuthorContainer(
|
||||
profile: author,
|
||||
pubkey: stack.pubkey,
|
||||
beforeText: 'Published by',
|
||||
oneLine: true,
|
||||
size: 14,
|
||||
isLoading: isAuthorLoading,
|
||||
onTap: () => pushUser(context, stack.pubkey),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
// Metadata row: updated timestamp + private indicator
|
||||
Row(
|
||||
|
||||
@@ -1738,12 +1738,6 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Background auto-updates are enabled, but the first check '
|
||||
'could not be scheduled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@@ -184,13 +184,14 @@ class _UpdatesList extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final automaticUpdates = categorized.automaticUpdates;
|
||||
final manualUpdates = categorized.manualUpdates;
|
||||
final availableAutomaticUpdates = categorized.automaticUpdates;
|
||||
final availableManualUpdates = categorized.manualUpdates;
|
||||
final upToDateApps = categorized.upToDateApps;
|
||||
final uncatalogedApps = categorized.uncatalogedApps;
|
||||
final unmanagedApps = categorized.unmanagedApps;
|
||||
|
||||
// Resolve installing apps (active operations not already in update lists)
|
||||
// Resolve all active operations into one section. Active updates are
|
||||
// removed from their available-update sections below to avoid duplicates.
|
||||
final operations = ref.watch(
|
||||
packageManagerProvider.select((s) => s.operations),
|
||||
);
|
||||
@@ -199,36 +200,34 @@ class _UpdatesList extends ConsumerWidget {
|
||||
.map((entry) => entry.key)
|
||||
.toSet();
|
||||
|
||||
final updateAppIds = {
|
||||
...automaticUpdates.map((a) => a.identifier),
|
||||
...manualUpdates.map((a) => a.identifier),
|
||||
};
|
||||
final automaticUpdates = availableAutomaticUpdates
|
||||
.where((app) => !activeAppIds.contains(app.identifier))
|
||||
.toList();
|
||||
final manualUpdates = availableManualUpdates
|
||||
.where((app) => !activeAppIds.contains(app.identifier))
|
||||
.toList();
|
||||
|
||||
final List<App> installingApps;
|
||||
final List<App> inProgressApps;
|
||||
if (activeAppIds.isEmpty) {
|
||||
installingApps = const [];
|
||||
inProgressApps = const [];
|
||||
} else {
|
||||
final installingAppsState = ref.watch(
|
||||
final inProgressAppsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {'#d': activeAppIds},
|
||||
and: (app) => {app.latestRelease.query()},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
|
||||
subscriptionPrefix: 'app-installing-apps',
|
||||
subscriptionPrefix: 'app-in-progress-apps',
|
||||
),
|
||||
);
|
||||
installingApps = installingAppsState.models
|
||||
.where(
|
||||
(app) =>
|
||||
activeAppIds.contains(app.identifier) &&
|
||||
!updateAppIds.contains(app.identifier),
|
||||
)
|
||||
inProgressApps = inProgressAppsState.models
|
||||
.where((app) => activeAppIds.contains(app.identifier))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Empty state: only show if there are truly no apps at all (unmanaged ones don't count)
|
||||
if (automaticUpdates.isEmpty &&
|
||||
manualUpdates.isEmpty &&
|
||||
installingApps.isEmpty &&
|
||||
inProgressApps.isEmpty &&
|
||||
upToDateApps.isEmpty &&
|
||||
uncatalogedApps.isEmpty &&
|
||||
unmanagedApps.isEmpty) {
|
||||
@@ -297,12 +296,12 @@ class _UpdatesList extends ConsumerWidget {
|
||||
if (allUpdates.length > 1)
|
||||
SliverToBoxAdapter(child: UpdateAllRow(allUpdates: allUpdates)),
|
||||
const SliverToBoxAdapter(child: _LastCheckedIndicator()),
|
||||
if (installingApps.isNotEmpty)
|
||||
if (inProgressApps.isNotEmpty)
|
||||
_AppSection(
|
||||
icon: Icons.downloading,
|
||||
title: 'Installing',
|
||||
apps: installingApps,
|
||||
keyPrefix: 'installing',
|
||||
title: 'In Progress',
|
||||
apps: inProgressApps,
|
||||
keyPrefix: 'in-progress',
|
||||
showUpdateButton: true,
|
||||
showZapEncouragement: true,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/// Whether [id] is a Nostr addressable app coordinate (`kind:pubkey:d`).
|
||||
///
|
||||
/// Private stacks may store either these coordinates (e.g. Saved Apps) or bare
|
||||
/// Android package IDs (e.g. Unmanaged Apps).
|
||||
bool isAddressableAppId(String id) {
|
||||
final parts = id.split(':');
|
||||
return parts.length >= 3 && int.tryParse(parts.first) != null;
|
||||
}
|
||||
|
||||
/// Split stack member IDs into addressable coordinates vs bare package IDs.
|
||||
({List<String> addressableIds, List<String> packageIds}) partitionStackAppIds(
|
||||
Iterable<String> ids,
|
||||
) {
|
||||
final addressableIds = <String>[];
|
||||
final packageIds = <String>[];
|
||||
for (final id in ids) {
|
||||
if (isAddressableAppId(id)) {
|
||||
addressableIds.add(id);
|
||||
} else if (id.isNotEmpty) {
|
||||
packageIds.add(id);
|
||||
}
|
||||
}
|
||||
return (addressableIds: addressableIds, packageIds: packageIds);
|
||||
}
|
||||
|
||||
/// How a single stack member ID should be rendered.
|
||||
enum StackAppResolveKind {
|
||||
/// Matched a catalog [App] via addressable ID (`app.id`).
|
||||
catalogAddressable,
|
||||
|
||||
/// Matched a catalog [App] via bare package ID (`app.identifier`).
|
||||
catalogPackage,
|
||||
|
||||
/// No catalog match — render installed/unknown package metadata.
|
||||
packageFallback,
|
||||
}
|
||||
|
||||
class StackAppResolution {
|
||||
const StackAppResolution({required this.rawId, required this.kind});
|
||||
|
||||
final String rawId;
|
||||
final StackAppResolveKind kind;
|
||||
}
|
||||
|
||||
/// Resolve ordered stack IDs into display strategies.
|
||||
///
|
||||
/// Catalog matches win. Unmatched bare package IDs still produce a
|
||||
/// [StackAppResolveKind.packageFallback] entry so unmanaged apps remain visible.
|
||||
List<StackAppResolution> resolveStackAppIds({
|
||||
required Iterable<String> orderedIds,
|
||||
required Set<String> foundAddressableIds,
|
||||
required Set<String> foundPackageIds,
|
||||
}) {
|
||||
final resolutions = <StackAppResolution>[];
|
||||
for (final id in orderedIds) {
|
||||
if (isAddressableAppId(id)) {
|
||||
if (foundAddressableIds.contains(id)) {
|
||||
resolutions.add(
|
||||
StackAppResolution(
|
||||
rawId: id,
|
||||
kind: StackAppResolveKind.catalogAddressable,
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundPackageIds.contains(id)) {
|
||||
resolutions.add(
|
||||
StackAppResolution(rawId: id, kind: StackAppResolveKind.catalogPackage),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
resolutions.add(
|
||||
StackAppResolution(rawId: id, kind: StackAppResolveKind.packageFallback),
|
||||
);
|
||||
}
|
||||
return resolutions;
|
||||
}
|
||||
@@ -55,6 +55,10 @@ cross-device sync.
|
||||
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.
|
||||
- Stack detail (`AppStackScreen`) must treat bare package IDs in
|
||||
`privateAppIds` as package IDs: query Apps by `#d`, fall back to
|
||||
installed `PackageInfo`. Saved Apps keep addressable `kind:pubkey:d`
|
||||
coordinates; Unmanaged Apps intentionally do not.
|
||||
- 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,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zapstore/utils/stack_app_ids.dart';
|
||||
|
||||
void main() {
|
||||
group('isAddressableAppId', () {
|
||||
test('accepts kind:pubkey:identifier coordinates', () {
|
||||
expect(
|
||||
isAddressableAppId('32267:${'a' * 64}:com.example.app'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects bare package IDs used by unmanaged apps', () {
|
||||
expect(isAddressableAppId('com.example.app'), isFalse);
|
||||
expect(isAddressableAppId('dev.zapstore.app'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('partitionStackAppIds', () {
|
||||
test('splits mixed stacks into addressable and package IDs', () {
|
||||
final addressable = '32267:${'b' * 64}:com.cataloged.app';
|
||||
final partitioned = partitionStackAppIds([
|
||||
addressable,
|
||||
'com.uncataloged.app',
|
||||
'',
|
||||
]);
|
||||
|
||||
expect(partitioned.addressableIds, [addressable]);
|
||||
expect(partitioned.packageIds, ['com.uncataloged.app']);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveStackAppIds', () {
|
||||
test('unmanaged bare package IDs fall back when not in catalog', () {
|
||||
const packageId = 'com.example.unmanaged';
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [packageId],
|
||||
foundAddressableIds: const {},
|
||||
foundPackageIds: const {},
|
||||
);
|
||||
|
||||
expect(resolutions, hasLength(1));
|
||||
expect(resolutions.single.rawId, packageId);
|
||||
expect(resolutions.single.kind, StackAppResolveKind.packageFallback);
|
||||
});
|
||||
|
||||
test('cataloged package IDs prefer catalog match over fallback', () {
|
||||
const packageId = 'com.example.cataloged';
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [packageId],
|
||||
foundAddressableIds: const {},
|
||||
foundPackageIds: {packageId},
|
||||
);
|
||||
|
||||
expect(resolutions.single.kind, StackAppResolveKind.catalogPackage);
|
||||
});
|
||||
|
||||
test('addressable IDs resolve when present in catalog', () {
|
||||
final addressable = '32267:${'d' * 64}:com.saved.app';
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [addressable],
|
||||
foundAddressableIds: {addressable},
|
||||
foundPackageIds: const {},
|
||||
);
|
||||
|
||||
expect(resolutions, hasLength(1));
|
||||
expect(resolutions.single.kind, StackAppResolveKind.catalogAddressable);
|
||||
});
|
||||
|
||||
test('addressable IDs without catalog match are omitted', () {
|
||||
final addressable = '32267:${'d' * 64}:com.missing.app';
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [addressable],
|
||||
foundAddressableIds: const {},
|
||||
foundPackageIds: const {},
|
||||
);
|
||||
|
||||
expect(resolutions, isEmpty);
|
||||
});
|
||||
|
||||
test('preserves stack order across cataloged and package entries', () {
|
||||
const first = 'com.first';
|
||||
const second = 'com.second';
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [first, second],
|
||||
foundAddressableIds: const {},
|
||||
foundPackageIds: {second},
|
||||
);
|
||||
|
||||
expect(resolutions.map((r) => r.rawId), [first, second]);
|
||||
expect(resolutions[0].kind, StackAppResolveKind.packageFallback);
|
||||
expect(resolutions[1].kind, StackAppResolveKind.catalogPackage);
|
||||
});
|
||||
|
||||
test(
|
||||
'regression: bare unmanaged package IDs still produce visible entries',
|
||||
() {
|
||||
// Old stack-screen logic only decomposed kind:pubkey:d IDs, so bare
|
||||
// package IDs produced empty author/identifier filters and no rows.
|
||||
const packageId = 'org.thoughtcrime.securesms';
|
||||
final partitioned = partitionStackAppIds([packageId]);
|
||||
expect(partitioned.addressableIds, isEmpty);
|
||||
expect(partitioned.packageIds, [packageId]);
|
||||
|
||||
final resolutions = resolveStackAppIds(
|
||||
orderedIds: [packageId],
|
||||
foundAddressableIds: const {},
|
||||
foundPackageIds: const {},
|
||||
);
|
||||
expect(resolutions, hasLength(1));
|
||||
expect(resolutions.single.kind, StackAppResolveKind.packageFallback);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user