Fix and simplify updates system

This commit is contained in:
franzap
2025-12-16 18:34:02 -03:00
parent b778f1808d
commit 5e70b44ebb
11 changed files with 290 additions and 185 deletions
@@ -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
-5
View File
@@ -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);
+52 -2
View File
@@ -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,
+1 -4
View File
@@ -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',
),
)
+23 -1
View File
@@ -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(
+60 -11
View File
@@ -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
+43 -33
View File
@@ -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;
+80 -87
View File
@@ -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,
);