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: 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..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,9 @@ 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 private const val TAG = "AndroidPackageManager" @@ -37,6 +40,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. @@ -45,6 +51,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" @@ -93,7 +100,16 @@ 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() + + /** 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 @@ -101,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() @@ -135,6 +151,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) @@ -145,6 +164,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) @@ -177,12 +198,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) } } @@ -218,6 +254,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") } @@ -226,6 +265,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, mainHandler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } + unregisterSessionCallback() methodChannel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) eventSink = null @@ -257,6 +297,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 // ═══════════════════════════════════════════════════════════════════════════════ @@ -389,7 +480,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) } @@ -486,83 +577,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 +674,82 @@ 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: 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) - // 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 +759,40 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, session.commit(pendingIntent.intentSender) session.close() - - emitInstallStatus(trackingPackageName, InstallStatus.STARTED) - result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName)) + session = null // Prevent double-close in finally } 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 +833,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 -> diff --git a/lib/main.dart b/lib/main.dart index 4b31c27..cf0cf6c 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'; @@ -201,7 +202,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 @@ -218,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', @@ -245,7 +247,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/router.dart b/lib/router.dart index dcf9be8..8099d5a 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -9,6 +9,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']; @@ -128,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/profile_screen.dart b/lib/screens/profile_screen.dart index c390959..f9bf4b7 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'; @@ -22,9 +23,7 @@ import 'package:zapstore/theme.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/widgets/common/note_parser.dart'; import 'package:zapstore/widgets/nwc_widgets.dart'; - -// Note: Relay debugging features have been removed as they depend on internal APIs -// that are no longer public in purplebase 0.3.3+ +import 'package:zapstore/widgets/relay_management_card.dart'; /// Profile screen for authentication and app settings class ProfileScreen extends ConsumerWidget { @@ -63,6 +62,11 @@ class ProfileScreen extends ConsumerWidget { const SizedBox(height: 16), + // App Catalog Relay Management Section + const RelayManagementCard(), + + const SizedBox(height: 16), + // Data Management Section const _DataManagementSection(), @@ -1379,12 +1383,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,12 +1409,12 @@ 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( - '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( @@ -1477,80 +1483,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), @@ -1562,75 +1599,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), ), ), ); 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..584c3cd 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 @@ -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( @@ -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/screens/user_screen.dart b/lib/screens/user_screen.dart index ff1d154..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'; @@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget { ), }, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'user-apps', + subscriptionPrefix: 'app-user-apps', ), ); @@ -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, + ), + ], + ), + ), + ], ); } } 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'); 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..e8c345e 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. @@ -14,6 +15,7 @@ enum InstallStatus { started, verifying, pendingUserAction, + installing, // User accepted, system is now installing alreadyInProgress, success, failed, @@ -26,6 +28,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 +173,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. @@ -587,6 +601,7 @@ final class AndroidPackageManager extends PackageManager { } state = state.copyWith(installed: {...packages, ...preserved}); + await InstalledPackagesSnapshot.save(state.installed); // Clear operations for apps where the installed version matches the target version // This catches installs that succeeded but we missed the event 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 {}; + } + } +} 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/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 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/install_button.dart b/lib/widgets/install_button.dart index b3782b6..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, @@ -146,12 +146,12 @@ class InstallButton extends ConsumerWidget { isWarning: true, ), - ReadyToInstall() => _buildAsyncButton( + ReadyToInstall() => _buildSimpleButton( context, - ref, - text: isInstalled ? 'Update' : 'Install', - onPressed: () => _triggerInstall(ref), + 'Queued for ${isInstalled ? 'update' : 'install'}', + 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); 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/lib/widgets/relay_management_card.dart b/lib/widgets/relay_management_card.dart new file mode 100644 index 0000000..d2c1d0d --- /dev/null +++ b/lib/widgets/relay_management_card.dart @@ -0,0 +1,592 @@ +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; + 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 { + 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; + var loadingDialogShown = false; + + try { + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + isApplying.value = false; + 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) { + loadingDialogShown = true; + 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) { + if (loadingDialogShown) { + 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. + /// Normalizes default ports (443 for wss, 80 for ws) by omitting them. + 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; + + // 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: normalizedPort, + 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 607adb5..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" @@ -581,26 +581,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -653,10 +653,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1141,10 +1141,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.7" timezone: dependency: transitive description: @@ -1261,10 +1261,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: 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: 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 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.