mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Fix and simplify updates system
This commit is contained in:
@@ -968,12 +968,16 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler {
|
||||
""
|
||||
}
|
||||
|
||||
// Check if we can silently update this package (we are the installer)
|
||||
val canSilentlyUpdate = canInstallSilently(bundleId)
|
||||
|
||||
out.add(mapOf(
|
||||
"name" to name,
|
||||
"bundleId" to bundleId,
|
||||
"versionName" to versionName,
|
||||
"versionCode" to versionCode,
|
||||
"signatureHash" to signatureHash,
|
||||
"canInstallSilently" to canSilentlyUpdate,
|
||||
))
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'package:zapstore/services/background_update_service.dart';
|
||||
import 'package:zapstore/services/download/download_service.dart';
|
||||
import 'package:zapstore/router.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/updates_service.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
import 'package:zapstore/services/package_manager/android_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
||||
@@ -79,10 +78,6 @@ class ZapstoreApp extends HookConsumerWidget {
|
||||
// Watch initialization state for error overlay display
|
||||
final initState = ref.watch(appInitializationProvider);
|
||||
|
||||
// Keep categorizedAppsProvider alive for badge count updates
|
||||
// Using listen instead of watch since we don't use the value directly
|
||||
ref.listen(categorizedAppsProvider, (_, __) {});
|
||||
|
||||
// Listen to app lifecycle and check for updates when app regains focus
|
||||
useEffect(() {
|
||||
final observer = _AppLifecycleObserver(ref);
|
||||
|
||||
@@ -82,7 +82,9 @@ class MobileScaffold extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final profile = ref.watch(Signer.activeProfileProvider(LocalSource()));
|
||||
final categorized = ref.watch(categorizedAppsProvider);
|
||||
final updateCount = ref.watch(updateCountProvider);
|
||||
final isLoadingUpdates = categorized.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
@@ -169,7 +171,30 @@ class MobileScaffold extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.update),
|
||||
if (updateCount > 0)
|
||||
if (isLoadingUpdates)
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -8,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (updateCount > 0)
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -8,
|
||||
@@ -255,7 +280,9 @@ class DesktopScaffold extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final profile = ref.watch(Signer.activeProfileProvider(LocalSource()));
|
||||
final categorized = ref.watch(categorizedAppsProvider);
|
||||
final updateCount = ref.watch(updateCountProvider);
|
||||
final isLoadingUpdates = categorized.isLoading;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
@@ -293,7 +320,30 @@ class DesktopScaffold extends ConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.update),
|
||||
if (updateCount > 0)
|
||||
if (isLoadingUpdates)
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -8,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (updateCount > 0)
|
||||
Positioned(
|
||||
right: -8,
|
||||
top: -8,
|
||||
|
||||
@@ -43,10 +43,7 @@ class SearchScreen 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,
|
||||
),
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'search-results',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -86,6 +86,22 @@ class _UpdatesTab extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final categorized = ref.watch(categorizedAppsProvider);
|
||||
|
||||
if (categorized.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return _UpdatesContent(categorized: categorized);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdatesContent extends HookConsumerWidget {
|
||||
const _UpdatesContent({required this.categorized});
|
||||
|
||||
final CategorizedApps categorized;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final automaticUpdates = categorized.automaticUpdates;
|
||||
final manualUpdates = categorized.manualUpdates;
|
||||
|
||||
@@ -519,7 +535,13 @@ class _UpToDateTab extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final upToDateApps = ref.watch(categorizedAppsProvider).upToDateApps;
|
||||
final categorized = ref.watch(categorizedAppsProvider);
|
||||
|
||||
if (categorized.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final upToDateApps = categorized.upToDateApps;
|
||||
|
||||
if (upToDateApps.isEmpty) {
|
||||
return Center(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
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';
|
||||
@@ -87,7 +88,6 @@ Future<bool> _performWeeklyCleanup() async {
|
||||
// Remove from database
|
||||
await downloader.database.deleteRecordWithId(task.taskId);
|
||||
} catch (_) {}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,10 +173,7 @@ Future<bool> _checkForUpdatesInBackground() async {
|
||||
'#f': {packageManager.platform},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const RemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||
);
|
||||
|
||||
// Load releases for hasUpdate check
|
||||
@@ -326,8 +323,16 @@ class BackgroundUpdateService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Initialize local notifications plugin
|
||||
/// Initialize local notifications plugin and request permission
|
||||
Future<void> _initializeNotifications() async {
|
||||
// Request notification permission on Android 13+ (API 33+)
|
||||
if (Platform.isAndroid) {
|
||||
final status = await Permission.notification.status;
|
||||
if (!status.isGranted) {
|
||||
await Permission.notification.request();
|
||||
}
|
||||
}
|
||||
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
const initializationSettingsAndroid = AndroidInitializationSettings(
|
||||
@@ -346,11 +351,10 @@ class BackgroundUpdateService {
|
||||
);
|
||||
|
||||
// Create notification channel on Android
|
||||
final androidPlugin =
|
||||
flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final androidPlugin = flutterLocalNotificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidPlugin != null) {
|
||||
await androidPlugin.createNotificationChannel(
|
||||
@@ -377,6 +381,51 @@ class BackgroundUpdateService {
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a test notification directly (bypasses WorkManager, for debugging)
|
||||
/// Returns true if notification was shown, false if permission denied
|
||||
Future<bool> showTestNotification() async {
|
||||
// Request notification permission if not granted
|
||||
if (Platform.isAndroid) {
|
||||
final status = await Permission.notification.status;
|
||||
if (!status.isGranted) {
|
||||
final result = await Permission.notification.request();
|
||||
if (!result.isGranted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
const initializationSettingsAndroid = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
const initializationSettings = InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
);
|
||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
kUpdateNotificationChannelId,
|
||||
kUpdateNotificationChannelName,
|
||||
channelDescription: kUpdateNotificationChannelDescription,
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
showWhen: true,
|
||||
autoCancel: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(android: androidDetails);
|
||||
|
||||
await flutterLocalNotificationsPlugin.show(
|
||||
99, // Test notification ID
|
||||
'Test notification',
|
||||
'This is a test notification from Zapstore',
|
||||
notificationDetails,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for the background update service
|
||||
|
||||
@@ -8,10 +8,8 @@ import '../secure_storage_service.dart';
|
||||
import 'download_info.dart';
|
||||
|
||||
/// Callback to update state in the main service
|
||||
typedef StateUpdater = void Function(
|
||||
String appId,
|
||||
DownloadInfo Function(DownloadInfo) updater,
|
||||
);
|
||||
typedef StateUpdater =
|
||||
void Function(String appId, DownloadInfo Function(DownloadInfo) updater);
|
||||
|
||||
/// Callback to remove from state
|
||||
typedef StateRemover = void Function(String appId);
|
||||
@@ -65,8 +63,10 @@ class InstallationQueue {
|
||||
final downloadInfo = _getState(appId);
|
||||
if (downloadInfo == null) return;
|
||||
|
||||
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||
final canSilent = await packageManager.canInstallSilently(appId);
|
||||
// Check silent install from PackageInfo (computed during syncInstalledPackages)
|
||||
final packages = _ref.read(packageManagerProvider);
|
||||
final packageInfo = packages.where((p) => p.appId == appId).firstOrNull;
|
||||
final canSilent = packageInfo?.canInstallSilently ?? false;
|
||||
|
||||
// If backgrounded and not silent install, defer
|
||||
if (!_isAppInForeground && !canSilent) {
|
||||
@@ -78,7 +78,8 @@ class InstallationQueue {
|
||||
// Check if permission dialog needs to be shown (Android, non-silent, first time)
|
||||
if (Platform.isAndroid && !canSilent) {
|
||||
final secureStorage = _ref.read(secureStorageServiceProvider);
|
||||
final hasSeenDialog = await secureStorage.hasSeenInstallPermissionDialog();
|
||||
final hasSeenDialog = await secureStorage
|
||||
.hasSeenInstallPermissionDialog();
|
||||
if (!hasSeenDialog) {
|
||||
_updateState(appId, (info) => info.copyWith(isReadyToInstall: true));
|
||||
return;
|
||||
@@ -122,24 +123,26 @@ class InstallationQueue {
|
||||
if (packageManager is AndroidPackageManager) {
|
||||
final retry = await packageManager.retryPendingInstall(appId);
|
||||
|
||||
if (retry.hasPending && (retry.relaunched || retry.promptAlreadyShown)) {
|
||||
if (retry.hasPending &&
|
||||
(retry.relaunched || retry.promptAlreadyShown)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (retry.hasPending && retry.sessionPending) {
|
||||
_updateState(appId, (info) => info.copyWith(
|
||||
isInstalling: false,
|
||||
isReadyToInstall: true,
|
||||
));
|
||||
_updateState(
|
||||
appId,
|
||||
(info) =>
|
||||
info.copyWith(isInstalling: false, isReadyToInstall: true),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No pending prompt - mark ready to install
|
||||
_updateState(appId, (info) => info.copyWith(
|
||||
isInstalling: false,
|
||||
isReadyToInstall: true,
|
||||
));
|
||||
_updateState(
|
||||
appId,
|
||||
(info) => info.copyWith(isInstalling: false, isReadyToInstall: true),
|
||||
);
|
||||
}
|
||||
|
||||
// Reset lock if there were stalled apps without prompts
|
||||
@@ -171,10 +174,11 @@ class InstallationQueue {
|
||||
try {
|
||||
final filePath = await downloadInfo.task.filePath();
|
||||
|
||||
_updateState(appId, (info) => info.copyWith(
|
||||
isInstalling: true,
|
||||
isReadyToInstall: false,
|
||||
));
|
||||
_updateState(
|
||||
appId,
|
||||
(info) =>
|
||||
info.copyWith(isInstalling: true, isReadyToInstall: false),
|
||||
);
|
||||
|
||||
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||
await packageManager.install(
|
||||
@@ -207,14 +211,18 @@ class InstallationQueue {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleInstallError(String appId, DownloadInfo downloadInfo, String errorMessage) {
|
||||
void _handleInstallError(
|
||||
String appId,
|
||||
DownloadInfo downloadInfo,
|
||||
String errorMessage,
|
||||
) {
|
||||
// Already in progress - put back and stop
|
||||
if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) {
|
||||
_queue.insert(0, appId);
|
||||
_updateState(appId, (info) => info.copyWith(
|
||||
isInstalling: true,
|
||||
isReadyToInstall: false,
|
||||
));
|
||||
_updateState(
|
||||
appId,
|
||||
(info) => info.copyWith(isInstalling: true, isReadyToInstall: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,14 +236,16 @@ class InstallationQueue {
|
||||
if (wasCancelled) {
|
||||
_removeFromState(appId);
|
||||
} else {
|
||||
_updateState(appId, (info) => info.copyWith(
|
||||
isInstalling: false,
|
||||
isReadyToInstall: true,
|
||||
errorDetails: isCertMismatch
|
||||
? 'CERTIFICATE_MISMATCH'
|
||||
: errorMessage.replaceFirst('Exception: ', ''),
|
||||
));
|
||||
_updateState(
|
||||
appId,
|
||||
(info) => info.copyWith(
|
||||
isInstalling: false,
|
||||
isReadyToInstall: true,
|
||||
errorDetails: isCertMismatch
|
||||
? 'CERTIFICATE_MISMATCH'
|
||||
: errorMessage.replaceFirst('Exception: ', ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,13 +34,14 @@ final class AndroidPackageManager extends PackageManager {
|
||||
throw Exception('APK file not found: $filePath');
|
||||
}
|
||||
|
||||
final result = await _channel.invokeMethod<Map<Object?, Object?>>('install', {
|
||||
'filePath': filePath,
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
'skipVerification': skipVerification,
|
||||
});
|
||||
final result = await _channel
|
||||
.invokeMethod<Map<Object?, Object?>>('install', {
|
||||
'filePath': filePath,
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
'skipVerification': skipVerification,
|
||||
});
|
||||
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
|
||||
@@ -62,9 +63,10 @@ final class AndroidPackageManager extends PackageManager {
|
||||
|
||||
@override
|
||||
Future<void> uninstall(String appId) async {
|
||||
final result = await _channel.invokeMethod<Map<Object?, Object?>>('uninstall', {
|
||||
'packageName': appId,
|
||||
});
|
||||
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
'uninstall',
|
||||
{'packageName': appId},
|
||||
);
|
||||
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
|
||||
@@ -127,21 +129,6 @@ final class AndroidPackageManager extends PackageManager {
|
||||
return _supportsSilentInstall;
|
||||
}
|
||||
|
||||
/// Check if we can silently install/update a specific package
|
||||
@override
|
||||
Future<bool> canInstallSilently(String appId) async {
|
||||
try {
|
||||
final result =
|
||||
await _channel.invokeMethod<bool>('canInstallSilently', {
|
||||
'packageName': appId,
|
||||
}) ??
|
||||
false;
|
||||
return result;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Install an update silently if possible, otherwise fall back to user confirmation
|
||||
Future<void> installUpdate(
|
||||
String appId,
|
||||
@@ -184,15 +171,15 @@ final class AndroidPackageManager extends PackageManager {
|
||||
throw Exception('Failed to launch app: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Re-launch a pending install prompt that was backgrounded.
|
||||
/// Returns structured info so callers can avoid double-prompts and bad state resets.
|
||||
Future<RetryPendingInstallResult> retryPendingInstall(String appId) async {
|
||||
try {
|
||||
final result = await _channel
|
||||
.invokeMethod<Map<Object?, Object?>>('retryPendingInstall', {
|
||||
'packageName': appId,
|
||||
});
|
||||
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
|
||||
'retryPendingInstall',
|
||||
{'packageName': appId},
|
||||
);
|
||||
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
return RetryPendingInstallResult.fromMap(resultMap);
|
||||
@@ -222,11 +209,12 @@ final class AndroidPackageManager extends PackageManager {
|
||||
Future<void> syncInstalledPackages() async {
|
||||
try {
|
||||
// Check and cache silent install capability (general check)
|
||||
final canInstallSilently =
|
||||
final canInstallSilentlyGeneral =
|
||||
await _channel.invokeMethod<bool>('canInstallSilently') ?? false;
|
||||
_supportsSilentInstall = canInstallSilently;
|
||||
_supportsSilentInstall = canInstallSilentlyGeneral;
|
||||
|
||||
// Get installed apps via method channel, excluding system apps
|
||||
// Each app includes per-package canInstallSilently status
|
||||
final installedApps =
|
||||
await _channel.invokeMethod<List<Object?>>('getInstalledApps', {
|
||||
'includeSystemApps': false,
|
||||
@@ -242,6 +230,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
final version = app['versionName'] as String? ?? '0.0.0';
|
||||
final versionCode = app['versionCode'] as int?;
|
||||
final signatureHash = app['signatureHash'] as String? ?? '';
|
||||
final canInstallSilently = app['canInstallSilently'] as bool? ?? false;
|
||||
|
||||
if (appId.isNotEmpty) {
|
||||
packages.add(
|
||||
@@ -251,6 +240,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
versionCode: versionCode,
|
||||
installTime: null, // Method channel doesn't provide install time
|
||||
signatureHash: signatureHash,
|
||||
canInstallSilently: canInstallSilently,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,12 +87,6 @@ final class DummyPackageManager extends PackageManager {
|
||||
@override
|
||||
bool get supportsSilentInstall => false;
|
||||
|
||||
@override
|
||||
Future<bool> canInstallSilently(String appId) async {
|
||||
// Mock implementation - always returns false for dummy manager
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> syncInstalledPackages() async {
|
||||
// No-op for dummy implementation
|
||||
|
||||
@@ -14,6 +14,7 @@ class PackageInfo extends Equatable {
|
||||
required this.versionCode,
|
||||
required this.signatureHash,
|
||||
this.installTime,
|
||||
this.canInstallSilently = false,
|
||||
});
|
||||
|
||||
/// The application identifier (bundle ID)
|
||||
@@ -31,6 +32,9 @@ class PackageInfo extends Equatable {
|
||||
/// When the app was installed
|
||||
final DateTime? installTime;
|
||||
|
||||
/// Whether this package can be silently updated (we are the installer)
|
||||
final bool canInstallSilently;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
appId,
|
||||
@@ -38,6 +42,7 @@ class PackageInfo extends Equatable {
|
||||
versionCode,
|
||||
signatureHash,
|
||||
installTime,
|
||||
canInstallSilently,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -103,10 +108,6 @@ abstract class PackageManager extends StateNotifier<List<PackageInfo>> {
|
||||
/// Whether the platform supports silent installation
|
||||
bool get supportsSilentInstall;
|
||||
|
||||
/// Check if a specific package can be silently installed/updated
|
||||
/// Returns false if not supported or if the package requires user confirmation
|
||||
Future<bool> canInstallSilently(String appId);
|
||||
|
||||
/// Target platform identifier used to filter file metadata (e.g., '#f' tag)
|
||||
/// Example: 'android-arm64-v8a' for Android arm64 builds
|
||||
String get platform;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
@@ -17,13 +21,6 @@ class CategorizedApps {
|
||||
final List<App> upToDateApps;
|
||||
final bool isLoading;
|
||||
|
||||
static const empty = CategorizedApps(
|
||||
automaticUpdates: [],
|
||||
manualUpdates: [],
|
||||
upToDateApps: [],
|
||||
isLoading: true,
|
||||
);
|
||||
|
||||
CategorizedApps copyWith({
|
||||
List<App>? automaticUpdates,
|
||||
List<App>? manualUpdates,
|
||||
@@ -37,37 +34,44 @@ class CategorizedApps {
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = CategorizedApps(
|
||||
automaticUpdates: [],
|
||||
manualUpdates: [],
|
||||
upToDateApps: [],
|
||||
isLoading: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Provider that maintains streaming subscription and categorizes apps
|
||||
class CategorizedAppsNotifier extends StateNotifier<CategorizedApps> {
|
||||
CategorizedAppsNotifier(this._ref) : super(CategorizedApps.empty) {
|
||||
// Listen to package manager changes and recreate query subscription
|
||||
_ref.listen(packageManagerProvider, (_, packages) {
|
||||
final installedIds = packages.map((p) => p.appId).toSet();
|
||||
final platform = _ref.read(packageManagerProvider.notifier).platform;
|
||||
_recreateQuerySubscription(installedIds, platform);
|
||||
}, fireImmediately: true);
|
||||
}
|
||||
class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
bool _hasLoadedOnce = false;
|
||||
|
||||
final Ref _ref;
|
||||
ProviderSubscription<StorageState<App>>? _querySub;
|
||||
@override
|
||||
CategorizedApps build() {
|
||||
// Wait for app initialization
|
||||
final initState = ref.watch(appInitializationProvider);
|
||||
if (initState is! AsyncData) {
|
||||
return CategorizedApps.empty;
|
||||
}
|
||||
|
||||
void _recreateQuerySubscription(Set<String> installedIds, String platform) {
|
||||
// Close old subscription
|
||||
_querySub?.close();
|
||||
// Watch installed packages - this is the source of truth
|
||||
final packages = ref.watch(packageManagerProvider);
|
||||
final installedIds = packages.map((p) => p.appId).toSet();
|
||||
|
||||
if (installedIds.isEmpty) {
|
||||
state = const CategorizedApps(
|
||||
_hasLoadedOnce = true;
|
||||
return const CategorizedApps(
|
||||
automaticUpdates: [],
|
||||
manualUpdates: [],
|
||||
upToDateApps: [],
|
||||
isLoading: false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new streaming subscription
|
||||
_querySub = _ref.listen(
|
||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||
|
||||
// Query apps with relationships loaded via `and:`
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {
|
||||
'#d': installedIds,
|
||||
@@ -77,58 +81,39 @@ class CategorizedAppsNotifier extends StateNotifier<CategorizedApps> {
|
||||
app.latestRelease,
|
||||
app.latestRelease.value?.latestMetadata,
|
||||
},
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: true,
|
||||
),
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
andSource: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'updates',
|
||||
),
|
||||
(_, appsState) {
|
||||
// Fetch authors in background
|
||||
_fetchAuthors(appsState.models);
|
||||
|
||||
// Categorize
|
||||
_categorizeApps(appsState.models);
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
return switch (appsState) {
|
||||
StorageLoading() => CategorizedApps.empty.copyWith(
|
||||
isLoading: !_hasLoadedOnce,
|
||||
),
|
||||
StorageError() => CategorizedApps.empty.copyWith(isLoading: false),
|
||||
StorageData(:final models) => _categorize(models, packages),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_querySub?.close();
|
||||
super.dispose();
|
||||
}
|
||||
CategorizedApps _categorize(List<App> apps, List<PackageInfo> packages) {
|
||||
_hasLoadedOnce = true;
|
||||
|
||||
void _fetchAuthors(List<App> apps) async {
|
||||
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
|
||||
if (authorPubkeys.isNotEmpty) {
|
||||
await _ref.storage.query(
|
||||
RequestFilter<Profile>(authors: authorPubkeys).toRequest(),
|
||||
source: const RemoteSource(
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _categorizeApps(List<App> apps) async {
|
||||
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||
final automaticUpdates = <App>[];
|
||||
final manualUpdates = <App>[];
|
||||
final upToDateApps = <App>[];
|
||||
|
||||
for (final app in apps) {
|
||||
// Only process apps that are actually installed
|
||||
final installedApps = apps.where((a) => a.installedPackage != null);
|
||||
|
||||
for (final app in installedApps) {
|
||||
if (app.hasUpdate) {
|
||||
final canSilentInstall = await packageManager.canInstallSilently(
|
||||
app.identifier,
|
||||
);
|
||||
if (canSilentInstall) {
|
||||
// Look up silent install status from package info
|
||||
final pkg = packages.firstWhereOrNull((p) => p.appId == app.identifier);
|
||||
if (pkg?.canInstallSilently ?? false) {
|
||||
automaticUpdates.add(app);
|
||||
} else {
|
||||
manualUpdates.add(app);
|
||||
@@ -138,35 +123,43 @@ class CategorizedAppsNotifier extends StateNotifier<CategorizedApps> {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically
|
||||
automaticUpdates.sort(
|
||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||
(b.name ?? b.identifier).toLowerCase(),
|
||||
),
|
||||
);
|
||||
manualUpdates.sort(
|
||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||
(b.name ?? b.identifier).toLowerCase(),
|
||||
),
|
||||
);
|
||||
upToDateApps.sort(
|
||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||
(b.name ?? b.identifier).toLowerCase(),
|
||||
),
|
||||
);
|
||||
int byName(App a, App b) => (a.name ?? a.identifier)
|
||||
.toLowerCase()
|
||||
.compareTo((b.name ?? b.identifier).toLowerCase());
|
||||
|
||||
if (mounted) {
|
||||
state = CategorizedApps(
|
||||
automaticUpdates: automaticUpdates,
|
||||
manualUpdates: manualUpdates,
|
||||
upToDateApps: upToDateApps,
|
||||
);
|
||||
}
|
||||
automaticUpdates.sort(byName);
|
||||
manualUpdates.sort(byName);
|
||||
upToDateApps.sort(byName);
|
||||
|
||||
// Fetch author profiles in background (fire and forget)
|
||||
_fetchAuthors(installedApps);
|
||||
|
||||
return CategorizedApps(
|
||||
automaticUpdates: automaticUpdates,
|
||||
manualUpdates: manualUpdates,
|
||||
upToDateApps: upToDateApps,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _fetchAuthors(Iterable<App> apps) {
|
||||
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
|
||||
if (authorPubkeys.isEmpty) return;
|
||||
unawaited(
|
||||
ref.storage.query(
|
||||
RequestFilter<Profile>(authors: authorPubkeys).toRequest(),
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: {'social', 'vertex'},
|
||||
cachedFor: Duration(hours: 2),
|
||||
stream: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final categorizedAppsProvider =
|
||||
StateNotifierProvider<CategorizedAppsNotifier, CategorizedApps>(
|
||||
NotifierProvider<CategorizedAppsNotifier, CategorizedApps>(
|
||||
CategorizedAppsNotifier.new,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user