Merge branch 'master' into fix/275-setup-nwc-goes-nowhere

This commit is contained in:
Henrique Velloso
2026-01-27 16:26:01 -03:00
committed by GitHub
25 changed files with 1430 additions and 552 deletions
+1
View File
@@ -10,6 +10,7 @@
analyzer:
errors:
dangling_library_doc_comments: ignore
deprecated_member_use: ignore
include: package:flutter_lints/flutter.yaml
linter:
@@ -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<String, Long>()
/** Track verification threads so we can "ping" and avoid false timeouts */
private val verificationThreads = mutableMapOf<String, Thread>()
private val verificationThreads = ConcurrentHashMap<String, Thread>()
/** Track sessions that are pending user action (so we can detect when user accepts) */
private val sessionsPendingUserAction = mutableSetOf<Int>()
/** Track sessions that have emitted INSTALLING status (to avoid duplicates) */
private val sessionsInstalling = mutableSetOf<Int>()
/** 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<Int, String>()
private val sessionToPackage = ConcurrentHashMap<Int, String>()
/** Pending user action intents - stored for re-launch when app returns to foreground */
private val pendingUserActionIntents = mutableMapOf<String, Intent>()
@@ -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 ->
+8 -3
View File
@@ -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<void>((ref) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
// Clear storage if requested from a clear all operation
@@ -218,7 +219,8 @@ final appInitializationProvider = FutureProvider<void>((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<void>((ref) async {
});
// AmberSigner provider for Nostr authentication
final amberSignerProvider = Provider<AmberSigner>(AmberSigner.new);
// Uses SecureStoragePubkeyPersistence to survive database clears
final amberSignerProvider = Provider<AmberSigner>(
(ref) => AmberSigner(ref, persistence: SecureStoragePubkeyPersistence()),
);
Future<void> _attemptAutoSignIn(Ref ref) async {
try {
+22
View File
@@ -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<GoRouter>((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;
});
+108 -131
View File
@@ -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<String>()
.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<String>()
.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<String> _toIdentifiers(Set<String> 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<String> 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<App>(
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<String> identifiers;
final ValueNotifier<bool> hasLoadedOnce;
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedAppsState = ref.watch(
query<App>(
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),
),
),
);
+1 -1
View File
@@ -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',
),
);
+25 -29
View File
@@ -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<App>(
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<App> installingApps;
if (installingAppIds.isEmpty) {
installingApps = const [];
} else {
final installingAppsState = ref.watch(
query<App>(
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,
+43 -26
View File
@@ -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,
),
],
),
),
],
);
}
}
+2 -2
View File
@@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart';
/// Checks if storage should be cleared on this launch, and clears the marker.
/// Call this BEFORE initializing storage.
Future<void> maybeClearStorage(String dbPath) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName');
if (await marker.exists()) {
final dbFile = File(dbPath);
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
/// Sets a marker file and triggers a native app restart.
Future<void> restartApp() async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName');
await marker.create();
await _channel.invokeMethod('restart');
+71 -43
View File
@@ -1,7 +1,9 @@
import 'dart:io' show Directory, Platform;
import 'dart:io' show Directory, File, Platform;
import 'dart:ui' as ui;
import 'package:background_downloader/background_downloader.dart';
import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:path/path.dart' as path;
@@ -9,7 +11,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.dart';
import 'package:zapstore/services/package_manager/android_package_manager.dart';
import 'package:zapstore/services/package_manager/background_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -42,6 +44,8 @@ const kAppCatalogRelaysKey = 'appCatalogRelays';
/// This MUST be a top-level function (not a class method).
@pragma('vm:entry-point')
void callbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
ui.DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((task, inputData) async {
switch (task) {
case kBackgroundUpdateTaskName:
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
try {
// Delete the file if it exists
final filePath = await task.filePath();
final file = Directory(filePath).parent.listSync().firstWhere(
(f) => f.path.endsWith(task.filename),
orElse: () => Directory(''),
);
if (file.path.isNotEmpty) {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
);
if (await downloadDir.exists()) {
final files = downloadDir.listSync();
final entities = downloadDir.listSync();
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
for (final file in files) {
if (file is! Directory) {
final stat = await file.stat();
for (final entity in entities) {
if (entity is File) {
final stat = await entity.stat();
if (stat.modified.isBefore(cutoff)) {
await file.delete();
await entity.delete();
}
}
}
@@ -145,7 +146,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
packageManagerProvider.overrideWith(
(ref) => Platform.isAndroid
? AndroidPackageManager(ref)
? BackgroundPackageManager(ref)
: DummyPackageManager(ref),
),
],
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
try {
// Initialize Purplebase with same DB path as main app
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
await container.read(
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
// Load releases for all apps in a single query
// Load releases and their metadata/assets (required for hasUpdate)
if (apps.isNotEmpty) {
final addressableIds = apps
.map((app) => app.event.addressableId)
.toSet();
await storage.query(
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(),
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
);
final releaseFilters = apps
.map((app) => app.latestRelease.req?.filters.firstOrNull)
.nonNulls
.toList();
if (releaseFilters.isNotEmpty) {
final List<Release> releases = await storage.query(
Request<Release>(releaseFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
final metadataFilters = releases
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
.nonNulls
.toList();
if (metadataFilters.isNotEmpty) {
await storage.query(
Request<FileMetadata>(metadataFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
final assetFilters = releases
.map((r) => r.latestAsset.req?.filters.firstOrNull)
.nonNulls
.toList();
if (assetFilters.isNotEmpty) {
await storage.query(
Request<SoftwareAsset>(assetFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
}
}
// Re-query apps from local to ensure relationships are loaded
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
// Build notification content
final title = updateCount == 1
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
// Navigation is handled by the app's normal launch flow
},
);
// Create notification channel on Android
final androidPlugin = flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
description: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
),
);
}
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
}
/// Cancel background update checks
@@ -405,6 +413,26 @@ class BackgroundUpdateService {
}
}
Future<void> _ensureUpdateNotificationChannel(
FlutterLocalNotificationsPlugin plugin,
) async {
final androidPlugin = plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
description: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
),
);
}
}
/// Provider for the background update service
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
BackgroundUpdateService.new,
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Install status values from native side.
@@ -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
@@ -0,0 +1,53 @@
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Background-safe PackageManager that avoids EventChannel usage.
final class BackgroundPackageManager extends PackageManager {
BackgroundPackageManager(super.ref);
// Zapstore currently targets arm64 APKs for background checks.
@override
String get platform => 'android-arm64-v8a';
@override
String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => false;
@override
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
}) {
throw UnsupportedError('Install not supported in background');
}
@override
Future<void> uninstall(String appId) {
throw UnsupportedError('Uninstall not supported in background');
}
@override
Future<void> launchApp(String appId) {
throw UnsupportedError('Launch not supported in background');
}
@override
Future<void> requestPermission() {
throw UnsupportedError('Permission not supported in background');
}
@override
Future<bool> hasPermission() async => false;
@override
Future<void> syncInstalledPackages() async {
final installed = await InstalledPackagesSnapshot.load();
state = state.copyWith(installed: installed);
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
class InstalledPackagesSnapshot {
static const _fileName = 'installed_packages_snapshot.json';
static Future<File> _file() async {
final dir = await getApplicationSupportDirectory();
return File(path.join(dir.path, _fileName));
}
static Future<void> save(Map<String, PackageInfo> installed) async {
try {
final file = await _file();
final tmp = File('${file.path}.tmp');
final list = installed.values
.map(
(p) => <String, dynamic>{
'appId': p.appId,
'name': p.name,
'version': p.version,
'versionCode': p.versionCode,
'signatureHash': p.signatureHash,
'canInstallSilently': p.canInstallSilently,
},
)
.toList(growable: false);
final payload = jsonEncode({
'v': 1,
'savedAt': DateTime.now().millisecondsSinceEpoch,
'installed': list,
});
await tmp.writeAsString(payload, flush: true);
if (await file.exists()) {
await file.delete();
}
await tmp.rename(file.path);
} catch (e) {
// Best-effort snapshot only.
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Save failed: $e');
}
}
}
static Future<Map<String, PackageInfo>> load() async {
try {
final file = await _file();
if (!await file.exists()) return {};
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
final version = decoded['v'];
if (version != null && version is! int && kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version');
}
final installed = decoded['installed'];
if (installed is! List) return {};
final result = <String, PackageInfo>{};
for (final item in installed) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(item);
final appId = map['appId'] as String?;
if (appId == null || appId.isEmpty) continue;
result[appId] = PackageInfo(
appId: appId,
name: map['name'] as String?,
version: (map['version'] as String?) ?? '0.0.0',
versionCode: map['versionCode'] as int?,
signatureHash: (map['signatureHash'] as String?) ?? '',
installTime: null,
canInstallSilently: (map['canInstallSilently'] as bool?) ?? false,
);
}
return result;
} catch (e) {
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Load failed: $e');
}
return {};
}
}
}
@@ -731,25 +731,46 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
void _processQueuedDownload() {
var activeDownloads = countOperations<Downloading>();
final activeDownloads = countOperations<Downloading>();
if (activeDownloads >= maxConcurrentDownloads) return;
// Start downloads until we fill all available slots
// Collect queued items first to avoid iterating while modifying state
final queuedItems = <String, DownloadQueued>{};
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',
),
);
}
}
}
+21 -1
View File
@@ -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<void> persistPubkey(String pubkey) async {
await SecureStorageService._storage.write(key: _key, value: pubkey);
}
@override
Future<String?> loadPubkey() async {
return SecureStorageService._storage.read(key: _key);
}
@override
Future<void> clearPubkey() async {
await SecureStorageService._storage.delete(key: _key);
}
}
final secureStorageServiceProvider = Provider<SecureStorageService>(
+1 -1
View File
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'updates',
subscriptionPrefix: 'app-updates',
),
);
+1 -1
View File
@@ -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',
),
);
+41 -27
View File
@@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) {
List<String> _getPreviewIdentifiers(AppStack stack) {
final rawTags = _getRawAppTagValues(stack).toList()
..shuffle(Random(stack.id.hashCode));
return rawTags
.take(3)
.map(_extractIdentifier)
.whereType<String>()
.toList();
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
}
/// Sort app stacks: franzap/following first, then by recency
@@ -67,10 +63,12 @@ List<AppStack> _sortStacks(
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...followed, ...others];
} else {
final franzapStacks =
stacks.where((s) => s.pubkey == kFranzapPubkey).toList();
final otherStacks =
stacks.where((s) => s.pubkey != kFranzapPubkey).toList();
final franzapStacks = stacks
.where((s) => s.pubkey == kFranzapPubkey)
.toList();
final otherStacks = stacks
.where((s) => s.pubkey != kFranzapPubkey)
.toList();
franzapStacks.shuffle(random);
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
scrollController.position.maxScrollExtent - 200) {
// Load more if we haven't shown all stacks yet
if (visibleCount.value < sortedStacks.length) {
visibleCount.value = (visibleCount.value + _kBatchSize)
.clamp(0, sortedStacks.length);
visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
0,
sortedStacks.length,
);
}
}
}
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
if (col * 2 < displayedStacks.length)
_StackCard(
stack: displayedStacks[col * 2],
author: authorsMap[displayedStacks[col * 2].event.pubkey],
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey),
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [],
author:
authorsMap[displayedStacks[col * 2].event.pubkey],
isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
appsMap: appsMap,
),
// Bottom item
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
const SizedBox(height: 10),
_StackCard(
stack: displayedStacks[col * 2 + 1],
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey],
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey),
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [],
author:
authorsMap[displayedStacks[col * 2 + 1]
.event
.pubkey],
isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2 + 1].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
[],
appsMap: appsMap,
),
],
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10),
),
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
),
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.broken_image_outlined,
size: 16,
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
),
)
: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.apps_outlined,
size: 16,
+5 -10
View File
@@ -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<void> _triggerInstall(WidgetRef ref) async {
final pm = ref.read(packageManagerProvider.notifier);
await pm.triggerInstall(app.identifier);
}
Future<void> _retryInstall(WidgetRef ref) async {
final pm = ref.read(packageManagerProvider.notifier);
await pm.retryInstall(app.identifier);
+3 -3
View File
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
},
// NOTE: It must stream=true
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'latest',
subscriptionPrefix: 'app-latest',
),
(previous, next) async {
// Always mirror storage state and ensure olderApps don't duplicate the live head
@@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget {
final poolState = ref.watch(poolStateProvider);
final subscriptions = poolState?.subscriptions ?? {};
// Filter to only "latest" subscription
// Filter to only "app-latest" subscription
final latestSubs = subscriptions.entries
.where((e) => e.key.startsWith('latest'))
.where((e) => e.key.startsWith('app-latest'))
.map((e) => e.value);
// Check relay status for latest subscription
+592
View File
@@ -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<StorageState<AppCatalogRelayList>?>((ref) {
final pubkey = ref.watch(Signer.activePubkeyProvider);
if (pubkey == null) {
return null;
}
return ref.watch(
query<AppCatalogRelayList>(
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 ?? <String>{}).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<List<String>?>(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<String>().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<void> applyChanges() async {
final confirmed = await showDialog<bool>(
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<String> 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,
};
}
}
+14 -14
View File
@@ -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:
+1 -1
View File
@@ -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:
-122
View File
@@ -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
+4
View File
@@ -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.