diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 1f02114..6f315ce 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -188,7 +188,7 @@ class _AppDetailContent extends HookConsumerWidget { }; final latestRelease = app.latestRelease.value; - final latestMetadata = app.latestFileMetadata; + final latestMetadata = app.installable; // Check if app is installed for menu options final installedPackage = ref.watch( @@ -391,7 +391,7 @@ class _AppDetailContent extends HookConsumerWidget { ), Gap(4), Text( - '(${formatDate(latestMetadata.createdAt)})', + '(${formatDate(latestRelease.createdAt)})', style: context.textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.6), diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index c137516..9d894ab 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -200,7 +200,7 @@ class _UserZapsList extends HookConsumerWidget { for (final entry in appTags.entries) { allAppTags[entry.key] = {...?allAppTags[entry.key], ...entry.value}; } - final metadata = app.latestFileMetadata; + final metadata = app.installable; if (metadata != null) { metadataIds.add(metadata.id); } diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index abe899e..aebd873 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -451,7 +451,7 @@ final class AndroidPackageManager extends PackageManager { /// Directly update installed package from target metadata. /// This ensures we don't depend on Android's package DB timing. - void _updateInstalledPackage(String appId, FileMetadata target) { + void _updateInstalledPackage(String appId, Installable target) { final existingPkg = state.installed[appId]; final newPkg = PackageInfo( appId: appId, @@ -482,7 +482,7 @@ final class AndroidPackageManager extends PackageManager { String filePath, { required String expectedHash, required int expectedSize, - required FileMetadata target, + required Installable target, }) async { // Permission is already checked by _proceedToInstall in base class // No need for redundant _ensureInstallPermission() call here diff --git a/lib/services/package_manager/background_package_manager.dart b/lib/services/package_manager/background_package_manager.dart index e72e275..89781e8 100644 --- a/lib/services/package_manager/background_package_manager.dart +++ b/lib/services/package_manager/background_package_manager.dart @@ -22,7 +22,7 @@ final class BackgroundPackageManager extends PackageManager { String filePath, { required String expectedHash, required int expectedSize, - required FileMetadata target, + required Installable target, }) { throw UnsupportedError('Install not supported in background'); } diff --git a/lib/services/package_manager/dummy_package_manager.dart b/lib/services/package_manager/dummy_package_manager.dart index 45778d2..da79c87 100644 --- a/lib/services/package_manager/dummy_package_manager.dart +++ b/lib/services/package_manager/dummy_package_manager.dart @@ -47,7 +47,7 @@ final class DummyPackageManager extends PackageManager { String filePath, { required String expectedHash, required int expectedSize, - required FileMetadata target, + required Installable target, }) async { // Mock: simulate install delay then add to installed await Future.delayed(const Duration(milliseconds: 500)); diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 7af1843..a42b580 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -21,8 +21,7 @@ const watchdogCheckInterval = Duration(seconds: 30); /// Represents an active install operation for an app. /// When there's no operation, the app simply has no entry in the operations map. sealed class InstallOperation { - /// The target file metadata being installed (works for both FileMetadata and SoftwareAsset) - final FileMetadata target; + final Installable target; const InstallOperation({required this.target}); } diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 246fe60..417ae2f 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -331,7 +331,7 @@ abstract class PackageManager extends StateNotifier { /// [displayName] is shown in system notification (defaults to appId if null) Future startDownload( String appId, - FileMetadata target, { + Installable target, { String? displayName, }) async { await _ensureDownloaderReady(); @@ -376,7 +376,7 @@ abstract class PackageManager extends StateNotifier { /// Queue multiple downloads at once - staggered to prevent UI flood. /// This is the primary method for "Update All" functionality. Future queueDownloads( - List<({String appId, FileMetadata target, String? displayName})> items, + List<({String appId, Installable target, String? displayName})> items, ) async { await _ensureDownloaderReady(); @@ -671,7 +671,7 @@ abstract class PackageManager extends StateNotifier { /// Advances the specified app AND all other apps awaiting permission. Future onPermissionGranted(String appId) async { // Collect all apps that need to advance (AwaitingPermission or permissionDenied failures) - final toAdvance = {}; + final toAdvance = {}; for (final entry in state.operations.entries) { final id = entry.key; @@ -708,7 +708,7 @@ abstract class PackageManager extends StateNotifier { Future _startDownloadTask( String appId, - FileMetadata target, + Installable target, String downloadUrl, { String? displayName, bool isCdnRetry = false, @@ -932,7 +932,7 @@ abstract class PackageManager extends StateNotifier { Future _handleDownloadComplete( String appId, - FileMetadata target, + Installable target, DownloadTask task, ) async { // Remove from active downloads @@ -1050,7 +1050,7 @@ abstract class PackageManager extends StateNotifier { /// Check permission and proceed to install Future _proceedToInstall( String appId, - FileMetadata target, + Installable target, String filePath, ) async { if (!await hasPermission()) { @@ -1085,7 +1085,7 @@ abstract class PackageManager extends StateNotifier { } /// Add app to install queue and trigger processing. - void _addToInstallQueue(String appId, FileMetadata target, String filePath) { + void _addToInstallQueue(String appId, Installable target, String filePath) { if (!installQueue.contains(appId)) { installQueue.add(appId); } @@ -1096,7 +1096,7 @@ abstract class PackageManager extends StateNotifier { /// Perform the actual installation Future _performInstall( String appId, - FileMetadata target, + Installable target, String filePath, ) async { try { @@ -1210,7 +1210,7 @@ abstract class PackageManager extends StateNotifier { String appId, TaskRecord record, DownloadTask task, - FileMetadata fileMetadata, + Installable fileMetadata, ) async { switch (record.status) { case TaskStatus.complete: @@ -1319,13 +1319,23 @@ abstract class PackageManager extends StateNotifier { return (metaData.isNotEmpty ? metaData : null, null, false); } - Future _loadFileMetadata( + Future _loadFileMetadata( String? metadataId, String filename, ) async { final storage = ref.read(storageNotifierProvider.notifier); if (metadataId != null) { + // Try SoftwareAsset (3063) first + try { + final assets = await storage.query( + RequestFilter(ids: {metadataId}).toRequest(), + subscriptionPrefix: 'app-file-metadata-asset', + ); + if (assets.isNotEmpty) return assets.first; + } catch (_) {} + + // Fall back to FileMetadata (1063) try { final results = await storage.query( RequestFilter(ids: {metadataId}).toRequest(), @@ -1338,6 +1348,14 @@ abstract class PackageManager extends StateNotifier { final dotIndex = filename.lastIndexOf('.'); final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; if (hash.isNotEmpty) { + try { + final assets = await storage.query( + RequestFilter(search: hash).toRequest(), + subscriptionPrefix: 'app-file-metadata-asset-search', + ); + if (assets.isNotEmpty) return assets.first; + } catch (_) {} + try { final results = await storage.query( RequestFilter(search: hash).toRequest(), @@ -1363,7 +1381,7 @@ abstract class PackageManager extends StateNotifier { String filePath, { required String expectedHash, required int expectedSize, - required FileMetadata target, + required Installable target, }); Future uninstall(String appId); @@ -1386,7 +1404,7 @@ abstract class PackageManager extends StateNotifier { /// /// Comparison uses Android versionCode only. Returns false when either /// versionCode is unavailable or the app is not installed. - bool hasUpdate(String appId, FileMetadata latest) { + bool hasUpdate(String appId, Installable latest) { final installed = state.installed[appId]; if (installed == null) return false; final installedCode = installed.versionCode; @@ -1399,7 +1417,7 @@ abstract class PackageManager extends StateNotifier { /// /// Comparison uses Android versionCode only. Returns false when either /// versionCode is unavailable or the app is not installed. - bool hasDowngrade(String appId, FileMetadata latest) { + bool hasDowngrade(String appId, Installable latest) { final installed = state.installed[appId]; if (installed == null) return false; final installedCode = installed.versionCode; diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index bc000ea..1e9a9e0 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -382,7 +382,7 @@ class CategorizedUpdatesNotifier extends Notifier { for (final app in installedApps) { final pkg = installedMap[app.identifier]!; - final latest = app.latestFileMetadata; + final latest = app.installable; // Determine if update available using versionCode (via PackageManager) final pm = ref.read(packageManagerProvider.notifier); diff --git a/lib/utils/app_query.dart b/lib/utils/app_query.dart new file mode 100644 index 0000000..6d0f8bd --- /dev/null +++ b/lib/utils/app_query.dart @@ -0,0 +1,170 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +/// Reactive query provider: fetches SoftwareAsset (3063) events and resolves +/// their parent App (32267) via the direct relationship. +/// +/// This is the primary query path for app listings. Each SoftwareAsset carries +/// version, versionCode, hash, urls, and platform — everything needed for +/// app cards and update detection — and links directly to its App. +AutoDisposeStateNotifierProvider, + StorageState> appAssetsQuery({ + Set? authors, + Map>? tags, + String? search, + DateTime? since, + DateTime? until, + int? limit, + Source? source, + required String subscriptionPrefix, +}) { + return query( + authors: authors, + tags: tags, + search: search, + since: since, + until: until, + limit: limit, + and: (asset) => {asset.app.query(), asset.author.query()}, + source: source, + subscriptionPrefix: subscriptionPrefix, + ); +} + +/// Reactive query provider: fetches FileMetadata (1063) events and resolves +/// their parent App (32267) via the direct `#i` → `#d` relationship. +/// +/// Same shape as [appAssetsQuery] but for legacy 1063-only apps. +/// Delete this function when legacy 1063 support is fully removed. +AutoDisposeStateNotifierProvider, + StorageState> legacyAppQuery({ + Set? authors, + Map>? tags, + String? search, + DateTime? since, + DateTime? until, + int? limit, + Source? source, + required String subscriptionPrefix, +}) { + return query( + authors: authors, + tags: tags, + search: search, + since: since, + until: until, + limit: limit, + and: (fm) => {fm.app.query(), fm.author.query()}, + source: source, + subscriptionPrefix: subscriptionPrefix, + ); +} + +/// Result of a paginated asset-first fetch. +class AssetFetchResult { + final List apps; + final int assetCount; + const AssetFetchResult(this.apps, this.assetCount); +} + +/// Imperative one-shot: fetches a page of SoftwareAsset (3063) events, +/// resolves their parent Apps, and pre-loads author profiles. +/// Returns the resolved Apps (deduplicated) and the raw asset count +/// (for accurate pagination — multiple assets may map to one app). +Future fetchAppsByAsset( + StorageNotifier storage, { + Map>? tags, + DateTime? until, + int? limit, + Source? source, + required String subscriptionPrefix, +}) async { + final assets = await storage.query( + RequestFilter( + tags: tags, + until: until, + limit: limit, + ).toRequest(), + source: source, + subscriptionPrefix: subscriptionPrefix, + ); + + if (assets.isEmpty) return const AssetFetchResult([], 0); + + final appFilters = assets + .map((a) => a.app.req?.filters.firstOrNull) + .nonNulls + .toList(); + + if (appFilters.isEmpty) return AssetFetchResult(const [], assets.length); + + final apps = await storage.query( + Request(appFilters), + source: source, + subscriptionPrefix: '$subscriptionPrefix-apps', + ); + + await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors'); + + return AssetFetchResult(apps, assets.length); +} + +/// Imperative one-shot: fetches a page of FileMetadata (1063) events, +/// resolves their parent Apps, and pre-loads author profiles. +/// Same shape as [fetchAppsByAsset] but for legacy 1063-only apps. +Future fetchLegacyAppsByMetadata( + StorageNotifier storage, { + Map>? tags, + DateTime? until, + int? limit, + Source? source, + required String subscriptionPrefix, +}) async { + final metadatas = await storage.query( + RequestFilter( + tags: tags, + until: until, + limit: limit, + ).toRequest(), + source: source, + subscriptionPrefix: subscriptionPrefix, + ); + + if (metadatas.isEmpty) return const AssetFetchResult([], 0); + + final appFilters = metadatas + .map((fm) => fm.app.req?.filters.firstOrNull) + .nonNulls + .toList(); + + if (appFilters.isEmpty) return AssetFetchResult(const [], metadatas.length); + + final apps = await storage.query( + Request(appFilters), + source: source, + subscriptionPrefix: '$subscriptionPrefix-apps', + ); + + await _loadAuthors(storage, apps, source, '$subscriptionPrefix-authors'); + + return AssetFetchResult(apps, metadatas.length); +} + +Future _loadAuthors( + StorageNotifier storage, + List apps, + Source? source, + String subscriptionPrefix, +) async { + if (apps.isEmpty) return; + final authorFilters = apps + .map((a) => a.author.req?.filters.firstOrNull) + .nonNulls + .toList(); + if (authorFilters.isEmpty) return; + await storage.query( + Request(authorFilters), + source: source, + subscriptionPrefix: subscriptionPrefix, + ); +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 527c3e0..ce4b3eb 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -33,17 +33,18 @@ extension AppExt on App { bool get isInstalled => ref.read(packageManagerProvider.notifier).isInstalled(identifier); - /// Latest file metadata associated to the latest release - /// Prefers SoftwareAsset (new format) over FileMetadata (old format) - /// Note: assumes latest metadata has been loaded for the current platform - FileMetadata? get latestFileMetadata => + /// Resolved installable for the current platform. + /// Prefers direct SoftwareAsset (3063) via `latestAsset` relationship, + /// falls back to Release chain (1063). + Installable? get installable => + latestAsset.value ?? latestRelease.value?.latestAsset.value ?? latestRelease.value?.latestMetadata.value; /// Whether there is an update available for the installed app. /// Delegates to PackageManager (versionCode-only comparison). bool get hasUpdate { - final latest = latestFileMetadata; + final latest = installable; if (latest == null) return false; return ref.read(packageManagerProvider.notifier).hasUpdate(identifier, latest); } @@ -51,7 +52,7 @@ extension AppExt on App { /// Whether the relay version would be a downgrade from the installed version. /// Delegates to PackageManager (versionCode-only comparison). bool get hasDowngrade { - final latest = latestFileMetadata; + final latest = installable; if (latest == null) return false; return ref.read(packageManagerProvider.notifier).hasDowngrade(identifier, latest); } @@ -64,29 +65,24 @@ extension WidgetRefExt on WidgetRef { Ref get asRef => read(Provider((ref) => ref)); } -/// Extension to handle both old and new format certificate hashes -extension FileMetadataExt on FileMetadata { - /// Returns the APK certificate hash, checking new format first then old - /// New format (SoftwareAsset): apkCertificateHashes (Set) - /// Old format (FileMetadata): apkSignatureHash (String?) +/// Extension to get APK certificate hashes from either format. +extension InstallableExt on Installable { + /// Returns the primary APK certificate hash. + /// SoftwareAsset: uses apkCertificateHashes. FileMetadata: uses apkSignatureHash. String? get certificateHash { - // Try new format first (SoftwareAsset has apkCertificateHashes) if (this is SoftwareAsset) { final hashes = (this as SoftwareAsset).apkCertificateHashes; if (hashes.isNotEmpty) return hashes.first; } - // Fall back to old format return apkSignatureHash; } - /// Returns all APK certificate hashes + /// Returns all APK certificate hashes. Set get certificateHashes { - // Try new format first if (this is SoftwareAsset) { final hashes = (this as SoftwareAsset).apkCertificateHashes; if (hashes.isNotEmpty) return hashes; } - // Fall back to old format (single hash as set) return apkSignatureHash != null ? {apkSignatureHash!} : {}; } } diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 770c997..4629ab5 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -277,7 +277,7 @@ class _ZappersListSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final metadataId = app.latestFileMetadata?.id; + final metadataId = app.installable?.id; // Query zaps on app (via #a tag) final zapsState = ref.watch( @@ -925,7 +925,7 @@ class DebugVersionsSection extends HookConsumerWidget { WidgetRef ref, App app, Release release, - FileMetadata metadata, + Installable metadata, ) async { // Use PackageManager to start download final pm = ref.read(packageManagerProvider.notifier); diff --git a/lib/widgets/app_info_table.dart b/lib/widgets/app_info_table.dart index 7522495..9608608 100644 --- a/lib/widgets/app_info_table.dart +++ b/lib/widgets/app_info_table.dart @@ -13,7 +13,7 @@ class AppInfoTable extends HookConsumerWidget { const AppInfoTable({super.key, required this.app, this.fileMetadata}); final App app; - final FileMetadata? fileMetadata; + final Installable? fileMetadata; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/batch_progress_banner.dart b/lib/widgets/batch_progress_banner.dart index 5ddc0de..ec95e23 100644 --- a/lib/widgets/batch_progress_banner.dart +++ b/lib/widgets/batch_progress_banner.dart @@ -25,11 +25,11 @@ class UpdateAllRow extends ConsumerWidget { onPressed: () async { final pm = ref.read(packageManagerProvider.notifier); final items = allUpdates - .where((app) => app.latestFileMetadata != null) + .where((app) => app.installable != null) .map( (app) => ( appId: app.identifier, - target: app.latestFileMetadata!, + target: app.installable!, displayName: app.name, ), ) diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index 639e831..48599fb 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -17,7 +17,7 @@ class CommentsSection extends HookConsumerWidget { const CommentsSection({super.key, required this.app, this.fileMetadata}); final App app; - final FileMetadata? fileMetadata; + final Installable? fileMetadata; @override Widget build(BuildContext context, WidgetRef ref) { @@ -112,7 +112,7 @@ class _CommentsSectionLayout extends StatelessWidget { final Object? errorException; final Widget addCommentButton; final App? app; - final FileMetadata? fileMetadata; + final Installable? fileMetadata; final AppStack? stack; @override @@ -247,7 +247,7 @@ class _ThreadedCommentCard extends HookConsumerWidget { final Comment comment; final int depth; final App? app; - final FileMetadata? fileMetadata; + final Installable? fileMetadata; final AppStack? stack; // Thread line colors for different depths @@ -478,7 +478,7 @@ class _ThreadedCommentCard extends HookConsumerWidget { class _AddCommentButton extends ConsumerWidget { const _AddCommentButton({required this.fileMetadata, required this.app}); - final FileMetadata fileMetadata; + final Installable fileMetadata; final App app; @override @@ -547,7 +547,7 @@ class _AddStackCommentButton extends ConsumerWidget { class _CommentComposer extends HookConsumerWidget { const _CommentComposer({required this.fileMetadata, required this.app}); - final FileMetadata fileMetadata; + final Installable fileMetadata; final App app; @override @@ -701,7 +701,7 @@ class _ReplyComposer extends HookConsumerWidget { final Comment parentComment; final App? app; - final FileMetadata? fileMetadata; + final Installable? fileMetadata; final AppStack? stack; @override diff --git a/lib/widgets/install_alert_dialog.dart b/lib/widgets/install_alert_dialog.dart index 30749e0..f23c220 100644 --- a/lib/widgets/install_alert_dialog.dart +++ b/lib/widgets/install_alert_dialog.dart @@ -61,7 +61,7 @@ class InstallAlertDialog extends HookConsumerWidget { '${app.name ?? app.identifier} will be installed from its original release location:', oneLine: false, showFullUrl: true, - url: app.latestFileMetadata!.urls.first, + url: app.installable!.urls.first, size: baseTextSize, ), ] else ...[ diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index a1154d6..9f0be34 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -40,7 +40,7 @@ class InstallButton extends ConsumerWidget { final hasUpdate = app.hasUpdate; final hasDowngrade = app.hasDowngrade; final hasRelease = release != null; - final fileMetadata = app.latestFileMetadata; + final fileMetadata = app.installable; // Listen for errors to show toasts ref.listen(installOperationProvider(app.identifier), (prev, next) { @@ -104,7 +104,7 @@ class InstallButton extends ConsumerWidget { required bool hasUpdate, required bool hasDowngrade, required bool hasRelease, - required FileMetadata? fileMetadata, + required Installable? fileMetadata, required double fontSize, }) { // Completed is a terminal "result" state and may linger for batch progress UX. @@ -285,7 +285,7 @@ class InstallButton extends ConsumerWidget { } String? _formatTotalSizeMb() { - final sizeBytes = app.latestFileMetadata?.size; + final sizeBytes = app.installable?.size; if (sizeBytes == null || sizeBytes <= 0) return null; final mb = sizeBytes / (1024 * 1024); return '${mb.toStringAsFixed(1)} MB'; @@ -561,7 +561,7 @@ class InstallButton extends ConsumerWidget { Future _startDownload( BuildContext context, WidgetRef ref, - FileMetadata fileMetadata, + Installable fileMetadata, ) async { final pm = ref.read(packageManagerProvider.notifier); await pm.startDownload(app.identifier, fileMetadata, displayName: app.name); diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d2d03be..efc4c9e 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:purplebase/purplebase.dart'; import 'package:zapstore/services/updates_service.dart'; +import 'package:zapstore/utils/app_query.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; @@ -295,47 +296,34 @@ class LatestReleasesNotifier extends StateNotifier { final Ref ref; static const int _pageSize = 5; - ProviderSubscription>? _sub; - // We keep a fixed live head window from query(); older pages are appended + ProviderSubscription>? _sub; void _startQuery() { _sub?.close(); - _sub = ref.listen>( - query( + _sub = ref.listen>( + appAssetsQuery( limit: _pageSize, tags: { '#f': {'android-arm64-v8a'}, }, - and: (app) => { - app.latestRelease.query( - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - and: (release) => { - release.latestMetadata.query(), - release.latestAsset.query(), - }, - ), - }, - // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), subscriptionPrefix: 'app-latest', ), - (previous, next) async { - // Always mirror storage state and ensure olderApps don't duplicate the live head - if (next is StorageData) { - final liveIds = next.models.map((a) => a.id).toSet(); + (previous, next) { + final appState = _appsFromAssetState(next); + + if (appState is StorageData) { + final liveIds = appState.models.map((a) => a.id).toSet(); final filteredOlder = state.olderApps .where((a) => !liveIds.contains(a.id)) .toList(); - state = state.copyWith(storage: next, olderApps: filteredOlder); + state = state.copyWith(storage: appState, olderApps: filteredOlder); } else { - state = state.copyWith(storage: next); + state = state.copyWith(storage: appState); } - if (next is StorageError) { + if (appState is StorageError) { state = state.copyWith(isLoadingMore: false); } }, @@ -343,10 +331,31 @@ class LatestReleasesNotifier extends StateNotifier { ); } - /// Fetch authors for a page of apps (used during pagination) - /// Note: Profiles are now loaded reactively via `query` in individual widgets - Future _loadRelationshipsFor(List appsPage) async { - // No-op: profiles are now loaded reactively via `query` with caching + /// Derive a `StorageState` from the SoftwareAsset query results. + StorageState _appsFromAssetState(StorageState assetState) { + return switch (assetState) { + StorageLoading() => StorageLoading( + _uniqueAppsFromAssets(assetState.models), + ), + StorageData(:final models) => StorageData( + _uniqueAppsFromAssets(models), + ), + StorageError(:final exception) => + StorageError(const [], exception: exception), + }; + } + + /// Extract unique Apps from assets, preserving order (newest first). + List _uniqueAppsFromAssets(List assets) { + final seen = {}; + final apps = []; + for (final asset in assets) { + final app = asset.app.value; + if (app != null && seen.add(app.identifier)) { + apps.add(app); + } + } + return apps; } Future loadMore() async { @@ -362,68 +371,26 @@ class LatestReleasesNotifier extends StateNotifier { state = state.copyWith(isLoadingMore: true); try { - // Fetch older apps - final olderPage = await ref.storage.query( - RequestFilter( - until: oldest, - limit: _pageSize, - tags: { - '#f': {'android-arm64-v8a'}, - }, - ).toRequest(), + final result = await fetchAppsByAsset( + ref.storage, + tags: { + '#f': {'android-arm64-v8a'}, + }, + until: oldest, + limit: _pageSize, source: const LocalAndRemoteSource(stream: false), subscriptionPrefix: 'app-latest-older', ); - if (olderPage.isNotEmpty) { - // Load relationships: releases and their file metadata (same relay group) - final releases = await ref.storage.query( - Request( - olderPage - .map((app) => app.latestRelease.req?.filters.firstOrNull) - .nonNulls - .toList(), - ), - source: const RemoteSource(stream: false), - subscriptionPrefix: 'app-latest-releases', - ); - - // Load file metadata for the releases (old format) - if (releases.isNotEmpty) { - await ref.storage.query( - Request( - releases - .map((r) => r.latestMetadata.req?.filters.firstOrNull) - .nonNulls - .toList(), - ), - source: const RemoteSource(stream: false), - subscriptionPrefix: 'app-latest-metadata', - ); - // Load software assets for the releases (new format) - await ref.storage.query( - Request( - releases - .map((r) => r.latestAsset.req?.filters.firstOrNull) - .nonNulls - .toList(), - ), - source: const RemoteSource(stream: false), - subscriptionPrefix: 'app-latest-assets', - ); - } - - // Load release authors from different relay group (social) - await _loadRelationshipsFor(olderPage); - + if (result.apps.isNotEmpty) { final existingIds = combined.map((a) => a.id).toSet(); - final uniqueOlder = olderPage + final uniqueOlder = result.apps .where((a) => !existingIds.contains(a.id)) .toList(); state = state.copyWith( olderApps: [...state.olderApps, ...uniqueOlder], isLoadingMore: false, - hasMore: olderPage.length >= _pageSize, + hasMore: result.assetCount >= _pageSize, ); } else { state = state.copyWith(isLoadingMore: false, hasMore: false); diff --git a/lib/widgets/relevant_who_follow_container.dart b/lib/widgets/relevant_who_follow_container.dart index f6e8ad8..1e48437 100644 --- a/lib/widgets/relevant_who_follow_container.dart +++ b/lib/widgets/relevant_who_follow_container.dart @@ -81,7 +81,7 @@ class RelevantWhoFollowContainer extends HookConsumerWidget { ); // Also query zaps on the latest metadata if available - final latestMetadata = app.latestFileMetadata; + final latestMetadata = app.installable; final metadataZapsState = latestMetadata != null ? ref.watch( query( diff --git a/lib/widgets/version_pill_widget.dart b/lib/widgets/version_pill_widget.dart index 24a47e8..9e8aff6 100644 --- a/lib/widgets/version_pill_widget.dart +++ b/lib/widgets/version_pill_widget.dart @@ -28,22 +28,22 @@ class VersionPillWidget extends HookConsumerWidget { packageManagerProvider.select((s) => s.installed[app.identifier]), ); - // Watch app with relationships to ensure Release and FileMetadata are loaded + // Watch app with relationships from local cache only — parent screens are + // responsible for loading data from remote. final appState = ref.watch( model( app, and: (a) => { + a.latestAsset.query(source: const LocalSource()), a.latestRelease.query( - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), + source: const LocalSource(), and: (release) => { - release.latestMetadata.query(), - release.latestAsset.query(), + release.latestMetadata.query(source: const LocalSource()), + release.latestAsset.query(source: const LocalSource()), }, ), }, + source: const LocalSource(), subscriptionPrefix: 'app-version-pill', ), ); @@ -58,8 +58,8 @@ class VersionPillWidget extends HookConsumerWidget { final installedPackage = loadedApp.installedPackage; final installedVersion = installedPackage?.version; final installedVersionCode = installedPackage?.versionCode; - final availableVersion = loadedApp.latestFileMetadata?.version; - final availableVersionCode = loadedApp.latestFileMetadata?.versionCode; + final availableVersion = loadedApp.installable?.version; + final availableVersionCode = loadedApp.installable?.versionCode; final isInstalled = loadedApp.isInstalled; final updateAvailable = loadedApp.hasUpdate; final downgradeAvailable = loadedApp.hasDowngrade; diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index f51c406..7460fdf 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -427,7 +427,7 @@ class ZapAmountDialog extends HookConsumerWidget { : await secureStorage.getNWCString(); // Build zap request - final latestMetadata = app.latestFileMetadata; + final latestMetadata = app.installable; final author = app.author.value; if (latestMetadata == null || author == null) { @@ -445,7 +445,7 @@ class ZapAmountDialog extends HookConsumerWidget { if (comment.isNotEmpty) zapRequest.comment = comment; zapRequest.linkProfileByPubkey(author.pubkey); zapRequest.linkModel(app); - zapRequest.linkModel(latestMetadata); + zapRequest.linkModelById(latestMetadata.id); zapRequest.relays = socialRelays; final signedZapRequest = await zapRequest.signWith( diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..57efdf4 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,22 @@ #include "generated_plugin_registrant.h" +#include +#include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..9137173 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + gtk + sqlite3_flutter_libs + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..065ad84 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,28 @@ import FlutterMacOS import Foundation +import app_links +import connectivity_plus +import device_info_plus +import flutter_local_notifications +import flutter_secure_storage_macos +import path_provider_foundation +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import sqlite3_flutter_libs +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/spec/features/FEAT-004-asset-first-queries.md b/spec/features/FEAT-004-asset-first-queries.md new file mode 100644 index 0000000..d12747e --- /dev/null +++ b/spec/features/FEAT-004-asset-first-queries.md @@ -0,0 +1,42 @@ +# FEAT-004 — Asset-First Query Optimization + +## Goal + +Speed up app listing screens by querying SoftwareAsset (kind 3063) first, +resolving the App (32267) via its direct relationship, and skipping the +intermediate Release (30063) for card-level display. + +## Non-Goals + +- Removing Release from the data model or detail screen +- Changing the models package +- Migrating all screens at once (phased rollout starting with Latest Releases) + +## User-Visible Behavior + +- App cards on the home screen load faster (2 hops instead of 4) +- No change to what the user sees: same name, icon, version pill, description +- Legacy 1063-only apps still appear, loaded via a separate fallback query +- Offline: local-first behavior unchanged — cached assets and apps render immediately + +## Edge Cases + +- App has only legacy FileMetadata (1063), no SoftwareAsset (3063) → fallback query picks it up (when `includeLegacy: true`; Latest Releases skips these) +- App has both 3063 and 1063 → 3063 wins (already the existing preference) +- SoftwareAsset exists but its App relationship fails to resolve → card skipped, no crash +- Network failure → local cache used, graceful degradation unchanged + +## Acceptance Criteria + +- [ ] `latestFileMetadata` renamed to `latestAsset` across codebase, with 3063→1063 fallback +- [ ] Centralized query helper with `includeLegacy` flag, reusable across screens +- [ ] LatestReleasesNotifier uses centralized query (3063-only, no legacy fallback) +- [ ] VersionPillWidget reads locally instead of re-fetching the full chain from remote +- [ ] `flutter analyze` clean +- [ ] Legacy 1063-only apps still appear in listings + +## Notes + +- The detail screen continues using Release for release notes, commit info, etc. + Release is loaded via `latestRelease` relationship when needed, not as part of the card query. +- When legacy 1063 is fully removed, the fallback query function is deleted — single removal point. diff --git a/spec/work/WORK-007-asset-first-queries.md b/spec/work/WORK-007-asset-first-queries.md new file mode 100644 index 0000000..0f1cef7 --- /dev/null +++ b/spec/work/WORK-007-asset-first-queries.md @@ -0,0 +1,69 @@ +# WORK-007 — Asset-First Query Optimization + +**Feature:** FEAT-004-asset-first-queries.md +**Status:** In Progress + +## Tasks + +- [x] 1. Rename `latestFileMetadata` → `latestAsset` in AppExt + - Files: `lib/utils/extensions.dart`, all call sites + - Returns `Installable?` (shared interface for FileMetadata + SoftwareAsset) + +- [x] 2. Make SoftwareAsset self-referential (`RegularModel`) + - Files: `purplebase/models/lib/src/models/asset.dart` + - No longer extends FileMetadata — duplicates shared getters + - Added `Installable` interface implemented by both FileMetadata and SoftwareAsset + - Updated entire install pipeline (InstallOperation, PackageManager, platform impls) to use `Installable` + +- [x] 3. Create centralized app query helper function + - Files: `lib/utils/app_query.dart` (new) + - `appAssetsQuery()`: reactive `query` with `asset.app.query()` + - `legacyAppQuery()`: reactive `query` with old chain — delete when 1063 removed + - `fetchAppsByAsset()`: imperative one-shot for pagination + +- [x] 4. Rewrite LatestReleasesNotifier to use centralized helper + - Files: `lib/widgets/latest_releases_container.dart` + - 3063-only, no legacy fallback + - Live head: `appAssetsQuery()` with `stream: true`, derives Apps from assets + - Pagination: `fetchAppsByAsset()` imperative call + +- [x] 5. Update VersionPillWidget to not re-fetch from remote + - Files: `lib/widgets/version_pill_widget.dart` + - Switched to `LocalSource` only — parent screens load data + +- [x] 6. Self-review against INVARIANTS.md — all clean + +## Decisions + +### 2026-03-26 — Query direction: asset-first + +**Context:** Current queries go App→Release→FileMetadata/SoftwareAsset (3-4 hops). +SoftwareAsset (3063) has a direct `BelongsTo` relationship. +**Options:** (A) Keep App-first, optimize relay-side. (B) Query 3063 first, resolve App via relationship. +**Decision:** Option B. +**Rationale:** Eliminates Release from the card-display path. 2 hops instead of 4. +When 1063 is removed, the fallback function is simply deleted. + +### 2026-03-26 — VersionPillWidget remote query + +**Context:** VersionPillWidget independently re-fetches App→Release→Metadata from remote. +**Decision:** Switch to LocalSource or remove the query. Listing screens are responsible for loading data. +**Rationale:** Eliminates redundant network requests per card. Data is already loaded by the parent. + +### 2026-03-26 — No legacy fallback in Latest Releases + +**Context:** Latest Releases is the hot path on the home screen. +**Decision:** 3063-only, no 1063 fallback. Other screens can opt in via `includeLegacy: true`. +**Rationale:** Optimizes for fastest retrieval. Legacy apps appear elsewhere (search, updates, stacks). + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-03-26:** Completed rename of `latestFileMetadata` → `latestAsset` across all call sites. +**2026-03-26:** Made SoftwareAsset self-referential. Introduced `Installable` interface. Updated entire install pipeline. +**2026-03-26:** Created centralized query helpers. Rewrote LatestReleasesNotifier (3063-only). VersionPillWidget now local-only. +**2026-03-26:** `dart analyze lib/` — no issues found. +