From 87c0ef3ccbdaed2b79876927827f8ca946e3a322 Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 16 Jan 2026 20:38:14 -0300 Subject: [PATCH 01/20] Update dependencies in pubspec.lock: bump versions for leak_tracker, leak_tracker_flutter_testing, leak_tracker_testing, meta, test_api, and vector_math packages to their latest releases. --- pubspec.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9528246..5b55023 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -589,26 +589,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: @@ -669,10 +669,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: @@ -1157,10 +1157,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: @@ -1277,10 +1277,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: From 24518908520e9bb17dbc11be9aa5f26612732ddf Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 16 Jan 2026 20:39:23 -0300 Subject: [PATCH 02/20] Refactor storage directory usage: switch from getApplicationDocumentsDirectory to getApplicationSupportDirectory for better data management --- lib/main.dart | 2 +- lib/services/app_restart_service.dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4b31c27..15ee050 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget { } final appInitializationProvider = FutureProvider((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 diff --git a/lib/services/app_restart_service.dart b/lib/services/app_restart_service.dart index f9a5179..03ab726 100644 --- a/lib/services/app_restart_service.dart +++ b/lib/services/app_restart_service.dart @@ -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 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 maybeClearStorage(String dbPath) async { /// Sets a marker file and triggers a native app restart. Future restartApp() async { - final dir = await getApplicationDocumentsDirectory(); + final dir = await getApplicationSupportDirectory(); final marker = File('${dir.path}/$_markerFileName'); await marker.create(); await _channel.invokeMethod('restart'); From 332ed3506dcdfc934f44cac6cd955cfc106b9377 Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 16 Jan 2026 20:39:40 -0300 Subject: [PATCH 03/20] Add BackgroundPackageManager and InstalledPackagesSnapshot classes for managing package installations and snapshots in the background. This includes methods for saving and loading installed packages, with error handling for file operations. --- .../background_package_manager.dart | 53 +++++++++++ .../installed_packages_snapshot.dart | 90 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 lib/services/package_manager/background_package_manager.dart create mode 100644 lib/services/package_manager/installed_packages_snapshot.dart diff --git a/lib/services/package_manager/background_package_manager.dart b/lib/services/package_manager/background_package_manager.dart new file mode 100644 index 0000000..e72e275 --- /dev/null +++ b/lib/services/package_manager/background_package_manager.dart @@ -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 install( + String appId, + String filePath, { + required String expectedHash, + required int expectedSize, + required FileMetadata target, + }) { + throw UnsupportedError('Install not supported in background'); + } + + @override + Future uninstall(String appId) { + throw UnsupportedError('Uninstall not supported in background'); + } + + @override + Future launchApp(String appId) { + throw UnsupportedError('Launch not supported in background'); + } + + @override + Future requestPermission() { + throw UnsupportedError('Permission not supported in background'); + } + + @override + Future hasPermission() async => false; + + @override + Future syncInstalledPackages() async { + final installed = await InstalledPackagesSnapshot.load(); + state = state.copyWith(installed: installed); + } +} diff --git a/lib/services/package_manager/installed_packages_snapshot.dart b/lib/services/package_manager/installed_packages_snapshot.dart new file mode 100644 index 0000000..043af6f --- /dev/null +++ b/lib/services/package_manager/installed_packages_snapshot.dart @@ -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() async { + final dir = await getApplicationSupportDirectory(); + return File(path.join(dir.path, _fileName)); + } + + static Future save(Map installed) async { + try { + final file = await _file(); + final tmp = File('${file.path}.tmp'); + final list = installed.values + .map( + (p) => { + '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> 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 = {}; + for (final item in installed) { + if (item is! Map) continue; + final map = Map.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 {}; + } + } +} From e262fc927df2e67885d510a1f632022fb59fce01 Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 16 Jan 2026 20:40:25 -0300 Subject: [PATCH 04/20] Fix: #262 =-Enhance _ensureUpdateNotificationChannel method for better notification management. Update package manager references to BackgroundPackageManager for improved clarity and functionality. BackgroundUpdateService: Refactor file handling and notification channel setup. Introduce --- lib/services/background_update_service.dart | 114 +++++++++++------- .../android_package_manager.dart | 2 + 2 files changed, 73 insertions(+), 43 deletions(-) diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index f09b9a7..4df6719 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -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 _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 _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 _checkForUpdatesInBackground(Set? appCatalogRelays) async { storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new), packageManagerProvider.overrideWith( (ref) => Platform.isAndroid - ? AndroidPackageManager(ref) + ? BackgroundPackageManager(ref) : DummyPackageManager(ref), ), ], @@ -153,7 +154,7 @@ Future _checkForUpdatesInBackground(Set? 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 _checkForUpdatesInBackground(Set? 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(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 releases = await storage.query( + Request(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(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(assetFilters), + source: const RemoteSource(relays: 'AppCatalog', stream: false), + ); + } + } } // Re-query apps from local to ensure relationships are loaded @@ -255,6 +278,7 @@ Future _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 _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.new, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 17315f3..09f96fb 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -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. @@ -587,6 +588,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 From 579fd8ac5a10bc25d93ff7f987bc207a92322acb Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 20 Jan 2026 13:48:32 -0300 Subject: [PATCH 05/20] Removed useless getPackageArchiveInfo() (potential OOM fix), everything in bg thread, single-pass verify (also checks hash)! --- .../plugins/AndroidPackageManagerPlugin.kt | 288 +++++++++++------- 1 file changed, 171 insertions(+), 117 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 8196927..c42282f 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -37,6 +37,9 @@ private const val VERIFY_WATCHDOG_MS = 10_000L private const val INSTALL_WATCHDOG_MS = 10_000L private const val MAX_INSTALL_WATCHDOG_MS = 120_000L +/** Buffer size for file operations - 64KB for optimal throughput on large APKs */ +private const val COPY_BUFFER_SIZE = 65536 + /** * Install status values emitted via EventChannel. * These form a simple state machine with no hanging states. @@ -486,83 +489,95 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, abandonExistingSession(packageName) } - if (expectedHash != null && expectedSize != null) { - // Emit verifying status before starting hash check - emitInstallStatus(packageName, InstallStatus.VERIFYING) - result.success(mapOf("started" to true, "verifying" to true)) - - val t = Thread { - val verification = verifyApk(file, expectedHash, expectedSize) - mainHandler.post { - if (verification.isSuccess) { - startInstallSession(file, packageName, result = null) // Result already sent - } else { - emitInstallStatus( - packageName, - InstallStatus.FAILED, - verification.errorTitle, - verification.errorCode, - verification.errorDescription - ) - } - verificationThreads.remove(packageName) - } - } - verificationThreads[packageName] = t - t.start() - } else { - startInstallSession(file, packageName, result) + // Check for install restrictions on main thread before starting background work + val userManager = context.getSystemService(UserManager::class.java) + val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) + + if (installBlocked) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED) + result.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED)) + return } + + // Emit verifying status - all install work happens on background thread + emitInstallStatus(packageName, InstallStatus.VERIFYING) + result.success(mapOf("started" to true, "verifying" to true)) + + // All heavy I/O work runs on background thread to prevent ANRs + val t = Thread { + verifyAndInstall(file, packageName, expectedHash, expectedSize) + verificationThreads.remove(packageName) + } + verificationThreads[packageName] = t + t.start() } - private fun startInstallSession(apkFile: File, packageName: String, result: Result?) { + /** + * Single-pass verify and install - runs entirely on background thread. + * + * This method: + * 1. Validates APK format (ZIP magic bytes) + * 2. Creates install session + * 3. Streams file ONCE - computing hash AND copying to session simultaneously + * 4. Reports progress during copy + * 5. Commits session on success, cleans up on failure + * + * Benefits: + * - 50% less I/O (file read once instead of twice) + * - No ANRs (runs off main thread) + * - Better UX with progress reporting + * - Proper resource cleanup on all error paths + */ + private fun verifyAndInstall( + apkFile: File, + packageName: String, + expectedHash: String?, + expectedSize: Long? + ) { + // Step 1: Validate APK format before doing any heavy work + if (!isValidApkFormat(apkFile)) { + apkFile.delete() + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Invalid APK file", + ErrorCode.INVALID_FILE, + "The downloaded file is not a valid APK format." + ) + } + return + } + + val packageInstaller = context.packageManager.packageInstaller + var sessionId = -1 + var session: PackageInstaller.Session? = null + try { - val userManager = context.getSystemService(UserManager::class.java) - val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) || - userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) || - userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) - - if (installBlocked) { - emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED) - result?.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED)) - return - } - - val packageInstaller = context.packageManager.packageInstaller - val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) - if (pkgInfo == null) { - emitInstallStatus(packageName, InstallStatus.FAILED, "Invalid APK file", ErrorCode.INVALID_FILE) - result?.success(mapOf("started" to false, "error" to "Invalid APK", "errorCode" to ErrorCode.INVALID_FILE)) - return - } - - // CRITICAL: Use the passed packageName for tracking, not the APK's internal package name - // This ensures Dart's operation key matches the events we emit - // The APK's packageName should match, but we log a warning if not - val trackingPackageName = packageName - if (pkgInfo.packageName != packageName) { - Log.w(TAG, "Package name mismatch: tracking='$packageName', APK contains='${pkgInfo.packageName}'. Using tracking name for events.") - } - + // Step 2: Check if this is an update val isUpdate = try { - context.packageManager.getPackageInfo(pkgInfo.packageName, 0) + context.packageManager.getPackageInfo(packageName, 0) true } catch (_: PackageManager.NameNotFoundException) { false } - abandonExistingSession(trackingPackageName) + // Step 3: Abandon any existing session and create new one + abandonExistingSession(packageName) val sessionParams = PackageInstaller.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL ).apply { - // Set app package name for proper installer tracking - setAppPackageName(pkgInfo.packageName) + // Use the passed packageName directly - no getPackageArchiveInfo() needed + // Android validates the APK during commit anyway + setAppPackageName(packageName) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - // Explicitly set ourselves as the installer for update ownership setInstallerPackageName(context.packageName) } - setInstallLocation(pkgInfo.installLocation) + // Use AUTO install location - safe default without parsing APK + setInstallLocation(android.content.pm.PackageInfo.INSTALL_LOCATION_AUTO) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) } @@ -571,24 +586,70 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } } - val sessionId = packageInstaller.createSession(sessionParams) - val session = packageInstaller.openSession(sessionId) - // Use tracking name so events match Dart's operation key - sessionToPackage[sessionId] = trackingPackageName + sessionId = packageInstaller.createSession(sessionParams) + session = packageInstaller.openSession(sessionId) + sessionToPackage[sessionId] = packageName + + // Step 4: Single-pass verify + copy with progress reporting + val digest = if (expectedHash != null) MessageDigest.getInstance("SHA-256") else null FileInputStream(apkFile).use { fis -> val fileSize = Os.fstat(fis.fd).st_size - val sessionStream = session.openWrite(apkFile.name, 0, fileSize) - fis.copyTo(sessionStream) - fis.close() - session.fsync(sessionStream) - sessionStream.close() + + session.openWrite(apkFile.name, 0, fileSize).use { sessionStream -> + val buffer = ByteArray(COPY_BUFFER_SIZE) + var bytesCopied = 0L + var lastProgressPercent = -1 + var bytesRead: Int + + while (fis.read(buffer).also { bytesRead = it } != -1) { + // Hash the chunk if verification is needed + digest?.update(buffer, 0, bytesRead) + + // Copy to session + sessionStream.write(buffer, 0, bytesRead) + bytesCopied += bytesRead + + // Report progress (throttled to whole percentage changes) + if (fileSize > 0) { + val progressPercent = ((bytesCopied * 100) / fileSize).toInt() + if (progressPercent != lastProgressPercent && progressPercent % 5 == 0) { + lastProgressPercent = progressPercent + Log.d(TAG, "Copy progress for $packageName: $progressPercent%") + } + } + } + + session.fsync(sessionStream) + } } + // Step 5: Verify hash if expected + if (digest != null && expectedHash != null) { + val actualHash = digest.digest().joinToString("") { "%02x".format(it) } + if (!actualHash.equals(expectedHash, ignoreCase = true)) { + // Hash mismatch - clean up and fail + session.close() + packageInstaller.abandonSession(sessionId) + sessionToPackage.remove(sessionId) + + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Hash verification failed", + ErrorCode.HASH_MISMATCH, + "The downloaded file hash does not match.\n\nExpected: $expectedHash\nActual: $actualHash" + ) + } + return + } + } + + // Step 6: Commit the session val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { putExtra("sessionId", sessionId) - // Use tracking name so events match Dart's operation key - putExtra("packageName", trackingPackageName) + putExtra("packageName", packageName) putExtra("isUpdate", isUpdate) } val pendingIntent = PendingIntent.getBroadcast( @@ -598,16 +659,44 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, session.commit(pendingIntent.intentSender) session.close() + session = null // Prevent double-close in finally - emitInstallStatus(trackingPackageName, InstallStatus.STARTED) - result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName)) + mainHandler.post { + emitInstallStatus(packageName, InstallStatus.STARTED) + } } catch (e: SecurityException) { - emitInstallStatus(packageName, InstallStatus.FAILED, "Permission denied: ${e.message}", ErrorCode.PERMISSION_DENIED) - result?.success(mapOf("started" to false, "error" to "Permission denied", "errorCode" to ErrorCode.PERMISSION_DENIED)) + // Clean up session on error + session?.close() + if (sessionId >= 0) { + try { packageInstaller.abandonSession(sessionId) } catch (_: Exception) {} + sessionToPackage.remove(sessionId) + } + + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + "Permission denied: ${e.message}", + ErrorCode.PERMISSION_DENIED + ) + } } catch (e: Exception) { - emitInstallStatus(packageName, InstallStatus.FAILED, e.message ?: "Installation failed", ErrorCode.INSTALL_FAILED) - result?.success(mapOf("started" to false, "error" to e.message, "errorCode" to ErrorCode.INSTALL_FAILED)) + // Clean up session on error + session?.close() + if (sessionId >= 0) { + try { packageInstaller.abandonSession(sessionId) } catch (_: Exception) {} + sessionToPackage.remove(sessionId) + } + + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.FAILED, + e.message ?: "Installation failed", + ErrorCode.INSTALL_FAILED + ) + } } } @@ -648,48 +737,13 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } // ═══════════════════════════════════════════════════════════════════════════════ - // APK VERIFICATION + // APK VALIDATION // ═══════════════════════════════════════════════════════════════════════════════ - private data class VerificationResult( - val isSuccess: Boolean, - val errorTitle: String = "", - val errorDescription: String? = null, - val errorCode: String? = null - ) - - private fun verifyApk(file: File, expectedHash: String, expectedSize: Long): VerificationResult { - if (!isValidApkFormat(file)) { - file.delete() - return VerificationResult( - false, - "Invalid APK file", - "The downloaded file is not a valid APK format.", - ErrorCode.INVALID_FILE - ) - } - - val digest = MessageDigest.getInstance("SHA-256") - FileInputStream(file).use { fis -> - val buffer = ByteArray(8192) - var bytesRead: Int - while (fis.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - } - } - val actualHash = digest.digest().joinToString("") { "%02x".format(it) } - - if (actualHash.lowercase() != expectedHash.lowercase()) { - return VerificationResult( - false, - "Hash verification failed", - "The downloaded file hash does not match.\n\nExpected: $expectedHash\nActual: $actualHash", - ErrorCode.HASH_MISMATCH - ) - } - return VerificationResult(true) - } - + /** + * Quick APK format check - validates ZIP magic bytes. + * This is a lightweight check that doesn't load the APK into memory. + */ private fun isValidApkFormat(file: File): Boolean { return try { FileInputStream(file).use { fis -> From 3001d938dd9dbfcfaee79402dd32ad3ce4b6c0a9 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 20 Jan 2026 16:14:03 -0300 Subject: [PATCH 06/20] Fix race condition --- .../plugins/AndroidPackageManagerPlugin.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index c42282f..168d518 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -25,6 +25,8 @@ import io.flutter.plugin.common.MethodChannel.Result import java.io.File import java.io.FileInputStream import java.security.MessageDigest +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit private const val TAG = "AndroidPackageManager" @@ -646,7 +648,19 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } } - // Step 6: Commit the session + // Step 6: Emit STARTED *before* commit to avoid race with SUCCESS broadcast + // For silent installs, the SUCCESS broadcast can arrive immediately after commit. + // We use a CountDownLatch to ensure STARTED is processed on main thread before + // we call commit(), which could trigger an immediate broadcast. + val startedLatch = CountDownLatch(1) + mainHandler.post { + emitInstallStatus(packageName, InstallStatus.STARTED) + startedLatch.countDown() + } + // Wait for STARTED to be emitted (timeout prevents deadlock if main thread is blocked) + startedLatch.await(1, TimeUnit.SECONDS) + + // Step 7: Commit the session - this triggers the install broadcast val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { putExtra("sessionId", sessionId) putExtra("packageName", packageName) @@ -661,10 +675,6 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, session.close() session = null // Prevent double-close in finally - mainHandler.post { - emitInstallStatus(packageName, InstallStatus.STARTED) - } - } catch (e: SecurityException) { // Clean up session on error session?.close() From 6593ba43afd1cdad3dd5ce39c496e0010de86c5a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 20 Jan 2026 18:07:37 -0300 Subject: [PATCH 07/20] Add "installing" state to package manager, fix jumping bug in updates screen and show when app is waiting to be installed --- .../plugins/AndroidPackageManagerPlugin.kt | 99 +++++++++++++++++-- lib/screens/updates_screen.dart | 48 +++++---- .../android_package_manager.dart | 13 +++ .../package_manager/package_manager.dart | 41 ++++++-- lib/widgets/install_button.dart | 13 +-- 5 files changed, 162 insertions(+), 52 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 168d518..5ef66c8 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -50,6 +50,7 @@ object InstallStatus { const val STARTED = "started" const val VERIFYING = "verifying" const val PENDING_USER_ACTION = "pendingUserAction" + const val INSTALLING = "installing" // User accepted, system is now installing const val ALREADY_IN_PROGRESS = "alreadyInProgress" const val SUCCESS = "success" const val FAILED = "failed" @@ -99,6 +100,15 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, /** Track verification threads so we can "ping" and avoid false timeouts */ private val verificationThreads = mutableMapOf() + + /** Track sessions that are pending user action (so we can detect when user accepts) */ + private val sessionsPendingUserAction = mutableSetOf() + + /** Track sessions that have emitted INSTALLING status (to avoid duplicates) */ + private val sessionsInstalling = mutableSetOf() + + /** Session callback to detect progress after user confirms install */ + private var sessionCallback: PackageInstaller.SessionCallback? = null private var eventSink: EventChannel.EventSink? = null @@ -140,6 +150,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { pendingUserActionIntents[pkg] = confirmIntent + // Track this session as pending user action so SessionCallback can detect acceptance + instance?.sessionsPendingUserAction?.add(sessionId) + // Auto-launch dialog if app is in foreground if (isAppInForeground) { launchConfirmDialog(pkg, confirmIntent) @@ -150,6 +163,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) { sessionToPackage.remove(sessionId) pendingUserActionIntents.remove(pkg) + instance?.sessionsPendingUserAction?.remove(sessionId) + instance?.sessionsInstalling?.remove(sessionId) if (status != InstallStatus.SUCCESS) { abandonSession(sessionId) @@ -182,12 +197,27 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private fun launchConfirmDialog(packageName: String, intent: Intent) { val ctx = appContext ?: return - try { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - ctx.startActivity(intent) - Log.d(TAG, "Launched confirmation dialog for $packageName") - } catch (e: Exception) { - Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) + val inst = instance + + // Post with delay to ensure the system is ready to show the dialog. + // Without this, the dialog may not appear when the broadcast arrives + // immediately after session.commit() while the app is still processing. + val launcher: () -> Unit = { + try { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ctx.startActivity(intent) + Log.d(TAG, "Launched confirmation dialog for $packageName") + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) + } + Unit + } + + if (inst != null) { + inst.mainHandler.postDelayed(launcher, 100) + } else { + // Fallback if instance not available + Handler(Looper.getMainLooper()).postDelayed(launcher, 100) } } @@ -223,6 +253,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, ProcessLifecycleOwner.get().lifecycle.addObserver(this) } + // Register session callback to detect when user accepts install dialog + registerSessionCallback() + cleanupStaleSessions() Log.d(TAG, "AndroidPackageManagerPlugin initialized") } @@ -231,6 +264,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, mainHandler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } + unregisterSessionCallback() methodChannel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) eventSink = null @@ -262,6 +296,57 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, Log.d(TAG, "App backgrounded") } + // ═══════════════════════════════════════════════════════════════════════════════ + // SESSION CALLBACK (Detect when user accepts install dialog) + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun registerSessionCallback() { + sessionCallback = object : PackageInstaller.SessionCallback() { + override fun onCreated(sessionId: Int) {} + override fun onBadgingChanged(sessionId: Int) {} + override fun onActiveChanged(sessionId: Int, active: Boolean) {} + + override fun onProgressChanged(sessionId: Int, progress: Float) { + // When we see progress on a session that was pending user action, + // it means the user accepted and the system is now installing. + val pkg = sessionToPackage[sessionId] ?: return + + // Only emit INSTALLING once per session + if (sessionId in sessionsPendingUserAction && sessionId !in sessionsInstalling) { + sessionsInstalling.add(sessionId) + sessionsPendingUserAction.remove(sessionId) + Log.d(TAG, "User accepted install for $pkg (progress=$progress)") + onInstallResult( + sessionId = sessionId, + status = InstallStatus.INSTALLING, + packageName = pkg, + message = "Installing..." + ) + } + } + + override fun onFinished(sessionId: Int, success: Boolean) { + // Clean up tracking + sessionsPendingUserAction.remove(sessionId) + sessionsInstalling.remove(sessionId) + } + } + + context.packageManager.packageInstaller.registerSessionCallback(sessionCallback!!, mainHandler) + Log.d(TAG, "Registered session callback") + } + + private fun unregisterSessionCallback() { + sessionCallback?.let { + try { + context.packageManager.packageInstaller.unregisterSessionCallback(it) + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister session callback", e) + } + } + sessionCallback = null + } + // ═══════════════════════════════════════════════════════════════════════════════ // EVENT CHANNEL STREAM HANDLER // ═══════════════════════════════════════════════════════════════════════════════ @@ -394,7 +479,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, // Start watchdog for verify stage scheduleWatchdog(appId, VERIFY_WATCHDOG_MS) } - InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> { + InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, InstallStatus.INSTALLING -> { // Start/refresh watchdog for install stage scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) } diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index f82f2a6..5eea8e7 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -147,16 +147,7 @@ class _UpdatesContent extends HookConsumerWidget { .map((entry) => entry.key) .toSet(); - if (activeAppIds.isEmpty) { - return _UpdatesListBody( - automaticUpdates: automaticUpdates, - manualUpdates: manualUpdates, - installingApps: const [], - upToDateApps: upToDateApps, - uncatalogedApps: uncatalogedApps, - ); - } - + // Always use the same widget type to preserve scroll position return _UpdatesListBodyWithInstallingAppIds( installingAppIds: activeAppIds, automaticUpdates: automaticUpdates, @@ -184,27 +175,32 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final installingAppsState = ref.watch( - query( - tags: {'#d': installingAppIds}, - and: (app) => {app.latestRelease.query()}, - source: const LocalAndRemoteSource(relays: 'AppCatalog'), - subscriptionPrefix: 'installing-apps', - ), - ); - final updateAppIds = { ...automaticUpdates.map((a) => a.identifier), ...manualUpdates.map((a) => a.identifier), }; - final installingApps = installingAppsState.models - .where( - (app) => - installingAppIds.contains(app.identifier) && - !updateAppIds.contains(app.identifier), - ) - .toList(); + // Only query for installing apps if there are active operations + final List installingApps; + if (installingAppIds.isEmpty) { + installingApps = const []; + } else { + final installingAppsState = ref.watch( + query( + tags: {'#d': installingAppIds}, + and: (app) => {app.latestRelease.query()}, + source: const LocalAndRemoteSource(relays: 'AppCatalog'), + subscriptionPrefix: 'installing-apps', + ), + ); + installingApps = installingAppsState.models + .where( + (app) => + installingAppIds.contains(app.identifier) && + !updateAppIds.contains(app.identifier), + ) + .toList(); + } return _UpdatesListBody( automaticUpdates: automaticUpdates, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 17315f3..0e4b350 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -14,6 +14,7 @@ enum InstallStatus { started, verifying, pendingUserAction, + installing, // User accepted, system is now installing alreadyInProgress, success, failed, @@ -26,6 +27,7 @@ extension InstallStatusX on InstallStatus { 'started' => InstallStatus.started, 'verifying' => InstallStatus.verifying, 'pendingUserAction' => InstallStatus.pendingUserAction, + 'installing' => InstallStatus.installing, 'alreadyInProgress' => InstallStatus.alreadyInProgress, 'success' => InstallStatus.success, 'failed' => InstallStatus.failed, @@ -170,6 +172,17 @@ final class AndroidPackageManager extends PackageManager { } break; + case InstallStatus.installing: + // User accepted the install dialog, system is now installing. + // Transition to Installing with isSilent=true to show "Installing..." + if (filePath != null) { + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: true), + ); + } + break; + case InstallStatus.alreadyInProgress: // Real pending dialog exists - Kotlin will also send pendingUserAction event // which transitions to Installing. Nothing to do here. diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 171737a..9b32f8f 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -731,25 +731,46 @@ abstract class PackageManager extends StateNotifier { } void _processQueuedDownload() { - var activeDownloads = countOperations(); + final activeDownloads = countOperations(); if (activeDownloads >= maxConcurrentDownloads) return; - // Start downloads until we fill all available slots + // Collect queued items first to avoid iterating while modifying state + final queuedItems = {}; for (final entry in state.operations.entries) { - if (activeDownloads >= maxConcurrentDownloads) break; - if (entry.value is DownloadQueued) { - final queued = entry.value as DownloadQueued; - final downloadUrl = queued.target.urls.firstOrNull; - if (downloadUrl != null) { + queuedItems[entry.key] = entry.value as DownloadQueued; + } + } + + if (queuedItems.isEmpty) return; + + // Start downloads until we fill all available slots + var started = 0; + for (final entry in queuedItems.entries) { + if (activeDownloads + started >= maxConcurrentDownloads) break; + + final queued = entry.value; + final downloadUrl = queued.target.urls.firstOrNull; + if (downloadUrl != null) { + unawaited( _startDownloadTask( entry.key, queued.target, downloadUrl, displayName: queued.displayName, - ); - activeDownloads++; - } + ), + ); + started++; + } else { + // No URL - fail the operation so it doesn't stay queued forever + setOperation( + entry.key, + OperationFailed( + target: queued.target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); } } } diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index b3782b6..511d191 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -146,12 +146,12 @@ class InstallButton extends ConsumerWidget { isWarning: true, ), - ReadyToInstall() => _buildAsyncButton( + ReadyToInstall() => _buildSimpleButton( context, - ref, - text: isInstalled ? 'Update' : 'Install', - onPressed: () => _triggerInstall(ref), + 'Waiting...', + null, // Not tappable - system advances automatically fontSize: fontSize, + showSpinner: true, ), Installing(:final isSilent) => _buildSimpleButton( @@ -539,11 +539,6 @@ class InstallButton extends ConsumerWidget { pm.resumeDownload(app.identifier); } - Future _triggerInstall(WidgetRef ref) async { - final pm = ref.read(packageManagerProvider.notifier); - await pm.triggerInstall(app.identifier); - } - Future _retryInstall(WidgetRef ref) async { final pm = ref.read(packageManagerProvider.notifier); await pm.retryInstall(app.identifier); From ae149e278cd0e9a1501444fff92b4c361253f00e Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 20 Jan 2026 20:03:30 -0300 Subject: [PATCH 08/20] Sync installed packages on navigation to updates --- lib/router.dart | 28 ++++- lib/screens/updates_screen.dart | 2 +- spec/features/FEAT-001-package-manager.md | 122 ---------------------- 3 files changed, 28 insertions(+), 124 deletions(-) delete mode 100644 spec/features/FEAT-001-package-manager.md diff --git a/lib/router.dart b/lib/router.dart index 27169c9..53e5e2f 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; @@ -8,6 +10,7 @@ import 'package:zapstore/screens/user_screen.dart'; import 'package:zapstore/screens/search_screen.dart'; import 'package:zapstore/screens/updates_screen.dart'; import 'package:zapstore/screens/profile_screen.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; /// Root paths for each navigation branch (used for back navigation handling) const kBranchRoots = ['/search', '/updates', '/profile']; @@ -70,7 +73,9 @@ GoRoute _userRoute() { } final routerProvider = Provider((ref) { - return GoRouter( + String? previousPath; + + final router = GoRouter( initialLocation: '/search', routes: [ // Top-level route for market:// intents @@ -124,4 +129,25 @@ final routerProvider = Provider((ref) { ), ], ); + + // Listen for route changes to trigger actions + void onRouteChange() { + final currentPath = router.routerDelegate.currentConfiguration.uri.path; + final isUpdatesRoute = currentPath.startsWith('/updates'); + final wasUpdatesRoute = previousPath?.startsWith('/updates') ?? false; + + // Sync installed packages when navigating TO the updates branch + if (isUpdatesRoute && !wasUpdatesRoute) { + unawaited( + ref.read(packageManagerProvider.notifier).syncInstalledPackages(), + ); + } + + previousPath = currentPath; + } + + router.routerDelegate.addListener(onRouteChange); + ref.onDispose(() => router.routerDelegate.removeListener(onRouteChange)); + + return router; }); diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 5eea8e7..1c8e3f6 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -61,7 +61,7 @@ class _ConnectionStatusIndicator extends ConsumerWidget { final statusColor = hasActiveConnection ? Colors.green : Colors.grey; final statusText = hasActiveConnection - ? 'Connected · Checking for updates' + ? 'Connected · Streaming updates' : 'Offline'; return Container( diff --git a/spec/features/FEAT-001-package-manager.md b/spec/features/FEAT-001-package-manager.md deleted file mode 100644 index 83b8b81..0000000 --- a/spec/features/FEAT-001-package-manager.md +++ /dev/null @@ -1,122 +0,0 @@ -# FEAT-001 — Package Manager - -## Goal - -Single source of truth for installed packages and active install operations. -Manages the complete lifecycle: download → verify → install, with pause/resume/cancel support. - -## Non-Goals - -- Managing non-APK file types -- Auto-updating without user awareness -- Installing from sources other than Nostr-published releases - -## User-Visible Behavior - -### Download Phase - -- User taps "Install" → download begins, progress shown -- User can pause/resume/cancel active downloads -- Multiple downloads queue automatically (max 3 concurrent) -- "Update All" queues all updates immediately with visual feedback - -### Verification Phase - -- After download completes, hash verification runs -- Verification state is visible (not hidden) -- Hash mismatch blocks install with clear error - -### Permission Phase - -- If "Install unknown apps" permission not granted, user is prompted -- Permission state is explicit in UI -- Once granted, all waiting installs advance automatically - -### Install Phase - -- Native Android install dialog shown -- One install dialog at a time (serialized) -- If user dismisses dialog, install shows "Tap to retry" state -- Success updates installed list immediately (no stale UI) - -### Failure States - -- Download failed → clear error, can retry -- Hash mismatch → error, cannot proceed -- Certificate mismatch → offer "Uninstall and reinstall" option -- Permission denied → guidance to enable in Settings - -## State Machine - -Operations follow this sealed class hierarchy (`install_operation.dart`): - -``` -DownloadQueued → Downloading ↔ DownloadPaused - ↓ - Verifying - ↓ - AwaitingPermission (if needed) - ↓ - ReadyToInstall - ↓ - Installing → AwaitingUserAction (if dismissed) - ↓ - [cleared] or OperationFailed -``` - -State transitions are unidirectional except Downloading ↔ DownloadPaused. - -## Edge Cases - -- Network drops mid-download → download pauses or fails gracefully, can retry -- App backgrounded during install → install completes, UI updates on return -- 404 from origin server → automatic CDN fallback before failing -- Stale operations (>7 days) → garbage collected on app restart -- Android package DB race condition → state updated from target metadata, not sync - -## Invariants - -These are non-negotiable. Violations mean the implementation is broken. - -1. **UI never blocks** — `install()` returns immediately; events drive state via EventChannel -2. **One install dialog at a time** — Android PackageInstaller limitation, enforced by serialization -3. **Hash verification before install** — Native side verifies before install session opens -4. **Permission flow is explicit** — `AwaitingPermission` state exists for UI feedback -5. **Downloaded files are cleaned up** — Deleted after success or dismissal -6. **No polling** — All state changes via callbacks/events, never periodic checks - -## Integration Boundaries - -``` -┌─────────────────────────────────────────────────────────────┐ -│ PackageManager (Dart) │ -│ - State machine owner │ -│ - Download management (background_downloader) │ -│ - Orchestrates flow │ -└─────────────────────────┬───────────────────────────────────┘ - │ MethodChannel / EventChannel -┌─────────────────────────▼───────────────────────────────────┐ -│ AndroidPackageManagerPlugin (Kotlin) │ -│ - Hash verification │ -│ - PackageInstaller session │ -│ - Permission checks │ -│ - Emits: verifying/started/success/failed/cancelled │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Acceptance Criteria - -- [ ] User can download, pause, resume, cancel downloads -- [ ] User can install apps with proper verification -- [ ] Multiple downloads queue correctly (max 3 concurrent) -- [ ] Install failures show actionable error messages -- [ ] Certificate mismatch offers force-update option -- [ ] UI remains responsive throughout all operations -- [ ] No operations block the UI thread - -## Files - -- `lib/services/package_manager/package_manager.dart` — Base class, state machine -- `lib/services/package_manager/install_operation.dart` — State definitions -- `lib/services/package_manager/android_package_manager.dart` — Android implementation -- `android/.../AndroidPackageManagerPlugin.kt` — Native side From 8197355cd30a329a1c68eff6deb5404d2272a012 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 22 Jan 2026 21:14:52 -0300 Subject: [PATCH 09/20] Fix Clear local storage overflow, closes #284 --- lib/screens/profile_screen.dart | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index c390959..5fcdef5 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -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( From a953ebbcf49b8ad5296007a9eb69c59e4b74cbfb Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 20 Jan 2026 22:24:04 -0300 Subject: [PATCH 10/20] - Add app catalog relay management to profile screen. - Introduce refresh tokens for updates and latest releases subscriptions to ensure UI updates. - Implement utility functions for relay URL handling. --- lib/screens/profile_screen.dart | 484 +++++++++++++++++++++ lib/services/updates_service.dart | 16 +- lib/utils/relay_utils.dart | 96 ++++ lib/widgets/latest_releases_container.dart | 6 +- 4 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 lib/utils/relay_utils.dart diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5fcdef5..dc000b3 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,16 +17,48 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/relay_utils.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; +import 'package:zapstore/widgets/latest_releases_container.dart'; // Note: Relay debugging features have been removed as they depend on internal APIs // that are no longer public in purplebase 0.3.3+ +/// Refresh token for app catalog relay list query. +/// Invalidate this provider to force a re-fetch of the relay list. +final _appCatalogRelaysRefreshProvider = StateProvider((ref) => 0); + +/// Provider for the user's app catalog relay list. +/// Watches the active pubkey and refresh token to enable invalidation. +final _appCatalogRelayListProvider = + Provider?>((ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + final refreshToken = ref.watch(_appCatalogRelaysRefreshProvider); + + if (pubkey == null) { + return null; + } + + return ref.watch( + query( + authors: {pubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'bootstrap', + stream: false, + ), + // Include refresh token to force new query after modifications + subscriptionPrefix: 'user-appcatalog-relays-$refreshToken', + ), + ); + }); + /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { const ProfileScreen({super.key}); @@ -64,6 +96,11 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), + // App Catalog Relay Management Section + const _AppCatalogRelayManagementSection(), + + const SizedBox(height: 16), + // Data Management Section const _DataManagementSection(), @@ -1353,6 +1390,453 @@ class _EmptyState extends StatelessWidget { } } +/// App Catalog Relay Management Section - manages app catalog relays (kind 10067) +/// These are relays for discovering apps, NOT social relays like Damus/Primal. +class _AppCatalogRelayManagementSection extends HookConsumerWidget { + const _AppCatalogRelayManagementSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + // Only show when user is signed in + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + final relayUrlController = useTextEditingController(); + final isOperating = useState(false); + final selectedProtocol = useState('wss://'); + + // Watch the named provider (can be invalidated after save/publish) + final relayListState = ref.watch(_appCatalogRelayListProvider); + + final existingRelayList = relayListState?.models.firstOrNull; + final relays = (existingRelayList?.readRelays ?? {}).toList() + ..sort(); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'App Catalog Relays', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (isOperating.value) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 8), + + // Info text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'These relays are used to discover apps. Not for social content like Damus or Primal.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Current relays list + if (relays.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Using default catalog relays. Add a relay to override.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ) + else + ...relays.map((relayUrl) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + Icon( + Icons.cloud, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: isOperating.value + ? null + : () => _removeAppCatalogRelay( + context, + ref, + existingRelayList, + relayUrl, + isOperating, + ), + tooltip: 'Remove app catalog relay', + ), + ], + ), + ); + }), + + const SizedBox(height: 8), + + // Add relay input + Row( + children: [ + DropdownButton( + value: selectedProtocol.value, + items: const [ + DropdownMenuItem( + value: 'wss://', + child: Text('wss://'), + ), + DropdownMenuItem( + value: 'ws://', + child: Text('ws://'), + ), + ], + onChanged: isOperating.value + ? null + : (value) { + if (value != null) { + selectedProtocol.value = value; + } + }, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + underline: const SizedBox.shrink(), + isDense: true, + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: relayUrlController, + decoration: InputDecoration( + hintText: 'relay.example.com', + hintStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + ), + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + onSubmitted: isOperating.value + ? null + : (value) { + if (value.trim().isNotEmpty) { + // Remove protocol if user typed it manually + final cleaned = value.trim().replaceFirst( + RegExp(r'^(ws|wss)://'), + '', + ); + _addAppCatalogRelay( + context, + ref, + existingRelayList, + '${selectedProtocol.value}$cleaned', + relayUrlController, + isOperating, + ); + } + }, + ), + ), + const SizedBox(width: 8), + AsyncButtonBuilder( + onPressed: () async { + final url = relayUrlController.text.trim(); + if (url.isNotEmpty) { + // Remove protocol if user typed it manually + final cleaned = url.replaceFirst( + RegExp(r'^(ws|wss)://'), + '', + ); + await _addAppCatalogRelay( + context, + ref, + existingRelayList, + '${selectedProtocol.value}$cleaned', + relayUrlController, + isOperating, + ); + } + }, + builder: (context, child, callback, buttonState) { + final isLoading = buttonState.maybeWhen( + loading: () => true, + orElse: () => false, + ); + return IconButton( + onPressed: isLoading || isOperating.value ? null : callback, + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add), + tooltip: 'Add relay', + style: IconButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ); + }, + child: const SizedBox.shrink(), + ), + ], + ), + ], + ), + ), + ); + } + + Future _addAppCatalogRelay( + BuildContext context, + WidgetRef ref, + AppCatalogRelayList? existingRelayList, + String relayUrl, + TextEditingController controller, + ValueNotifier isOperating, + ) async { + // Validate and normalize URL + final normalizedUrl = validateAndNormalizeRelayUrl(relayUrl); + if (normalizedUrl == null) { + context.showError( + 'Invalid app catalog relay URL', + description: 'Must be a valid WebSocket URL (ws:// or wss://)', + ); + return; + } + + // Check for duplicates (case-insensitive on host) + final existingRelays = existingRelayList?.readRelays ?? {}; + if (isDuplicateRelay(normalizedUrl, existingRelays)) { + context.showError( + 'App catalog relay already exists', + description: 'This relay is already in your list.', + ); + return; + } + + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + context.showError('Sign in required'); + return; + } + + try { + isOperating.value = true; + + // Create partial model with existing relays + final partialRelayList = PartialAppCatalogRelayList(); + for (final relay in existingRelays) { + partialRelayList.addReadRelay(relay); + } + // Add the new relay (normalized) + partialRelayList.addReadRelay(normalizedUrl); + + // Sign the event + final signedRelayList = await partialRelayList.signWith(signer); + + // Save locally and publish to bootstrap relays + await ref.storage.save({signedRelayList}); + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + + // Increment refresh token to force UI refresh + ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; + + // Invalidate latest releases to force new query with updated relays + // This will make the new relay appear in Debug Info immediately + ref.invalidate(latestReleasesProvider); + ref.read(updatesRefreshProvider.notifier).state++; + ref.read(latestReleasesRefreshProvider.notifier).state++; + + + controller.clear(); + + if (context.mounted) { + context.showInfo('App catalog relay added'); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to add app catalog relay', description: '$e'); + } + } finally { + isOperating.value = false; + } + } + + Future _removeAppCatalogRelay( + BuildContext context, + WidgetRef ref, + AppCatalogRelayList? existingRelayList, + String relayUrl, + ValueNotifier isOperating, + ) async { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + context.showError('Sign in required'); + return; + } + + try { + isOperating.value = true; + + final existingRelays = existingRelayList?.readRelays ?? {}; + + // Create partial model with all relays except the one being removed + final partialRelayList = PartialAppCatalogRelayList(); + for (final relay in existingRelays) { + if (relay != relayUrl) { + partialRelayList.addReadRelay(relay); + } + } + + // Sign the event + final signedRelayList = await partialRelayList.signWith(signer); + + // Save locally and publish to bootstrap relays + await ref.storage.save({signedRelayList}); + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + + // Increment refresh token to force UI refresh + ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; + + // Invalidate latest releases to force new query with updated relays + // This will make the removed relay disappear from Debug Info immediately + ref.invalidate(latestReleasesProvider); + ref.read(updatesRefreshProvider.notifier).state++; + ref.read(latestReleasesRefreshProvider.notifier).state++; + + // Cancel subscriptions tied to the removed relay only + await _cancelAppCatalogSubscriptions(ref, {relayUrl}); + + if (context.mounted) { + context.showInfo('App catalog relay removed'); + } + } catch (e) { + if (context.mounted) { + context.showError( + 'Failed to remove app catalog relay', + description: '$e', + ); + } + } finally { + isOperating.value = false; + } + } + + Future _cancelAppCatalogSubscriptions( + WidgetRef ref, + Set relaysToCancel, + ) async { + final poolState = ref.read(poolStateProvider); + final subscriptions = poolState?.subscriptions ?? const {}; + final targets = relaysToCancel.map((r) => r.toLowerCase()).toSet(); + + for (final sub in subscriptions.values) { + final hasTargetRelay = + sub.relays.keys.any((relay) => targets.contains(relay.toLowerCase())); + if (hasTargetRelay) { + await ref.storage.cancel(sub.request); + } + } + } +} + class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1a5942b..259a48a 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -7,6 +7,9 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/version_utils.dart'; +/// Refresh token for updates subscriptions. +final updatesRefreshProvider = StateProvider((ref) => 0); + /// Categorized apps state class CategorizedApps { const CategorizedApps({ @@ -83,6 +86,7 @@ class CategorizedAppsNotifier extends Notifier { } final platform = ref.read(packageManagerProvider.notifier).platform; + final refreshToken = ref.watch(updatesRefreshProvider); // Query apps with relationships loaded via `and:` final appsState = ref.watch( @@ -104,7 +108,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates', + subscriptionPrefix: 'updates-$refreshToken', ), ); @@ -113,13 +117,15 @@ class CategorizedAppsNotifier extends Notifier { isLoading: !_hasLoadedOnce, ), StorageError() => CategorizedApps.empty.copyWith(isLoading: false), - StorageData(:final models) => _categorize(models, installedPackages), + StorageData(:final models) => + _categorize(models, installedPackages, refreshToken), }; } CategorizedApps _categorize( List apps, List installedPackages, + int refreshToken, ) { _hasLoadedOnce = true; @@ -177,7 +183,7 @@ class CategorizedAppsNotifier extends Notifier { upToDateApps.sort(byName); // Fetch author profiles in background (fire and forget) - _fetchAuthors(installedApps); + _fetchAuthors(installedApps, refreshToken); return CategorizedApps( automaticUpdates: automaticUpdates, @@ -196,7 +202,7 @@ class CategorizedAppsNotifier extends Notifier { return canUpgrade(installed.version, latest.version); } - void _fetchAuthors(Iterable apps) { + void _fetchAuthors(Iterable apps, int refreshToken) { final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); if (authorPubkeys.isEmpty) return; unawaited( @@ -207,7 +213,7 @@ class CategorizedAppsNotifier extends Notifier { cachedFor: Duration(hours: 2), stream: false, ), - subscriptionPrefix: 'updates-profiles', + subscriptionPrefix: 'updates-profiles-$refreshToken', ), ); } diff --git a/lib/utils/relay_utils.dart b/lib/utils/relay_utils.dart new file mode 100644 index 0000000..4ed93bd --- /dev/null +++ b/lib/utils/relay_utils.dart @@ -0,0 +1,96 @@ +/// Utility functions for WebSocket relay URL handling. + +/// Validates and normalizes a relay URL: checks format, removes trailing +/// slashes, lowercases the host. Returns null if invalid. +/// +/// Examples: +/// - ` WSS://Relay.Example.Com/ ` → `wss://relay.example.com` +/// - `wss://relay.example.com/` → `wss://relay.example.com` +/// - `wss://relay.example.com:443/path/` → `wss://relay.example.com:443/path` +/// - `invalid-url` → `null` +String? validateAndNormalizeRelayUrl(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + final uri = Uri.tryParse(trimmed); + if (uri == null) return null; + + // Must have ws or wss scheme + if (uri.scheme != 'ws' && uri.scheme != 'wss') return null; + + // Must have a host + if (uri.host.isEmpty) return null; + if (!_isValidRelayHost(uri.host)) return null; + + // Rebuild normalized URL: scheme://host[:port][/path] + final buffer = StringBuffer() + ..write(uri.scheme) + ..write('://') + ..write(uri.host.toLowerCase()); + + if (uri.hasPort && uri.port != 0) { + buffer + ..write(':') + ..write(uri.port); + } + + // Add path if present, but remove trailing slashes + var path = uri.path; + while (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + if (path.isNotEmpty) { + buffer.write(path); + } + + return buffer.toString(); +} + +bool _isValidRelayHost(String host) { + if (host == 'localhost') return true; + + // IPv4 validation (e.g., 192.168.0.1) + final ipv4Match = RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host); + if (ipv4Match) { + final parts = host.split('.'); + return parts.every((p) { + final value = int.tryParse(p); + return value != null && value >= 0 && value <= 255; + }); + } + + // Domain validation + if (host.length > 253) return false; + final labels = host.split('.'); + if (labels.length < 2) return false; + for (final label in labels) { + if (label.isEmpty || label.length > 63) return false; + if (!RegExp(r'^[a-zA-Z0-9-]+$').hasMatch(label)) return false; + if (label.startsWith('-') || label.endsWith('-')) return false; + } + return true; +} + +/// Checks if a relay URL already exists in the set. +/// Normalizes both URLs before comparison for robustness. +bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { + for (final existing in existingRelays) { + final existingNormalized = validateAndNormalizeRelayUrl(existing); + if (existingNormalized == null) continue; + + if (existingNormalized == normalizedUrl) { + return true; + } + } + return false; +} + +/// Formats a relay URL for display by removing the scheme and trailing slash. +/// +/// Example: `wss://relay.example.com/` → `relay.example.com` +String formatRelayUrlShort(String relayUrl) { + return relayUrl + .replaceAll('wss://', '') + .replaceAll('ws://', '') + .replaceAll(RegExp(r'/$'), ''); +} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d9dd42c..14016cd 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -7,6 +7,9 @@ import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; +/// Refresh token for latest releases subscriptions. +final latestReleasesRefreshProvider = StateProvider((ref) => 0); + class LatestReleasesContainer extends HookConsumerWidget { const LatestReleasesContainer({ super.key, @@ -300,6 +303,7 @@ class LatestReleasesNotifier extends StateNotifier { void _startQuery() { _sub?.close(); + final refreshToken = ref.read(latestReleasesRefreshProvider); _sub = ref.listen>( query( @@ -321,7 +325,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest', + subscriptionPrefix: 'latest-$refreshToken', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head From 9e177c6bb33e5aad574e00fc15cf3ce8a991977a Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Tue, 20 Jan 2026 23:11:38 -0300 Subject: [PATCH 11/20] - Enhance profile screen relay management with skeleton loading and input validation. - Introduce relay URL normalization for comparison to prevent duplicates. - Update UI elements for better user experience and feedback during operations. --- lib/screens/profile_screen.dart | 293 ++++++++++++++++++++------------ lib/utils/relay_utils.dart | 47 ++++- 2 files changed, 229 insertions(+), 111 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index dc000b3..edb590a 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -26,6 +26,7 @@ import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; import 'package:zapstore/widgets/latest_releases_container.dart'; +import 'package:skeletonizer/skeletonizer.dart'; // Note: Relay debugging features have been removed as they depend on internal APIs // that are no longer public in purplebase 0.3.3+ @@ -1407,6 +1408,17 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { final relayUrlController = useTextEditingController(); final isOperating = useState(false); final selectedProtocol = useState('wss://'); + final hasText = useState(false); + + // Listen to text changes to enable/disable add button + useEffect(() { + void listener() { + hasText.value = relayUrlController.text.trim().isNotEmpty; + } + + relayUrlController.addListener(listener); + return () => relayUrlController.removeListener(listener); + }, [relayUrlController]); // Watch the named provider (can be invalidated after save/publish) final relayListState = ref.watch(_appCatalogRelayListProvider); @@ -1457,9 +1469,7 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { child: Icon( Icons.info_outline, size: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), + color: Theme.of(context).colorScheme.primary, ), ), const SizedBox(width: 10), @@ -1492,108 +1502,167 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { ), ) else - ...relays.map((relayUrl) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: SkeletonizerConfig( + data: AppColors.getSkeletonizerConfig( + Theme.of(context).brightness, ), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), + child: Skeletonizer( + enabled: isOperating.value, + child: ListView.separated( + shrinkWrap: true, + physics: relays.length > 4 + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: relays.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final relayUrl = relays[index]; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 24, + child: Center( + child: Icon( + Icons.cloud, + size: 16, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + onPressed: isOperating.value + ? null + : () => _removeAppCatalogRelay( + context, + ref, + existingRelayList, + relayUrl, + isOperating, + ), + tooltip: 'Remove app catalog relay', + ), + ), + ], + ), + ); + }, ), ), - child: Row( - children: [ - Icon( - Icons.cloud, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - relayUrl, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - ), - ), - IconButton( - icon: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.error, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: isOperating.value - ? null - : () => _removeAppCatalogRelay( - context, - ref, - existingRelayList, - relayUrl, - isOperating, - ), - tooltip: 'Remove app catalog relay', - ), - ], - ), - ); - }), + ), + ), const SizedBox(height: 8), // Add relay input Row( children: [ - DropdownButton( - value: selectedProtocol.value, - items: const [ - DropdownMenuItem( - value: 'wss://', - child: Text('wss://'), - ), - DropdownMenuItem( - value: 'ws://', - child: Text('ws://'), - ), - ], - onChanged: isOperating.value - ? null - : (value) { - if (value != null) { - selectedProtocol.value = value; - } - }, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - underline: const SizedBox.shrink(), - isDense: true, - ), - const SizedBox(width: 8), Expanded( child: TextField( controller: relayUrlController, + enabled: !isOperating.value, decoration: InputDecoration( hintText: 'relay.example.com', hintStyle: TextStyle( + fontSize: 12, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.4), ), + prefixIcon: Padding( + padding: const EdgeInsets.only(left: 8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedProtocol.value, + items: const [ + DropdownMenuItem( + value: 'wss://', + child: Text('wss://'), + ), + DropdownMenuItem( + value: 'ws://', + child: Text('ws://'), + ), + ], + onChanged: isOperating.value + ? null + : (value) { + if (value != null) { + selectedProtocol.value = value; + } + }, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + isDense: true, + iconSize: 14, + ), + ), + ), + ), + ), + prefixIconConstraints: const BoxConstraints( + minWidth: 70, + minHeight: 0, + ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, @@ -1613,9 +1682,9 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { if (value.trim().isNotEmpty) { // Remove protocol if user typed it manually final cleaned = value.trim().replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); + RegExp(r'^(ws|wss)://'), + '', + ); _addAppCatalogRelay( context, ref, @@ -1635,9 +1704,9 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { if (url.isNotEmpty) { // Remove protocol if user typed it manually final cleaned = url.replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); + RegExp(r'^(ws|wss)://'), + '', + ); await _addAppCatalogRelay( context, ref, @@ -1653,8 +1722,10 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { loading: () => true, orElse: () => false, ); + final isDisabled = + isLoading || isOperating.value || !hasText.value; return IconButton( - onPressed: isLoading || isOperating.value ? null : callback, + onPressed: isDisabled ? null : callback, icon: isLoading ? const SizedBox( width: 20, @@ -1664,8 +1735,17 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { : const Icon(Icons.add), tooltip: 'Add relay', style: IconButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - foregroundColor: Theme.of(context).colorScheme.onPrimaryContainer, + backgroundColor: isDisabled + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.primaryContainer, + foregroundColor: isDisabled + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4) + : Theme.of(context).colorScheme.onPrimaryContainer, ), ); }, @@ -1742,7 +1822,6 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { ref.read(updatesRefreshProvider.notifier).state++; ref.read(latestReleasesRefreshProvider.notifier).state++; - controller.clear(); if (context.mounted) { @@ -1770,6 +1849,10 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { return; } + // Capture a stable context reference before async operations + // The itemBuilder context may become invalid after widget rebuilds + final navigatorContext = Navigator.of(context).context; + try { isOperating.value = true; @@ -1792,6 +1875,11 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { signedRelayList, }, source: const RemoteSource(relays: 'bootstrap')); + // Show success toast using stable context + if (navigatorContext.mounted) { + navigatorContext.showInfo('App catalog relay removed'); + } + // Increment refresh token to force UI refresh ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; @@ -1803,13 +1891,9 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { // Cancel subscriptions tied to the removed relay only await _cancelAppCatalogSubscriptions(ref, {relayUrl}); - - if (context.mounted) { - context.showInfo('App catalog relay removed'); - } } catch (e) { - if (context.mounted) { - context.showError( + if (navigatorContext.mounted) { + navigatorContext.showError( 'Failed to remove app catalog relay', description: '$e', ); @@ -1828,8 +1912,9 @@ class _AppCatalogRelayManagementSection extends HookConsumerWidget { final targets = relaysToCancel.map((r) => r.toLowerCase()).toSet(); for (final sub in subscriptions.values) { - final hasTargetRelay = - sub.relays.keys.any((relay) => targets.contains(relay.toLowerCase())); + final hasTargetRelay = sub.relays.keys.any( + (relay) => targets.contains(relay.toLowerCase()), + ); if (hasTargetRelay) { await ref.storage.cancel(sub.request); } diff --git a/lib/utils/relay_utils.dart b/lib/utils/relay_utils.dart index 4ed93bd..9ddd879 100644 --- a/lib/utils/relay_utils.dart +++ b/lib/utils/relay_utils.dart @@ -71,14 +71,47 @@ bool _isValidRelayHost(String host) { return true; } -/// Checks if a relay URL already exists in the set. -/// Normalizes both URLs before comparison for robustness. -bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { - for (final existing in existingRelays) { - final existingNormalized = validateAndNormalizeRelayUrl(existing); - if (existingNormalized == null) continue; +/// Normalizes a relay URL for comparison, including default ports. +/// Returns a canonical form: scheme://host:port/path +/// where port is always explicit (defaults: 443 for wss, 80 for ws). +String? _normalizeForComparison(String url) { + final normalized = validateAndNormalizeRelayUrl(url); + if (normalized == null) return null; - if (existingNormalized == normalizedUrl) { + final uri = Uri.parse(normalized); + final defaultPort = uri.scheme == 'wss' ? 443 : 80; + final port = uri.hasPort && uri.port != 0 ? uri.port : defaultPort; + + final buffer = StringBuffer() + ..write(uri.scheme) + ..write('://') + ..write(uri.host.toLowerCase()) + ..write(':') + ..write(port); + + var path = uri.path; + while (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + if (path.isNotEmpty) { + buffer.write(path); + } + + return buffer.toString(); +} + +/// Checks if a relay URL already exists in the set. +/// Normalizes both URLs before comparison, considering default ports. +/// Treats wss://relay.com and wss://relay.com:443 as duplicates. +bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { + final newUrlCanonical = _normalizeForComparison(normalizedUrl); + if (newUrlCanonical == null) return false; + + for (final existing in existingRelays) { + final existingCanonical = _normalizeForComparison(existing); + if (existingCanonical == null) continue; + + if (existingCanonical == newUrlCanonical) { return true; } } From df8b42683d8ce8abf94727624383136c37c0c363 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 16:59:26 -0300 Subject: [PATCH 12/20] Restart app to apply relay changes --- lib/main.dart | 6 +- lib/screens/profile_screen.dart | 574 +-------------------- lib/services/secure_storage_service.dart | 22 +- lib/services/updates_service.dart | 16 +- lib/utils/relay_utils.dart | 129 ----- lib/widgets/latest_releases_container.dart | 6 +- lib/widgets/relay_management_card.dart | 570 ++++++++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 9 files changed, 609 insertions(+), 720 deletions(-) delete mode 100644 lib/utils/relay_utils.dart create mode 100644 lib/widgets/relay_management_card.dart diff --git a/lib/main.dart b/lib/main.dart index 15ee050..112284d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; +import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -245,7 +246,10 @@ final appInitializationProvider = FutureProvider((ref) async { }); // AmberSigner provider for Nostr authentication -final amberSignerProvider = Provider(AmberSigner.new); +// Uses SecureStoragePubkeyPersistence to survive database clears +final amberSignerProvider = Provider( + (ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()), +); Future _attemptAutoSignIn(Ref ref) async { try { diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index edb590a..cbc2d78 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,48 +17,13 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/utils/relay_utils.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; -import 'package:zapstore/widgets/latest_releases_container.dart'; -import 'package:skeletonizer/skeletonizer.dart'; - -// Note: Relay debugging features have been removed as they depend on internal APIs -// that are no longer public in purplebase 0.3.3+ - -/// Refresh token for app catalog relay list query. -/// Invalidate this provider to force a re-fetch of the relay list. -final _appCatalogRelaysRefreshProvider = StateProvider((ref) => 0); - -/// Provider for the user's app catalog relay list. -/// Watches the active pubkey and refresh token to enable invalidation. -final _appCatalogRelayListProvider = - Provider?>((ref) { - final pubkey = ref.watch(Signer.activePubkeyProvider); - final refreshToken = ref.watch(_appCatalogRelaysRefreshProvider); - - if (pubkey == null) { - return null; - } - - return ref.watch( - query( - authors: {pubkey}, - limit: 1, - source: const LocalAndRemoteSource( - relays: 'bootstrap', - stream: false, - ), - // Include refresh token to force new query after modifications - subscriptionPrefix: 'user-appcatalog-relays-$refreshToken', - ), - ); - }); +import 'package:zapstore/widgets/relay_management_card.dart'; /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { @@ -98,7 +63,7 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), // App Catalog Relay Management Section - const _AppCatalogRelayManagementSection(), + const RelayManagementCard(), const SizedBox(height: 16), @@ -1391,537 +1356,6 @@ class _EmptyState extends StatelessWidget { } } -/// App Catalog Relay Management Section - manages app catalog relays (kind 10067) -/// These are relays for discovering apps, NOT social relays like Damus/Primal. -class _AppCatalogRelayManagementSection extends HookConsumerWidget { - const _AppCatalogRelayManagementSection(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - - // Only show when user is signed in - if (signedInPubkey == null) { - return const SizedBox.shrink(); - } - - final relayUrlController = useTextEditingController(); - final isOperating = useState(false); - final selectedProtocol = useState('wss://'); - final hasText = useState(false); - - // Listen to text changes to enable/disable add button - useEffect(() { - void listener() { - hasText.value = relayUrlController.text.trim().isNotEmpty; - } - - relayUrlController.addListener(listener); - return () => relayUrlController.removeListener(listener); - }, [relayUrlController]); - - // Watch the named provider (can be invalidated after save/publish) - final relayListState = ref.watch(_appCatalogRelayListProvider); - - final existingRelayList = relayListState?.models.firstOrNull; - final relays = (existingRelayList?.readRelays ?? {}).toList() - ..sort(); - - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - 'App Catalog Relays', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - if (isOperating.value) - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const SizedBox(height: 8), - - // Info text - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - 'These relays are used to discover apps. Not for social content like Damus or Primal.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Current relays list - if (relays.isEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'Using default catalog relays. Add a relay to override.', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.55), - ), - ), - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: SkeletonizerConfig( - data: AppColors.getSkeletonizerConfig( - Theme.of(context).brightness, - ), - child: Skeletonizer( - enabled: isOperating.value, - child: ListView.separated( - shrinkWrap: true, - physics: relays.length > 4 - ? const BouncingScrollPhysics() - : const NeverScrollableScrollPhysics(), - itemCount: relays.length, - separatorBuilder: (_, __) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final relayUrl = relays[index]; - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: 24, - child: Center( - child: Icon( - Icons.cloud, - size: 16, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - relayUrl, - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - ), - ), - SizedBox( - width: 24, - height: 24, - child: IconButton( - icon: Icon( - Icons.close, - size: 18, - color: Theme.of(context).colorScheme.error, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: 24, - height: 24, - ), - onPressed: isOperating.value - ? null - : () => _removeAppCatalogRelay( - context, - ref, - existingRelayList, - relayUrl, - isOperating, - ), - tooltip: 'Remove app catalog relay', - ), - ), - ], - ), - ); - }, - ), - ), - ), - ), - - const SizedBox(height: 8), - - // Add relay input - Row( - children: [ - Expanded( - child: TextField( - controller: relayUrlController, - enabled: !isOperating.value, - decoration: InputDecoration( - hintText: 'relay.example.com', - hintStyle: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - ), - prefixIcon: Padding( - padding: const EdgeInsets.only(left: 8), - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(6), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedProtocol.value, - items: const [ - DropdownMenuItem( - value: 'wss://', - child: Text('wss://'), - ), - DropdownMenuItem( - value: 'ws://', - child: Text('ws://'), - ), - ], - onChanged: isOperating.value - ? null - : (value) { - if (value != null) { - selectedProtocol.value = value; - } - }, - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - fontWeight: FontWeight.w500, - ), - isDense: true, - iconSize: 14, - ), - ), - ), - ), - ), - prefixIconConstraints: const BoxConstraints( - minWidth: 70, - minHeight: 0, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - isDense: true, - ), - style: const TextStyle( - fontSize: 13, - fontFamily: 'monospace', - ), - onSubmitted: isOperating.value - ? null - : (value) { - if (value.trim().isNotEmpty) { - // Remove protocol if user typed it manually - final cleaned = value.trim().replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); - _addAppCatalogRelay( - context, - ref, - existingRelayList, - '${selectedProtocol.value}$cleaned', - relayUrlController, - isOperating, - ); - } - }, - ), - ), - const SizedBox(width: 8), - AsyncButtonBuilder( - onPressed: () async { - final url = relayUrlController.text.trim(); - if (url.isNotEmpty) { - // Remove protocol if user typed it manually - final cleaned = url.replaceFirst( - RegExp(r'^(ws|wss)://'), - '', - ); - await _addAppCatalogRelay( - context, - ref, - existingRelayList, - '${selectedProtocol.value}$cleaned', - relayUrlController, - isOperating, - ); - } - }, - builder: (context, child, callback, buttonState) { - final isLoading = buttonState.maybeWhen( - loading: () => true, - orElse: () => false, - ); - final isDisabled = - isLoading || isOperating.value || !hasText.value; - return IconButton( - onPressed: isDisabled ? null : callback, - icon: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.add), - tooltip: 'Add relay', - style: IconButton.styleFrom( - backgroundColor: isDisabled - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3) - : Theme.of(context).colorScheme.primaryContainer, - foregroundColor: isDisabled - ? Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4) - : Theme.of(context).colorScheme.onPrimaryContainer, - ), - ); - }, - child: const SizedBox.shrink(), - ), - ], - ), - ], - ), - ), - ); - } - - Future _addAppCatalogRelay( - BuildContext context, - WidgetRef ref, - AppCatalogRelayList? existingRelayList, - String relayUrl, - TextEditingController controller, - ValueNotifier isOperating, - ) async { - // Validate and normalize URL - final normalizedUrl = validateAndNormalizeRelayUrl(relayUrl); - if (normalizedUrl == null) { - context.showError( - 'Invalid app catalog relay URL', - description: 'Must be a valid WebSocket URL (ws:// or wss://)', - ); - return; - } - - // Check for duplicates (case-insensitive on host) - final existingRelays = existingRelayList?.readRelays ?? {}; - if (isDuplicateRelay(normalizedUrl, existingRelays)) { - context.showError( - 'App catalog relay already exists', - description: 'This relay is already in your list.', - ); - return; - } - - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - context.showError('Sign in required'); - return; - } - - try { - isOperating.value = true; - - // Create partial model with existing relays - final partialRelayList = PartialAppCatalogRelayList(); - for (final relay in existingRelays) { - partialRelayList.addReadRelay(relay); - } - // Add the new relay (normalized) - partialRelayList.addReadRelay(normalizedUrl); - - // Sign the event - final signedRelayList = await partialRelayList.signWith(signer); - - // Save locally and publish to bootstrap relays - await ref.storage.save({signedRelayList}); - await ref.storage.publish({ - signedRelayList, - }, source: const RemoteSource(relays: 'bootstrap')); - - // Increment refresh token to force UI refresh - ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; - - // Invalidate latest releases to force new query with updated relays - // This will make the new relay appear in Debug Info immediately - ref.invalidate(latestReleasesProvider); - ref.read(updatesRefreshProvider.notifier).state++; - ref.read(latestReleasesRefreshProvider.notifier).state++; - - controller.clear(); - - if (context.mounted) { - context.showInfo('App catalog relay added'); - } - } catch (e) { - if (context.mounted) { - context.showError('Failed to add app catalog relay', description: '$e'); - } - } finally { - isOperating.value = false; - } - } - - Future _removeAppCatalogRelay( - BuildContext context, - WidgetRef ref, - AppCatalogRelayList? existingRelayList, - String relayUrl, - ValueNotifier isOperating, - ) async { - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) { - context.showError('Sign in required'); - return; - } - - // Capture a stable context reference before async operations - // The itemBuilder context may become invalid after widget rebuilds - final navigatorContext = Navigator.of(context).context; - - try { - isOperating.value = true; - - final existingRelays = existingRelayList?.readRelays ?? {}; - - // Create partial model with all relays except the one being removed - final partialRelayList = PartialAppCatalogRelayList(); - for (final relay in existingRelays) { - if (relay != relayUrl) { - partialRelayList.addReadRelay(relay); - } - } - - // Sign the event - final signedRelayList = await partialRelayList.signWith(signer); - - // Save locally and publish to bootstrap relays - await ref.storage.save({signedRelayList}); - await ref.storage.publish({ - signedRelayList, - }, source: const RemoteSource(relays: 'bootstrap')); - - // Show success toast using stable context - if (navigatorContext.mounted) { - navigatorContext.showInfo('App catalog relay removed'); - } - - // Increment refresh token to force UI refresh - ref.read(_appCatalogRelaysRefreshProvider.notifier).state++; - - // Invalidate latest releases to force new query with updated relays - // This will make the removed relay disappear from Debug Info immediately - ref.invalidate(latestReleasesProvider); - ref.read(updatesRefreshProvider.notifier).state++; - ref.read(latestReleasesRefreshProvider.notifier).state++; - - // Cancel subscriptions tied to the removed relay only - await _cancelAppCatalogSubscriptions(ref, {relayUrl}); - } catch (e) { - if (navigatorContext.mounted) { - navigatorContext.showError( - 'Failed to remove app catalog relay', - description: '$e', - ); - } - } finally { - isOperating.value = false; - } - } - - Future _cancelAppCatalogSubscriptions( - WidgetRef ref, - Set relaysToCancel, - ) async { - final poolState = ref.read(poolStateProvider); - final subscriptions = poolState?.subscriptions ?? const {}; - final targets = relaysToCancel.map((r) => r.toLowerCase()).toSet(); - - for (final sub in subscriptions.values) { - final hasTargetRelay = sub.relays.keys.any( - (relay) => targets.contains(relay.toLowerCase()), - ); - if (hasTargetRelay) { - await ref.storage.cancel(sub.request); - } - } - } -} - class _DataManagementSection extends ConsumerWidget { const _DataManagementSection(); @@ -1979,8 +1413,8 @@ class _DataManagementSection extends ConsumerWidget { ], ), content: const Text( - 'Clears all cached data (except NWC secret) and restarts the app. ' - 'You will be signed out.', + 'Clears all cached data and restarts the app. ' + 'Your sign-in and wallet connection will be preserved.', ), actions: [ TextButton( diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index 7af1ae1..25e5496 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -1,3 +1,4 @@ +import 'package:amber_signer/amber_signer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -34,8 +35,27 @@ class SecureStorageService { final value = await _storage.read(key: _nwcKey); return value?.isNotEmpty == true; } +} - /// Check if the install permission dialog has been shown +/// Persists the AmberSigner pubkey in flutter_secure_storage. +/// This survives app data clears (database deletion) and is encrypted. +class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence { + static const _key = 'amber_pubkey'; + + @override + Future persistPubkey(String pubkey) async { + await SecureStorageService._storage.write(key: _key, value: pubkey); + } + + @override + Future loadPubkey() async { + return SecureStorageService._storage.read(key: _key); + } + + @override + Future clearPubkey() async { + await SecureStorageService._storage.delete(key: _key); + } } final secureStorageServiceProvider = Provider( diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 259a48a..1a5942b 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -7,9 +7,6 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/version_utils.dart'; -/// Refresh token for updates subscriptions. -final updatesRefreshProvider = StateProvider((ref) => 0); - /// Categorized apps state class CategorizedApps { const CategorizedApps({ @@ -86,7 +83,6 @@ class CategorizedAppsNotifier extends Notifier { } final platform = ref.read(packageManagerProvider.notifier).platform; - final refreshToken = ref.watch(updatesRefreshProvider); // Query apps with relationships loaded via `and:` final appsState = ref.watch( @@ -108,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates-$refreshToken', + subscriptionPrefix: 'updates', ), ); @@ -117,15 +113,13 @@ class CategorizedAppsNotifier extends Notifier { isLoading: !_hasLoadedOnce, ), StorageError() => CategorizedApps.empty.copyWith(isLoading: false), - StorageData(:final models) => - _categorize(models, installedPackages, refreshToken), + StorageData(:final models) => _categorize(models, installedPackages), }; } CategorizedApps _categorize( List apps, List installedPackages, - int refreshToken, ) { _hasLoadedOnce = true; @@ -183,7 +177,7 @@ class CategorizedAppsNotifier extends Notifier { upToDateApps.sort(byName); // Fetch author profiles in background (fire and forget) - _fetchAuthors(installedApps, refreshToken); + _fetchAuthors(installedApps); return CategorizedApps( automaticUpdates: automaticUpdates, @@ -202,7 +196,7 @@ class CategorizedAppsNotifier extends Notifier { return canUpgrade(installed.version, latest.version); } - void _fetchAuthors(Iterable apps, int refreshToken) { + void _fetchAuthors(Iterable apps) { final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); if (authorPubkeys.isEmpty) return; unawaited( @@ -213,7 +207,7 @@ class CategorizedAppsNotifier extends Notifier { cachedFor: Duration(hours: 2), stream: false, ), - subscriptionPrefix: 'updates-profiles-$refreshToken', + subscriptionPrefix: 'updates-profiles', ), ); } diff --git a/lib/utils/relay_utils.dart b/lib/utils/relay_utils.dart deleted file mode 100644 index 9ddd879..0000000 --- a/lib/utils/relay_utils.dart +++ /dev/null @@ -1,129 +0,0 @@ -/// Utility functions for WebSocket relay URL handling. - -/// Validates and normalizes a relay URL: checks format, removes trailing -/// slashes, lowercases the host. Returns null if invalid. -/// -/// Examples: -/// - ` WSS://Relay.Example.Com/ ` → `wss://relay.example.com` -/// - `wss://relay.example.com/` → `wss://relay.example.com` -/// - `wss://relay.example.com:443/path/` → `wss://relay.example.com:443/path` -/// - `invalid-url` → `null` -String? validateAndNormalizeRelayUrl(String input) { - final trimmed = input.trim(); - if (trimmed.isEmpty) return null; - - final uri = Uri.tryParse(trimmed); - if (uri == null) return null; - - // Must have ws or wss scheme - if (uri.scheme != 'ws' && uri.scheme != 'wss') return null; - - // Must have a host - if (uri.host.isEmpty) return null; - if (!_isValidRelayHost(uri.host)) return null; - - // Rebuild normalized URL: scheme://host[:port][/path] - final buffer = StringBuffer() - ..write(uri.scheme) - ..write('://') - ..write(uri.host.toLowerCase()); - - if (uri.hasPort && uri.port != 0) { - buffer - ..write(':') - ..write(uri.port); - } - - // Add path if present, but remove trailing slashes - var path = uri.path; - while (path.endsWith('/')) { - path = path.substring(0, path.length - 1); - } - if (path.isNotEmpty) { - buffer.write(path); - } - - return buffer.toString(); -} - -bool _isValidRelayHost(String host) { - if (host == 'localhost') return true; - - // IPv4 validation (e.g., 192.168.0.1) - final ipv4Match = RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host); - if (ipv4Match) { - final parts = host.split('.'); - return parts.every((p) { - final value = int.tryParse(p); - return value != null && value >= 0 && value <= 255; - }); - } - - // Domain validation - if (host.length > 253) return false; - final labels = host.split('.'); - if (labels.length < 2) return false; - for (final label in labels) { - if (label.isEmpty || label.length > 63) return false; - if (!RegExp(r'^[a-zA-Z0-9-]+$').hasMatch(label)) return false; - if (label.startsWith('-') || label.endsWith('-')) return false; - } - return true; -} - -/// Normalizes a relay URL for comparison, including default ports. -/// Returns a canonical form: scheme://host:port/path -/// where port is always explicit (defaults: 443 for wss, 80 for ws). -String? _normalizeForComparison(String url) { - final normalized = validateAndNormalizeRelayUrl(url); - if (normalized == null) return null; - - final uri = Uri.parse(normalized); - final defaultPort = uri.scheme == 'wss' ? 443 : 80; - final port = uri.hasPort && uri.port != 0 ? uri.port : defaultPort; - - final buffer = StringBuffer() - ..write(uri.scheme) - ..write('://') - ..write(uri.host.toLowerCase()) - ..write(':') - ..write(port); - - var path = uri.path; - while (path.endsWith('/')) { - path = path.substring(0, path.length - 1); - } - if (path.isNotEmpty) { - buffer.write(path); - } - - return buffer.toString(); -} - -/// Checks if a relay URL already exists in the set. -/// Normalizes both URLs before comparison, considering default ports. -/// Treats wss://relay.com and wss://relay.com:443 as duplicates. -bool isDuplicateRelay(String normalizedUrl, Set existingRelays) { - final newUrlCanonical = _normalizeForComparison(normalizedUrl); - if (newUrlCanonical == null) return false; - - for (final existing in existingRelays) { - final existingCanonical = _normalizeForComparison(existing); - if (existingCanonical == null) continue; - - if (existingCanonical == newUrlCanonical) { - return true; - } - } - return false; -} - -/// Formats a relay URL for display by removing the scheme and trailing slash. -/// -/// Example: `wss://relay.example.com/` → `relay.example.com` -String formatRelayUrlShort(String relayUrl) { - return relayUrl - .replaceAll('wss://', '') - .replaceAll('ws://', '') - .replaceAll(RegExp(r'/$'), ''); -} diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index 14016cd..d9dd42c 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -7,9 +7,6 @@ import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; -/// Refresh token for latest releases subscriptions. -final latestReleasesRefreshProvider = StateProvider((ref) => 0); - class LatestReleasesContainer extends HookConsumerWidget { const LatestReleasesContainer({ super.key, @@ -303,7 +300,6 @@ class LatestReleasesNotifier extends StateNotifier { void _startQuery() { _sub?.close(); - final refreshToken = ref.read(latestReleasesRefreshProvider); _sub = ref.listen>( query( @@ -325,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // NOTE: It must stream=true source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'latest-$refreshToken', + subscriptionPrefix: 'latest', ), (previous, next) async { // Always mirror storage state and ensure olderApps don't duplicate the live head diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart new file mode 100644 index 0000000..80efb7d --- /dev/null +++ b/lib/widgets/relay_management_card.dart @@ -0,0 +1,570 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/services/app_restart_service.dart'; +import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Provider for the user's app catalog relay list. +final _appCatalogRelayListProvider = + Provider?>((ref) { + final pubkey = ref.watch(Signer.activePubkeyProvider); + + if (pubkey == null) { + return null; + } + + return ref.watch( + query( + authors: {pubkey}, + limit: 1, + source: const LocalAndRemoteSource( + relays: 'bootstrap', + stream: false, + ), + subscriptionPrefix: 'user-appcatalog-relays', + ), + ); +}); + +/// App Catalog Relay Management Card - manages app catalog relays (kind 10067) +/// These are relays for discovering apps, NOT social relays like Damus/Primal. +/// +/// Changes are accumulated in memory and applied with "Apply Changes" which +/// publishes the relay list and restarts the app with a fresh database. +class RelayManagementCard extends HookConsumerWidget { + const RelayManagementCard({super.key}); + + static const _kDefaultRelay = 'wss://relay.zapstore.dev'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + + // Only show when user is signed in + if (signedInPubkey == null) { + return const SizedBox.shrink(); + } + + final relayUrlController = useTextEditingController(); + final hasText = useState(false); + final isApplying = useState(false); + + // Watch pool state for relay connection status + final poolState = ref.watch(poolStateProvider); + + // Watch remote relay list + final relayListState = ref.watch(_appCatalogRelayListProvider); + final existingRelayList = relayListState?.models.firstOrNull; + final savedRelays = (existingRelayList?.readRelays ?? {}).toList() + ..sort(); + + // If no relays saved, show default relay + final effectiveSavedRelays = + savedRelays.isEmpty ? [_kDefaultRelay] : savedRelays; + + // Local pending state - initialized from effective saved relays + final pendingRelays = useState?>(null); + + // Initialize pending from effective saved when first loaded + useEffect(() { + if (pendingRelays.value == null && relayListState is StorageData) { + pendingRelays.value = effectiveSavedRelays; + } + return null; + }, [relayListState]); + + // Current display relays (pending if modified, else effective saved) + final displayRelays = pendingRelays.value ?? effectiveSavedRelays; + final hasChanges = + pendingRelays.value != null && + !const ListEquality().equals( + pendingRelays.value, + effectiveSavedRelays, + ); + + // Listen to text changes to enable/disable add button + useEffect(() { + void listener() { + hasText.value = relayUrlController.text.trim().isNotEmpty; + } + + relayUrlController.addListener(listener); + return () => relayUrlController.removeListener(listener); + }, [relayUrlController]); + + void addRelay(String relayUrl) { + // Validate and normalize URL + final normalizedUrl = _validateAndNormalizeRelayUrl(relayUrl); + if (normalizedUrl == null) { + context.showError( + 'Invalid relay URL', + description: 'Must be a valid WebSocket URL (ws:// or wss://)', + ); + return; + } + + // Check for duplicates + final currentRelays = pendingRelays.value ?? savedRelays; + if (_isDuplicateRelay(normalizedUrl, currentRelays.toSet())) { + context.showError( + 'Relay already exists', + description: 'This relay is already in your list.', + ); + return; + } + + pendingRelays.value = [...currentRelays, normalizedUrl]..sort(); + relayUrlController.clear(); + } + + void removeRelay(String relayUrl) { + final currentRelays = pendingRelays.value ?? savedRelays; + pendingRelays.value = currentRelays.where((r) => r != relayUrl).toList(); + } + + Future applyChanges() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + const Text('Apply Relay Changes'), + ], + ), + content: const Text( + 'Changing app catalog relays will clear cached app data and restart the app. ' + 'Your sign-in and wallet connection will be preserved.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Apply Changes'), + ), + ], + ), + ); + + if (confirmed != true || !context.mounted) return; + + isApplying.value = true; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + if (context.mounted) { + context.showError('Sign in required'); + } + return; + } + + // Create and sign the relay list (use displayRelays which reflects user's intent) + final partialRelayList = PartialAppCatalogRelayList(); + for (final relay in displayRelays) { + partialRelayList.addReadRelay(relay); + } + final signedRelayList = await partialRelayList.signWith(signer); + + // Publish to bootstrap relays + await ref.storage.publish({ + signedRelayList, + }, source: const RemoteSource(relays: 'bootstrap')); + + // Show loading dialog + if (context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => const AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: AppColors.darkSkeletonHighlight, + backgroundColor: AppColors.darkSkeletonBase, + ), + SizedBox(height: 16), + Text('Restarting...'), + ], + ), + ), + ); + } + + // Restart app with database clear + await restartApp(); + } catch (e) { + isApplying.value = false; + if (context.mounted) { + Navigator.of(context, rootNavigator: true).pop(); + context.showError( + 'Failed to apply relay changes', + description: '$e', + actions: [('Retry', () => applyChanges())], + ); + } + } + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Icon(Icons.dns, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + 'App Catalog Relays', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + + // Info text + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'These relays are used to discover apps, not for social content.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Current relays list + if (displayRelays.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Using default catalog relays. Add a relay to override.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.55), + ), + ), + ) + else + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.separated( + shrinkWrap: true, + physics: displayRelays.length > 4 + ? const BouncingScrollPhysics() + : const NeverScrollableScrollPhysics(), + itemCount: displayRelays.length, + separatorBuilder: (_, __) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final relayUrl = displayRelays[index]; + + // Get relay connection status from pool state + final statusColor = + _getRelayStatusColor(poolState, relayUrl); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Status dot + Container( + width: 10, + height: 10, + margin: const EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + boxShadow: statusColor != Colors.grey + ? [ + BoxShadow( + color: + statusColor.withValues(alpha: 0.4), + blurRadius: 4, + spreadRadius: 1, + ), + ] + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + relayUrl, + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + width: 24, + height: 24, + child: IconButton( + icon: Icon( + Icons.close, + size: 18, + color: Theme.of(context).colorScheme.error, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor( + width: 24, + height: 24, + ), + onPressed: isApplying.value + ? null + : () => removeRelay(relayUrl), + tooltip: 'Remove relay', + ), + ), + ], + ), + ); + }, + ), + ), + + const SizedBox(height: 8), + + // Add relay input + Row( + children: [ + Expanded( + child: TextField( + controller: relayUrlController, + enabled: !isApplying.value, + decoration: InputDecoration( + hintText: 'wss://relay.example.com', + hintStyle: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + ), + style: const TextStyle( + fontSize: 13, + fontFamily: 'monospace', + ), + onSubmitted: isApplying.value + ? null + : (value) { + if (value.trim().isNotEmpty) { + addRelay(value.trim()); + } + }, + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: isApplying.value || !hasText.value + ? null + : () { + final url = relayUrlController.text.trim(); + if (url.isNotEmpty) { + addRelay(url); + } + }, + icon: const Icon(Icons.add), + tooltip: 'Add relay', + style: IconButton.styleFrom( + backgroundColor: isApplying.value || !hasText.value + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3) + : Theme.of(context).colorScheme.primaryContainer, + foregroundColor: isApplying.value || !hasText.value + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4) + : Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ], + ), + + // Apply Changes button (only shown when there are changes) + if (hasChanges) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: isApplying.value ? null : applyChanges, + icon: isApplying.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.refresh), + label: Text( + isApplying.value ? 'Applying...' : 'Apply Changes', + ), + ), + ), + ], + ], + ), + ), + ); + } + + /// Validates and normalizes a relay URL. + /// Uses wss:// unless ws:// is explicitly specified. + /// If no protocol is provided, wss:// is assumed. + static String? _validateAndNormalizeRelayUrl(String input) { + var url = input.trim(); + + // Add wss:// if no protocol specified + if (!url.startsWith('ws://') && !url.startsWith('wss://')) { + url = 'wss://$url'; + } + + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) return null; + + // Use wss:// unless ws:// is explicitly specified + final scheme = uri.scheme == 'ws' ? 'ws' : 'wss'; + final path = uri.path.endsWith('/') + ? uri.path.substring(0, uri.path.length - 1) + : uri.path; + + return Uri( + scheme: scheme, + host: uri.host.toLowerCase(), + port: uri.hasPort ? uri.port : null, + path: path.isEmpty ? null : path, + ).toString(); + } + + /// Checks if a relay URL already exists in the set (case-insensitive). + static bool _isDuplicateRelay( + String normalizedUrl, + Set existingRelays, + ) { + final newLower = normalizedUrl.toLowerCase(); + for (final existing in existingRelays) { + if (existing.toLowerCase() == newLower) { + return true; + } + } + return false; + } + + /// Gets the best connection status color for a relay from pool state. + /// Returns green for streaming/loading, yellow for connecting/waiting, + /// red for failed, grey for disconnected/not found. + static Color _getRelayStatusColor(PoolState? poolState, String relayUrl) { + if (poolState == null) return Colors.grey; + + final subscriptions = poolState.subscriptions; + final relayLower = relayUrl.toLowerCase(); + + RelaySubPhase? bestPhase; + + for (final sub in subscriptions.values) { + for (final entry in sub.relays.entries) { + if (entry.key.toLowerCase() == relayLower) { + final phase = entry.value.phase; + // Prioritize: streaming > loading > connecting > waiting > others + if (bestPhase == null || + _phasePriority(phase) > _phasePriority(bestPhase)) { + bestPhase = phase; + } + } + } + } + + if (bestPhase == null) return Colors.grey; + + return switch (bestPhase) { + RelaySubPhase.streaming => Colors.green, + RelaySubPhase.loading => Colors.green, + RelaySubPhase.connecting => Colors.amber, + RelaySubPhase.waiting => Colors.amber, + RelaySubPhase.failed => Colors.red, + RelaySubPhase.disconnected => Colors.grey, + RelaySubPhase.closed => Colors.grey, + }; + } + + static int _phasePriority(RelaySubPhase phase) { + return switch (phase) { + RelaySubPhase.streaming => 6, + RelaySubPhase.loading => 5, + RelaySubPhase.connecting => 4, + RelaySubPhase.waiting => 3, + RelaySubPhase.failed => 2, + RelaySubPhase.disconnected => 1, + RelaySubPhase.closed => 0, + }; + } +} diff --git a/pubspec.lock b/pubspec.lock index 96efb8a..41adf14 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,8 +5,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4d065ba" - resolved-ref: "4d065badcef18ea397dff40c46fcce114ecca12a" + ref: "6cbedbe" + resolved-ref: "6cbedbe6ae58a952c564979002258840921b041b" url: "https://github.com/purplebase/amber_signer" source: git version: "0.2.0" diff --git a/pubspec.yaml b/pubspec.yaml index 453f415..cda80bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,7 +71,7 @@ dependency_overrides: # path: ../amber_signer git: url: https://github.com/purplebase/amber_signer - ref: 4d065ba + ref: 6cbedbe dev_dependencies: flutter_test: From e4cc62ec0b685efb23581cff66c70c7f5f22a0c7 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 17:10:38 -0300 Subject: [PATCH 13/20] Prefix subscriptions with app- (we need it for the relay) --- lib/screens/search_screen.dart | 2 +- lib/screens/updates_screen.dart | 6 +- lib/screens/user_screen.dart | 2 +- lib/services/updates_service.dart | 2 +- lib/widgets/app_detail_widgets.dart | 2 +- lib/widgets/app_stack_container.dart | 68 +++++++++++++--------- lib/widgets/latest_releases_container.dart | 6 +- spec/guidelines/ARCHITECTURE.md | 4 ++ 8 files changed, 55 insertions(+), 37 deletions(-) diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3592314..de21796 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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', ), ); diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index f82f2a6..c565103 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -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 @@ -189,7 +189,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { tags: {'#d': installingAppIds}, and: (app) => {app.latestRelease.query()}, source: const LocalAndRemoteSource(relays: 'AppCatalog'), - subscriptionPrefix: 'installing-apps', + subscriptionPrefix: 'app-installing-apps', ), ); diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index ff1d154..abab0a8 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -49,7 +49,7 @@ class UserScreen extends HookConsumerWidget { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'user-apps', + subscriptionPrefix: 'app-user-apps', ), ); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 1a5942b..1d6ac14 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), - subscriptionPrefix: 'updates', + subscriptionPrefix: 'app-updates', ), ); diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 6025c8b..22855f1 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -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', ), ); diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 5b642dd..c5acd40 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) { List _getPreviewIdentifiers(AppStack stack) { final rawTags = _getRawAppTagValues(stack).toList() ..shuffle(Random(stack.id.hashCode)); - return rawTags - .take(3) - .map(_extractIdentifier) - .whereType() - .toList(); + return rawTags.take(3).map(_extractIdentifier).whereType().toList(); } /// Sort app stacks: franzap/following first, then by recency @@ -67,10 +63,12 @@ List _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, diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index d9dd42c..968824f 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier { }, // 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 diff --git a/spec/guidelines/ARCHITECTURE.md b/spec/guidelines/ARCHITECTURE.md index 5af9916..031bebe 100644 --- a/spec/guidelines/ARCHITECTURE.md +++ b/spec/guidelines/ARCHITECTURE.md @@ -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. From 3ccb1b70ac0635820bac0902baf49d9f65c7efe2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Fri, 23 Jan 2026 17:22:44 -0300 Subject: [PATCH 14/20] Add copy npub icon to user_screen --- lib/screens/user_screen.dart | 67 ++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index abab0a8..ebd15d8 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -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'; @@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget { final abbreviatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}'; - return GestureDetector( - onTap: () => launchUrl( - Uri.parse('https://npub.world/$npub'), - mode: LaunchMode.externalApplication, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.key, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 4), - Text( - abbreviatedNpub, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.open_in_new, + 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, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.key, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + abbreviatedNpub, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.open_in_new, + size: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ], ); } } From cdc3bfdc3586c50b5bf0de715c0a464ef02b428c Mon Sep 17 00:00:00 2001 From: Henrique Velloso Date: Fri, 23 Jan 2026 19:30:54 -0300 Subject: [PATCH 15/20] Fix saved apps section stuck in loading or disappearing - #283 - Show "No saved apps yet" empty state instead of infinite loading - Simplify - Centralize empty state logic in _SavedAppsList - Fix SizedBox.shrink() causing section to disappear when apps not in local cache --- lib/screens/profile_screen.dart | 219 ++++++++++++++------------------ 1 file changed, 95 insertions(+), 124 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 5fcdef5..0a04536 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -1480,80 +1480,111 @@ 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() - .toSet(); - - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text('Saved Apps', style: context.textTheme.headlineSmall), - ); - }, + // 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; - final identifiers = addressableIds - .map((id) { - final parts = id.split(':'); - return parts.length >= 3 ? parts[2] : null; - }) - .whereType() - .toSet(); + // Show loading only on first load (when no value exists yet). + if (addressableIds == null) { + return _savedAppsLoadingCard(context); + } - if (identifiers.isEmpty) { - return const SizedBox.shrink(); - } + final identifiers = _toIdentifiers(addressableIds); + return _SavedAppsList(identifiers: identifiers); + } - return _SavedAppsList( - identifiers: identifiers, - hasLoadedOnce: hasLoadedOnce, - ); - }, + Set _toIdentifiers(Set addressableIds) { + return addressableIds + .map((id) => id.split(':')) + .where((parts) => parts.length >= 3) + .map((parts) => parts[2]) + .toSet(); + } + + Widget _savedAppsLoadingCard(BuildContext context) => Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: CircularProgressIndicator( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); +} + +class _SavedAppsList extends ConsumerWidget { + const _SavedAppsList({required this.identifiers}); + + final Set identifiers; + + @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( + tags: {'#d': identifiers}, + and: (app) => {app.latestRelease.query()}, + source: const LocalSource(), + subscriptionPrefix: 'profile-saved-apps', + ), + ); + + final isLoading = savedAppsState is StorageLoading; + + final savedApps = savedAppsState.models.toList() + ..sort( + (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( + (b.name ?? b.identifier).toLowerCase(), + ), + ); + + // 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); + } + + 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 _savedAppsLoadingCard(BuildContext context) { + Widget _loadingCard(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(16), @@ -1565,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget { ), ); } -} -class _SavedAppsList extends ConsumerWidget { - const _SavedAppsList({ - required this.identifiers, - required this.hasLoadedOnce, - }); - - final Set identifiers; - final ValueNotifier hasLoadedOnce; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final savedAppsState = ref.watch( - query( - tags: {'#d': identifiers}, - and: (app) => {app.latestRelease.query()}, - source: const LocalSource(), - subscriptionPrefix: 'profile-saved-apps', - ), - ); - - final savedApps = savedAppsState.models.toList() - ..sort( - (a, b) => (a.name ?? a.identifier).toLowerCase().compareTo( - (b.name ?? b.identifier).toLowerCase(), - ), - ); - - // 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 loading state only if we haven't loaded any apps yet - if (savedApps.isEmpty && !hasLoadedOnce.value) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } - - // 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), ), ), ); From f17100a6a6bf410c00ea22993576ff4baa25fa3d Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sat, 24 Jan 2026 13:07:29 -0300 Subject: [PATCH 16/20] Ignore retarded analyzer warning --- analysis_options.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5da7251..2fb84c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -10,6 +10,7 @@ analyzer: errors: dangling_library_doc_comments: ignore + deprecated_member_use: ignore include: package:flutter_lints/flutter.yaml linter: From abdee83b24a95d2b0cc6013191fe430296147bc2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Mon, 26 Jan 2026 23:41:53 -0300 Subject: [PATCH 17/20] Better labels in install button --- lib/widgets/install_button.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 511d191..bf17b02 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -106,7 +106,7 @@ class InstallButton extends ConsumerWidget { return switch (operation) { DownloadQueued() => _buildSimpleButton( context, - 'Queued', + 'Queued for download', null, fontSize: fontSize, showSpinner: true, @@ -148,7 +148,7 @@ class InstallButton extends ConsumerWidget { ReadyToInstall() => _buildSimpleButton( context, - 'Waiting...', + 'Queued for ${isInstalled ? 'update' : 'install'}', null, // Not tappable - system advances automatically fontSize: fontSize, showSpinner: true, From bc91f7a834a58422a139b439da525027cb6e2ffa Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 27 Jan 2026 10:30:06 -0300 Subject: [PATCH 18/20] Fix potential concurrency issues --- .../zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 5ef66c8..986cb66 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -25,6 +25,7 @@ import io.flutter.plugin.common.MethodChannel.Result import java.io.File import java.io.FileInputStream import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -99,7 +100,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private val watchdogDeadlineMs = mutableMapOf() /** Track verification threads so we can "ping" and avoid false timeouts */ - private val verificationThreads = mutableMapOf() + private val verificationThreads = ConcurrentHashMap() /** Track sessions that are pending user action (so we can detect when user accepts) */ private val sessionsPendingUserAction = mutableSetOf() @@ -116,7 +117,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private var instance: AndroidPackageManagerPlugin? = null /** Map sessionId to packageName for reverse lookup in broadcasts */ - private val sessionToPackage = mutableMapOf() + private val sessionToPackage = ConcurrentHashMap() /** Pending user action intents - stored for re-launch when app returns to foreground */ private val pendingUserActionIntents = mutableMapOf() From 62307b57dc2ced219bb85a40b32839f583af50cc Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 27 Jan 2026 14:59:00 -0300 Subject: [PATCH 19/20] Prevent from removing last relay, adjust bootstrap relay group --- lib/main.dart | 3 ++- lib/widgets/relay_management_card.dart | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 112284d..cf0cf6c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -219,7 +219,8 @@ final appInitializationProvider = FutureProvider((ref) async { ), defaultRelays: { 'default': {'wss://relay.zapstore.dev'}, - 'bootstrap': {'wss://purplepag.es', 'wss://relay.zapstore.dev'}, + 'bootstrap': {'wss://relay.zapstore.dev'}, + // TODO: add 'wss://purplepag.es' back when it's fixed 'AppCatalog': {'wss://relay.zapstore.dev'}, 'social': { 'wss://relay.damus.io', diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart index 80efb7d..c0f8126 100644 --- a/lib/widgets/relay_management_card.dart +++ b/lib/widgets/relay_management_card.dart @@ -124,7 +124,17 @@ class RelayManagementCard extends HookConsumerWidget { void removeRelay(String relayUrl) { final currentRelays = pendingRelays.value ?? savedRelays; - pendingRelays.value = currentRelays.where((r) => r != relayUrl).toList(); + final newRelays = currentRelays.where((r) => r != relayUrl).toList(); + // App catalog relays can never be empty - show error if trying to remove last + if (newRelays.isEmpty) { + context.showError( + 'Cannot remove last relay', + description: 'App catalog relays cannot be empty. ' + 'Add another relay before removing this one.', + ); + return; + } + pendingRelays.value = newRelays; } Future applyChanges() async { From 5c193ed098be43d4f4e7dca483ba9d8a7df63372 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 27 Jan 2026 15:02:03 -0300 Subject: [PATCH 20/20] Fix ports issue --- lib/widgets/relay_management_card.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart index c0f8126..d2c1d0d 100644 --- a/lib/widgets/relay_management_card.dart +++ b/lib/widgets/relay_management_card.dart @@ -168,10 +168,12 @@ class RelayManagementCard extends HookConsumerWidget { if (confirmed != true || !context.mounted) return; isApplying.value = true; + var loadingDialogShown = false; try { final signer = ref.read(Signer.activeSignerProvider); if (signer == null) { + isApplying.value = false; if (context.mounted) { context.showError('Sign in required'); } @@ -192,6 +194,7 @@ class RelayManagementCard extends HookConsumerWidget { // Show loading dialog if (context.mounted) { + loadingDialogShown = true; showDialog( context: context, barrierDismissible: false, @@ -216,7 +219,9 @@ class RelayManagementCard extends HookConsumerWidget { } catch (e) { isApplying.value = false; if (context.mounted) { - Navigator.of(context, rootNavigator: true).pop(); + if (loadingDialogShown) { + Navigator.of(context, rootNavigator: true).pop(); + } context.showError( 'Failed to apply relay changes', description: '$e', @@ -490,6 +495,7 @@ class RelayManagementCard extends HookConsumerWidget { /// Validates and normalizes a relay URL. /// Uses wss:// unless ws:// is explicitly specified. /// If no protocol is provided, wss:// is assumed. + /// Normalizes default ports (443 for wss, 80 for ws) by omitting them. static String? _validateAndNormalizeRelayUrl(String input) { var url = input.trim(); @@ -507,10 +513,16 @@ class RelayManagementCard extends HookConsumerWidget { ? uri.path.substring(0, uri.path.length - 1) : uri.path; + // Normalize default ports: omit 443 for wss and 80 for ws + final isDefaultPort = (scheme == 'wss' && uri.port == 443) || + (scheme == 'ws' && uri.port == 80); + final normalizedPort = + uri.hasPort && !isDefaultPort ? uri.port : null; + return Uri( scheme: scheme, host: uri.host.toLowerCase(), - port: uri.hasPort ? uri.port : null, + port: normalizedPort, path: path.isEmpty ? null : path, ).toString(); }