mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
merge master
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
analyzer:
|
||||
errors:
|
||||
dangling_library_doc_comments: ignore
|
||||
deprecated_member_use: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget {
|
||||
}
|
||||
|
||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||
|
||||
// Clear storage if requested from a clear all operation
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:async_button_builder/async_button_builder.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -1379,12 +1380,14 @@ class _DataManagementSection extends ConsumerWidget {
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title: AutoSizeText(
|
||||
'Clear local storage',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
minFontSize: 12,
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onTap: () => _showClearAllDataDialog(context, ref),
|
||||
@@ -1403,7 +1406,7 @@ class _DataManagementSection extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Clear local storage'),
|
||||
Flexible(child: const Text('Clear local storage')),
|
||||
],
|
||||
),
|
||||
content: const Text(
|
||||
@@ -1477,81 +1480,45 @@ class _SavedAppsHeading extends ConsumerWidget {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
|
||||
return savedAppsAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (addressableIds) {
|
||||
if (addressableIds.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final identifiers = addressableIds
|
||||
.map((id) {
|
||||
final parts = id.split(':');
|
||||
return parts.length >= 3 ? parts[2] : null;
|
||||
})
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
|
||||
if (identifiers.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Always show heading when signed in
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedAppsSection extends HookConsumerWidget {
|
||||
class _SavedAppsSection extends ConsumerWidget {
|
||||
const _SavedAppsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final hasLoadedOnce = useState(false);
|
||||
|
||||
if (signedInPubkey == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (signedInPubkey == null) return const SizedBox.shrink();
|
||||
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
|
||||
return savedAppsAsync.when(
|
||||
loading: () => _savedAppsLoadingCard(context),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (addressableIds) {
|
||||
if (addressableIds.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
// Keep previous value during refresh, if available.
|
||||
final addressableIds = savedAppsAsync.valueOrNull;
|
||||
|
||||
// Show loading only on first load (when no value exists yet).
|
||||
if (addressableIds == null) {
|
||||
return _savedAppsLoadingCard(context);
|
||||
}
|
||||
|
||||
final identifiers = addressableIds
|
||||
.map((id) {
|
||||
final parts = id.split(':');
|
||||
return parts.length >= 3 ? parts[2] : null;
|
||||
})
|
||||
.whereType<String>()
|
||||
final identifiers = _toIdentifiers(addressableIds);
|
||||
return _SavedAppsList(identifiers: identifiers);
|
||||
}
|
||||
|
||||
Set<String> _toIdentifiers(Set<String> addressableIds) {
|
||||
return addressableIds
|
||||
.map((id) => id.split(':'))
|
||||
.where((parts) => parts.length >= 3)
|
||||
.map((parts) => parts[2])
|
||||
.toSet();
|
||||
|
||||
if (identifiers.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _SavedAppsList(
|
||||
identifiers: identifiers,
|
||||
hasLoadedOnce: hasLoadedOnce,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _savedAppsLoadingCard(BuildContext context) {
|
||||
return Card(
|
||||
Widget _savedAppsLoadingCard(BuildContext context) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
@@ -1561,20 +1528,20 @@ class _SavedAppsSection extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SavedAppsList extends ConsumerWidget {
|
||||
const _SavedAppsList({
|
||||
required this.identifiers,
|
||||
required this.hasLoadedOnce,
|
||||
});
|
||||
const _SavedAppsList({required this.identifiers});
|
||||
|
||||
final Set<String> identifiers;
|
||||
final ValueNotifier<bool> hasLoadedOnce;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// No bookmarks saved - show empty state without querying
|
||||
if (identifiers.isEmpty) {
|
||||
return _emptyState(context);
|
||||
}
|
||||
|
||||
final savedAppsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {'#d': identifiers},
|
||||
@@ -1584,6 +1551,8 @@ class _SavedAppsList extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final isLoading = savedAppsState is StorageLoading;
|
||||
|
||||
final savedApps = savedAppsState.models.toList()
|
||||
..sort(
|
||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||
@@ -1591,15 +1560,31 @@ class _SavedAppsList extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
// Check if we have loaded at least once - defer state update to after build
|
||||
if (savedApps.isNotEmpty && !hasLoadedOnce.value) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
hasLoadedOnce.value = true;
|
||||
});
|
||||
// Show spinner only when we truly have nothing to render yet
|
||||
// If we're refreshing but still have models, keep showing the list
|
||||
if (isLoading && savedApps.isEmpty) {
|
||||
return _loadingCard(context);
|
||||
}
|
||||
|
||||
// Show loading state only if we haven't loaded any apps yet
|
||||
if (savedApps.isEmpty && !hasLoadedOnce.value) {
|
||||
if (savedApps.isEmpty) {
|
||||
return _emptyState(context);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final app in savedApps)
|
||||
AppCard(app: app, showUpdateArrow: false, showDescription: false),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loadingCard(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -1612,25 +1597,14 @@ class _SavedAppsList extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Hide if no apps after loading
|
||||
if (savedApps.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: savedApps
|
||||
.map(
|
||||
(app) => AppCard(
|
||||
app: app,
|
||||
showUpdateArrow: false,
|
||||
showDescription: false,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
Widget _emptyState(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'No saved apps yet',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget {
|
||||
// Force the search to hit the default relay group (relay.zapstore.dev)
|
||||
// so a connection appears in Debug Info when searching.
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'search-results',
|
||||
subscriptionPrefix: 'app-search-results',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
|
||||
final poolState = ref.watch(poolStateProvider);
|
||||
final subscriptions = poolState?.subscriptions ?? {};
|
||||
|
||||
// Filter to only "updates" subscription
|
||||
// Filter to only "app-updates" subscription
|
||||
final updatesSubs = subscriptions.entries
|
||||
.where((e) => e.key.startsWith('updates'))
|
||||
.where((e) => e.key.startsWith('app-updates'))
|
||||
.map((e) => e.value);
|
||||
|
||||
// Check relay status for updates subscription only
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget {
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'user-apps',
|
||||
subscriptionPrefix: 'app-user-apps',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -382,7 +383,21 @@ class _NpubRow extends StatelessWidget {
|
||||
final abbreviatedNpub =
|
||||
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||
|
||||
return GestureDetector(
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: npub));
|
||||
},
|
||||
child: Icon(
|
||||
Icons.copy,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://npub.world/$npub'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
@@ -410,6 +425,8 @@ class _NpubRow extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart';
|
||||
/// Checks if storage should be cleared on this launch, and clears the marker.
|
||||
/// Call this BEFORE initializing storage.
|
||||
Future<void> maybeClearStorage(String dbPath) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final marker = File('${dir.path}/$_markerFileName');
|
||||
if (await marker.exists()) {
|
||||
final dbFile = File(dbPath);
|
||||
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
|
||||
|
||||
/// Sets a marker file and triggers a native app restart.
|
||||
Future<void> restartApp() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final marker = File('${dir.path}/$_markerFileName');
|
||||
await marker.create();
|
||||
await _channel.invokeMethod('restart');
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io' show Directory, Platform;
|
||||
import 'dart:io' show Directory, File, Platform;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:background_downloader/background_downloader.dart' hide Request;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
@@ -9,7 +11,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/background_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
@@ -42,6 +44,8 @@ const kAppCatalogRelaysKey = 'appCatalogRelays';
|
||||
/// This MUST be a top-level function (not a class method).
|
||||
@pragma('vm:entry-point')
|
||||
void callbackDispatcher() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
ui.DartPluginRegistrant.ensureInitialized();
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
switch (task) {
|
||||
case kBackgroundUpdateTaskName:
|
||||
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
|
||||
try {
|
||||
// Delete the file if it exists
|
||||
final filePath = await task.filePath();
|
||||
final file = Directory(filePath).parent.listSync().firstWhere(
|
||||
(f) => f.path.endsWith(task.filename),
|
||||
orElse: () => Directory(''),
|
||||
);
|
||||
if (file.path.isNotEmpty) {
|
||||
final file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
|
||||
);
|
||||
|
||||
if (await downloadDir.exists()) {
|
||||
final files = downloadDir.listSync();
|
||||
final entities = downloadDir.listSync();
|
||||
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
||||
|
||||
for (final file in files) {
|
||||
if (file is! Directory) {
|
||||
final stat = await file.stat();
|
||||
for (final entity in entities) {
|
||||
if (entity is File) {
|
||||
final stat = await entity.stat();
|
||||
if (stat.modified.isBefore(cutoff)) {
|
||||
await file.delete();
|
||||
await entity.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,7 +146,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
||||
packageManagerProvider.overrideWith(
|
||||
(ref) => Platform.isAndroid
|
||||
? AndroidPackageManager(ref)
|
||||
? BackgroundPackageManager(ref)
|
||||
: DummyPackageManager(ref),
|
||||
),
|
||||
],
|
||||
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
|
||||
try {
|
||||
// Initialize Purplebase with same DB path as main app
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||
|
||||
await container.read(
|
||||
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
);
|
||||
|
||||
// Load releases for all apps in a single query
|
||||
// Load releases and their metadata/assets (required for hasUpdate)
|
||||
if (apps.isNotEmpty) {
|
||||
final addressableIds = apps
|
||||
.map((app) => app.event.addressableId)
|
||||
.toSet();
|
||||
await storage.query(
|
||||
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(),
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
final releaseFilters = apps
|
||||
.map((app) => app.latestRelease.req?.filters.firstOrNull)
|
||||
.nonNulls
|
||||
.toList();
|
||||
if (releaseFilters.isNotEmpty) {
|
||||
final List<Release> releases = await storage.query(
|
||||
Request<Release>(releaseFilters),
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
);
|
||||
|
||||
final metadataFilters = releases
|
||||
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
|
||||
.nonNulls
|
||||
.toList();
|
||||
if (metadataFilters.isNotEmpty) {
|
||||
await storage.query(
|
||||
Request<FileMetadata>(metadataFilters),
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
);
|
||||
}
|
||||
|
||||
final assetFilters = releases
|
||||
.map((r) => r.latestAsset.req?.filters.firstOrNull)
|
||||
.nonNulls
|
||||
.toList();
|
||||
if (assetFilters.isNotEmpty) {
|
||||
await storage.query(
|
||||
Request<SoftwareAsset>(assetFilters),
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-query apps from local to ensure relationships are loaded
|
||||
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
|
||||
android: initializationSettingsAndroid,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||
|
||||
// Build notification content
|
||||
final title = updateCount == 1
|
||||
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
|
||||
// Navigation is handled by the app's normal launch flow
|
||||
},
|
||||
);
|
||||
|
||||
// Create notification channel on Android
|
||||
final androidPlugin = flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidPlugin != null) {
|
||||
await androidPlugin.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
kUpdateNotificationChannelId,
|
||||
kUpdateNotificationChannelName,
|
||||
description: kUpdateNotificationChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
),
|
||||
);
|
||||
}
|
||||
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||
}
|
||||
|
||||
/// Cancel background update checks
|
||||
@@ -405,6 +413,26 @@ class BackgroundUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _ensureUpdateNotificationChannel(
|
||||
FlutterLocalNotificationsPlugin plugin,
|
||||
) async {
|
||||
final androidPlugin = plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidPlugin != null) {
|
||||
await androidPlugin.createNotificationChannel(
|
||||
const AndroidNotificationChannel(
|
||||
kUpdateNotificationChannelId,
|
||||
kUpdateNotificationChannelName,
|
||||
description: kUpdateNotificationChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the background update service
|
||||
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
||||
BackgroundUpdateService.new,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
|
||||
/// Install status values from native side.
|
||||
@@ -600,6 +601,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
}
|
||||
|
||||
state = state.copyWith(installed: {...packages, ...preserved});
|
||||
await InstalledPackagesSnapshot.save(state.installed);
|
||||
|
||||
// Clear operations for apps where the installed version matches the target version
|
||||
// This catches installs that succeeded but we missed the event
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
|
||||
/// Background-safe PackageManager that avoids EventChannel usage.
|
||||
final class BackgroundPackageManager extends PackageManager {
|
||||
BackgroundPackageManager(super.ref);
|
||||
|
||||
// Zapstore currently targets arm64 APKs for background checks.
|
||||
@override
|
||||
String get platform => 'android-arm64-v8a';
|
||||
|
||||
@override
|
||||
String get packageExtension => '.apk';
|
||||
|
||||
@override
|
||||
bool get supportsSilentInstall => false;
|
||||
|
||||
@override
|
||||
Future<void> install(
|
||||
String appId,
|
||||
String filePath, {
|
||||
required String expectedHash,
|
||||
required int expectedSize,
|
||||
required FileMetadata target,
|
||||
}) {
|
||||
throw UnsupportedError('Install not supported in background');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uninstall(String appId) {
|
||||
throw UnsupportedError('Uninstall not supported in background');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> launchApp(String appId) {
|
||||
throw UnsupportedError('Launch not supported in background');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> requestPermission() {
|
||||
throw UnsupportedError('Permission not supported in background');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasPermission() async => false;
|
||||
|
||||
@override
|
||||
Future<void> syncInstalledPackages() async {
|
||||
final installed = await InstalledPackagesSnapshot.load();
|
||||
state = state.copyWith(installed: installed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
|
||||
class InstalledPackagesSnapshot {
|
||||
static const _fileName = 'installed_packages_snapshot.json';
|
||||
|
||||
static Future<File> _file() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return File(path.join(dir.path, _fileName));
|
||||
}
|
||||
|
||||
static Future<void> save(Map<String, PackageInfo> installed) async {
|
||||
try {
|
||||
final file = await _file();
|
||||
final tmp = File('${file.path}.tmp');
|
||||
final list = installed.values
|
||||
.map(
|
||||
(p) => <String, dynamic>{
|
||||
'appId': p.appId,
|
||||
'name': p.name,
|
||||
'version': p.version,
|
||||
'versionCode': p.versionCode,
|
||||
'signatureHash': p.signatureHash,
|
||||
'canInstallSilently': p.canInstallSilently,
|
||||
},
|
||||
)
|
||||
.toList(growable: false);
|
||||
final payload = jsonEncode({
|
||||
'v': 1,
|
||||
'savedAt': DateTime.now().millisecondsSinceEpoch,
|
||||
'installed': list,
|
||||
});
|
||||
await tmp.writeAsString(payload, flush: true);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
await tmp.rename(file.path);
|
||||
} catch (e) {
|
||||
// Best-effort snapshot only.
|
||||
if (kDebugMode) {
|
||||
debugPrint('[InstalledPackagesSnapshot] Save failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, PackageInfo>> load() async {
|
||||
try {
|
||||
final file = await _file();
|
||||
if (!await file.exists()) return {};
|
||||
|
||||
final raw = await file.readAsString();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return {};
|
||||
final version = decoded['v'];
|
||||
if (version != null && version is! int && kDebugMode) {
|
||||
debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version');
|
||||
}
|
||||
final installed = decoded['installed'];
|
||||
if (installed is! List) return {};
|
||||
|
||||
final result = <String, PackageInfo>{};
|
||||
for (final item in installed) {
|
||||
if (item is! Map) continue;
|
||||
final map = Map<String, dynamic>.from(item);
|
||||
final appId = map['appId'] as String?;
|
||||
if (appId == null || appId.isEmpty) continue;
|
||||
result[appId] = PackageInfo(
|
||||
appId: appId,
|
||||
name: map['name'] as String?,
|
||||
version: (map['version'] as String?) ?? '0.0.0',
|
||||
versionCode: map['versionCode'] as int?,
|
||||
signatureHash: (map['signatureHash'] as String?) ?? '',
|
||||
installTime: null,
|
||||
canInstallSilently: (map['canInstallSilently'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[InstalledPackagesSnapshot] Load failed: $e');
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'updates',
|
||||
subscriptionPrefix: 'app-updates',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget {
|
||||
tags: app.event.addressableIdTagMap,
|
||||
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
|
||||
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'debug-releases',
|
||||
subscriptionPrefix: 'app-debug-releases',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) {
|
||||
List<String> _getPreviewIdentifiers(AppStack stack) {
|
||||
final rawTags = _getRawAppTagValues(stack).toList()
|
||||
..shuffle(Random(stack.id.hashCode));
|
||||
return rawTags
|
||||
.take(3)
|
||||
.map(_extractIdentifier)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
|
||||
}
|
||||
|
||||
/// Sort app stacks: franzap/following first, then by recency
|
||||
@@ -67,10 +63,12 @@ List<AppStack> _sortStacks(
|
||||
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
return [...followed, ...others];
|
||||
} else {
|
||||
final franzapStacks =
|
||||
stacks.where((s) => s.pubkey == kFranzapPubkey).toList();
|
||||
final otherStacks =
|
||||
stacks.where((s) => s.pubkey != kFranzapPubkey).toList();
|
||||
final franzapStacks = stacks
|
||||
.where((s) => s.pubkey == kFranzapPubkey)
|
||||
.toList();
|
||||
final otherStacks = stacks
|
||||
.where((s) => s.pubkey != kFranzapPubkey)
|
||||
.toList();
|
||||
|
||||
franzapStacks.shuffle(random);
|
||||
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
scrollController.position.maxScrollExtent - 200) {
|
||||
// Load more if we haven't shown all stacks yet
|
||||
if (visibleCount.value < sortedStacks.length) {
|
||||
visibleCount.value = (visibleCount.value + _kBatchSize)
|
||||
.clamp(0, sortedStacks.length);
|
||||
visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
|
||||
0,
|
||||
sortedStacks.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
if (col * 2 < displayedStacks.length)
|
||||
_StackCard(
|
||||
stack: displayedStacks[col * 2],
|
||||
author: authorsMap[displayedStacks[col * 2].event.pubkey],
|
||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey),
|
||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
||||
author:
|
||||
authorsMap[displayedStacks[col * 2].event.pubkey],
|
||||
isAuthorLoading: isAuthorLoading(
|
||||
displayedStacks[col * 2].event.pubkey,
|
||||
),
|
||||
previewIdentifiers:
|
||||
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
||||
appsMap: appsMap,
|
||||
),
|
||||
// Bottom item
|
||||
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
|
||||
const SizedBox(height: 10),
|
||||
_StackCard(
|
||||
stack: displayedStacks[col * 2 + 1],
|
||||
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey],
|
||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey),
|
||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [],
|
||||
author:
|
||||
authorsMap[displayedStacks[col * 2 + 1]
|
||||
.event
|
||||
.pubkey],
|
||||
isAuthorLoading: isAuthorLoading(
|
||||
displayedStacks[col * 2 + 1].event.pubkey,
|
||||
),
|
||||
previewIdentifiers:
|
||||
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
|
||||
[],
|
||||
appsMap: appsMap,
|
||||
),
|
||||
],
|
||||
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
placeholder: (_, __) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 16,
|
||||
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
child: const Icon(
|
||||
Icons.apps_outlined,
|
||||
size: 16,
|
||||
|
||||
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
},
|
||||
// NOTE: It must stream=true
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'latest',
|
||||
subscriptionPrefix: 'app-latest',
|
||||
),
|
||||
(previous, next) async {
|
||||
// Always mirror storage state and ensure olderApps don't duplicate the live head
|
||||
@@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget {
|
||||
final poolState = ref.watch(poolStateProvider);
|
||||
final subscriptions = poolState?.subscriptions ?? {};
|
||||
|
||||
// Filter to only "latest" subscription
|
||||
// Filter to only "app-latest" subscription
|
||||
final latestSubs = subscriptions.entries
|
||||
.where((e) => e.key.startsWith('latest'))
|
||||
.where((e) => e.key.startsWith('app-latest'))
|
||||
.map((e) => e.value);
|
||||
|
||||
// Check relay status for latest subscription
|
||||
|
||||
+12
-12
@@ -581,26 +581,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -653,10 +653,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1141,10 +1141,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
version: "0.7.7"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1261,10 +1261,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -95,4 +95,8 @@ onPressed: () async {
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription prefix naming
|
||||
|
||||
All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend.
|
||||
|
||||
For detailed API, see models/purplebase READMEs in pub cache.
|
||||
|
||||
Reference in New Issue
Block a user