mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Merge branch 'master' into fix/275-setup-nwc-goes-nowhere
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
analyzer:
|
||||
errors:
|
||||
dangling_library_doc_comments: ignore
|
||||
deprecated_member_use: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
+276
-126
@@ -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))
|
||||
// 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)
|
||||
|
||||
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)
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'updates',
|
||||
subscriptionPrefix: 'app-updates',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user