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:
|
analyzer:
|
||||||
errors:
|
errors:
|
||||||
dangling_library_doc_comments: ignore
|
dangling_library_doc_comments: ignore
|
||||||
|
deprecated_member_use: ignore
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|||||||
+276
-126
@@ -25,6 +25,9 @@ import io.flutter.plugin.common.MethodChannel.Result
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private const val TAG = "AndroidPackageManager"
|
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 INSTALL_WATCHDOG_MS = 10_000L
|
||||||
private const val MAX_INSTALL_WATCHDOG_MS = 120_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.
|
* Install status values emitted via EventChannel.
|
||||||
* These form a simple state machine with no hanging states.
|
* These form a simple state machine with no hanging states.
|
||||||
@@ -45,6 +51,7 @@ object InstallStatus {
|
|||||||
const val STARTED = "started"
|
const val STARTED = "started"
|
||||||
const val VERIFYING = "verifying"
|
const val VERIFYING = "verifying"
|
||||||
const val PENDING_USER_ACTION = "pendingUserAction"
|
const val PENDING_USER_ACTION = "pendingUserAction"
|
||||||
|
const val INSTALLING = "installing" // User accepted, system is now installing
|
||||||
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
|
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
|
||||||
const val SUCCESS = "success"
|
const val SUCCESS = "success"
|
||||||
const val FAILED = "failed"
|
const val FAILED = "failed"
|
||||||
@@ -93,7 +100,16 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
private val watchdogDeadlineMs = mutableMapOf<String, Long>()
|
private val watchdogDeadlineMs = mutableMapOf<String, Long>()
|
||||||
|
|
||||||
/** Track verification threads so we can "ping" and avoid false timeouts */
|
/** 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
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
@@ -101,7 +117,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
private var instance: AndroidPackageManagerPlugin? = null
|
private var instance: AndroidPackageManagerPlugin? = null
|
||||||
|
|
||||||
/** Map sessionId to packageName for reverse lookup in broadcasts */
|
/** 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 */
|
/** Pending user action intents - stored for re-launch when app returns to foreground */
|
||||||
private val pendingUserActionIntents = mutableMapOf<String, Intent>()
|
private val pendingUserActionIntents = mutableMapOf<String, Intent>()
|
||||||
@@ -135,6 +151,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) {
|
if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) {
|
||||||
pendingUserActionIntents[pkg] = confirmIntent
|
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
|
// Auto-launch dialog if app is in foreground
|
||||||
if (isAppInForeground) {
|
if (isAppInForeground) {
|
||||||
launchConfirmDialog(pkg, confirmIntent)
|
launchConfirmDialog(pkg, confirmIntent)
|
||||||
@@ -145,6 +164,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) {
|
if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) {
|
||||||
sessionToPackage.remove(sessionId)
|
sessionToPackage.remove(sessionId)
|
||||||
pendingUserActionIntents.remove(pkg)
|
pendingUserActionIntents.remove(pkg)
|
||||||
|
instance?.sessionsPendingUserAction?.remove(sessionId)
|
||||||
|
instance?.sessionsInstalling?.remove(sessionId)
|
||||||
|
|
||||||
if (status != InstallStatus.SUCCESS) {
|
if (status != InstallStatus.SUCCESS) {
|
||||||
abandonSession(sessionId)
|
abandonSession(sessionId)
|
||||||
@@ -177,12 +198,27 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
|
|
||||||
private fun launchConfirmDialog(packageName: String, intent: Intent) {
|
private fun launchConfirmDialog(packageName: String, intent: Intent) {
|
||||||
val ctx = appContext ?: return
|
val ctx = appContext ?: return
|
||||||
try {
|
val inst = instance
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
|
||||||
ctx.startActivity(intent)
|
// Post with delay to ensure the system is ready to show the dialog.
|
||||||
Log.d(TAG, "Launched confirmation dialog for $packageName")
|
// Without this, the dialog may not appear when the broadcast arrives
|
||||||
} catch (e: Exception) {
|
// immediately after session.commit() while the app is still processing.
|
||||||
Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e)
|
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)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register session callback to detect when user accepts install dialog
|
||||||
|
registerSessionCallback()
|
||||||
|
|
||||||
cleanupStaleSessions()
|
cleanupStaleSessions()
|
||||||
Log.d(TAG, "AndroidPackageManagerPlugin initialized")
|
Log.d(TAG, "AndroidPackageManagerPlugin initialized")
|
||||||
}
|
}
|
||||||
@@ -226,6 +265,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||||
}
|
}
|
||||||
|
unregisterSessionCallback()
|
||||||
methodChannel.setMethodCallHandler(null)
|
methodChannel.setMethodCallHandler(null)
|
||||||
eventChannel.setStreamHandler(null)
|
eventChannel.setStreamHandler(null)
|
||||||
eventSink = null
|
eventSink = null
|
||||||
@@ -257,6 +297,57 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
Log.d(TAG, "App backgrounded")
|
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
|
// EVENT CHANNEL STREAM HANDLER
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -389,7 +480,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
// Start watchdog for verify stage
|
// Start watchdog for verify stage
|
||||||
scheduleWatchdog(appId, VERIFY_WATCHDOG_MS)
|
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
|
// Start/refresh watchdog for install stage
|
||||||
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
|
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
|
||||||
}
|
}
|
||||||
@@ -486,83 +577,95 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
abandonExistingSession(packageName)
|
abandonExistingSession(packageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expectedHash != null && expectedSize != null) {
|
// Check for install restrictions on main thread before starting background work
|
||||||
// Emit verifying status before starting hash check
|
val userManager = context.getSystemService(UserManager::class.java)
|
||||||
emitInstallStatus(packageName, InstallStatus.VERIFYING)
|
val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) ||
|
||||||
result.success(mapOf("started" to true, "verifying" to true))
|
userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) ||
|
||||||
|
userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY)
|
||||||
|
|
||||||
val t = Thread {
|
if (installBlocked) {
|
||||||
val verification = verifyApk(file, expectedHash, expectedSize)
|
emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED)
|
||||||
mainHandler.post {
|
result.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED))
|
||||||
if (verification.isSuccess) {
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
val userManager = context.getSystemService(UserManager::class.java)
|
// Step 2: Check if this is an update
|
||||||
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.")
|
|
||||||
}
|
|
||||||
|
|
||||||
val isUpdate = try {
|
val isUpdate = try {
|
||||||
context.packageManager.getPackageInfo(pkgInfo.packageName, 0)
|
context.packageManager.getPackageInfo(packageName, 0)
|
||||||
true
|
true
|
||||||
} catch (_: PackageManager.NameNotFoundException) { false }
|
} catch (_: PackageManager.NameNotFoundException) { false }
|
||||||
|
|
||||||
abandonExistingSession(trackingPackageName)
|
// Step 3: Abandon any existing session and create new one
|
||||||
|
abandonExistingSession(packageName)
|
||||||
|
|
||||||
val sessionParams = PackageInstaller.SessionParams(
|
val sessionParams = PackageInstaller.SessionParams(
|
||||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||||
).apply {
|
).apply {
|
||||||
// Set app package name for proper installer tracking
|
// Use the passed packageName directly - no getPackageArchiveInfo() needed
|
||||||
setAppPackageName(pkgInfo.packageName)
|
// Android validates the APK during commit anyway
|
||||||
|
setAppPackageName(packageName)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
// Explicitly set ourselves as the installer for update ownership
|
|
||||||
setInstallerPackageName(context.packageName)
|
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) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
|
setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE)
|
||||||
}
|
}
|
||||||
@@ -571,24 +674,82 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val sessionId = packageInstaller.createSession(sessionParams)
|
sessionId = packageInstaller.createSession(sessionParams)
|
||||||
val session = packageInstaller.openSession(sessionId)
|
session = packageInstaller.openSession(sessionId)
|
||||||
// Use tracking name so events match Dart's operation key
|
sessionToPackage[sessionId] = packageName
|
||||||
sessionToPackage[sessionId] = trackingPackageName
|
|
||||||
|
// Step 4: Single-pass verify + copy with progress reporting
|
||||||
|
val digest = if (expectedHash != null) MessageDigest.getInstance("SHA-256") else null
|
||||||
|
|
||||||
FileInputStream(apkFile).use { fis ->
|
FileInputStream(apkFile).use { fis ->
|
||||||
val fileSize = Os.fstat(fis.fd).st_size
|
val fileSize = Os.fstat(fis.fd).st_size
|
||||||
val sessionStream = session.openWrite(apkFile.name, 0, fileSize)
|
|
||||||
fis.copyTo(sessionStream)
|
session.openWrite(apkFile.name, 0, fileSize).use { sessionStream ->
|
||||||
fis.close()
|
val buffer = ByteArray(COPY_BUFFER_SIZE)
|
||||||
session.fsync(sessionStream)
|
var bytesCopied = 0L
|
||||||
sessionStream.close()
|
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 {
|
val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply {
|
||||||
putExtra("sessionId", sessionId)
|
putExtra("sessionId", sessionId)
|
||||||
// Use tracking name so events match Dart's operation key
|
putExtra("packageName", packageName)
|
||||||
putExtra("packageName", trackingPackageName)
|
|
||||||
putExtra("isUpdate", isUpdate)
|
putExtra("isUpdate", isUpdate)
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
@@ -598,16 +759,40 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
|||||||
|
|
||||||
session.commit(pendingIntent.intentSender)
|
session.commit(pendingIntent.intentSender)
|
||||||
session.close()
|
session.close()
|
||||||
|
session = null // Prevent double-close in finally
|
||||||
emitInstallStatus(trackingPackageName, InstallStatus.STARTED)
|
|
||||||
result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName))
|
|
||||||
|
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
emitInstallStatus(packageName, InstallStatus.FAILED, "Permission denied: ${e.message}", ErrorCode.PERMISSION_DENIED)
|
// Clean up session on error
|
||||||
result?.success(mapOf("started" to false, "error" to "Permission denied", "errorCode" to ErrorCode.PERMISSION_DENIED))
|
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) {
|
} catch (e: Exception) {
|
||||||
emitInstallStatus(packageName, InstallStatus.FAILED, e.message ?: "Installation failed", ErrorCode.INSTALL_FAILED)
|
// Clean up session on error
|
||||||
result?.success(mapOf("started" to false, "error" to e.message, "errorCode" to ErrorCode.INSTALL_FAILED))
|
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,
|
* Quick APK format check - validates ZIP magic bytes.
|
||||||
val errorTitle: String = "",
|
* This is a lightweight check that doesn't load the APK into memory.
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isValidApkFormat(file: File): Boolean {
|
private fun isValidApkFormat(file: File): Boolean {
|
||||||
return try {
|
return try {
|
||||||
FileInputStream(file).use { fis ->
|
FileInputStream(file).use { fis ->
|
||||||
|
|||||||
+8
-3
@@ -12,6 +12,7 @@ import 'package:purplebase/purplebase.dart';
|
|||||||
import 'package:amber_signer/amber_signer.dart';
|
import 'package:amber_signer/amber_signer.dart';
|
||||||
import 'package:zapstore/services/app_restart_service.dart';
|
import 'package:zapstore/services/app_restart_service.dart';
|
||||||
import 'package:zapstore/services/background_update_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/router.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
@@ -201,7 +202,7 @@ class ZapstoreHome extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final dbPath = path.join(dir.path, 'zapstore.db');
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||||
|
|
||||||
// Clear storage if requested from a clear all operation
|
// Clear storage if requested from a clear all operation
|
||||||
@@ -218,7 +219,8 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
|||||||
),
|
),
|
||||||
defaultRelays: {
|
defaultRelays: {
|
||||||
'default': {'wss://relay.zapstore.dev'},
|
'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'},
|
'AppCatalog': {'wss://relay.zapstore.dev'},
|
||||||
'social': {
|
'social': {
|
||||||
'wss://relay.damus.io',
|
'wss://relay.damus.io',
|
||||||
@@ -245,7 +247,10 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AmberSigner provider for Nostr authentication
|
// 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 {
|
Future<void> _attemptAutoSignIn(Ref ref) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:zapstore/screens/user_screen.dart';
|
|||||||
import 'package:zapstore/screens/search_screen.dart';
|
import 'package:zapstore/screens/search_screen.dart';
|
||||||
import 'package:zapstore/screens/updates_screen.dart';
|
import 'package:zapstore/screens/updates_screen.dart';
|
||||||
import 'package:zapstore/screens/profile_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)
|
/// Root paths for each navigation branch (used for back navigation handling)
|
||||||
const kBranchRoots = ['/search', '/updates', '/profile'];
|
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 'dart:convert';
|
||||||
|
|
||||||
import 'package:async_button_builder/async_button_builder.dart';
|
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:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.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/services/notification_service.dart';
|
||||||
import 'package:zapstore/widgets/common/note_parser.dart';
|
import 'package:zapstore/widgets/common/note_parser.dart';
|
||||||
import 'package:zapstore/widgets/nwc_widgets.dart';
|
import 'package:zapstore/widgets/nwc_widgets.dart';
|
||||||
|
import 'package:zapstore/widgets/relay_management_card.dart';
|
||||||
// Note: Relay debugging features have been removed as they depend on internal APIs
|
|
||||||
// that are no longer public in purplebase 0.3.3+
|
|
||||||
|
|
||||||
/// Profile screen for authentication and app settings
|
/// Profile screen for authentication and app settings
|
||||||
class ProfileScreen extends ConsumerWidget {
|
class ProfileScreen extends ConsumerWidget {
|
||||||
@@ -63,6 +62,11 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// App Catalog Relay Management Section
|
||||||
|
const RelayManagementCard(),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Data Management Section
|
// Data Management Section
|
||||||
const _DataManagementSection(),
|
const _DataManagementSection(),
|
||||||
|
|
||||||
@@ -1379,12 +1383,14 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: AutoSizeText(
|
||||||
'Clear local storage',
|
'Clear local storage',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
minFontSize: 12,
|
||||||
),
|
),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
onTap: () => _showClearAllDataDialog(context, ref),
|
onTap: () => _showClearAllDataDialog(context, ref),
|
||||||
@@ -1403,12 +1409,12 @@ class _DataManagementSection extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
Icon(Icons.warning, color: Theme.of(context).colorScheme.error),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('Clear local storage'),
|
Flexible(child: const Text('Clear local storage')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Clears all cached data (except NWC secret) and restarts the app. '
|
'Clears all cached data and restarts the app. '
|
||||||
'You will be signed out.',
|
'Your sign-in and wallet connection will be preserved.',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1477,80 +1483,111 @@ class _SavedAppsHeading extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
// Always show heading when signed in
|
||||||
|
return Padding(
|
||||||
return savedAppsAsync.when(
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
loading: () => const SizedBox.shrink(),
|
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
|
||||||
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),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SavedAppsSection extends HookConsumerWidget {
|
class _SavedAppsSection extends ConsumerWidget {
|
||||||
const _SavedAppsSection();
|
const _SavedAppsSection();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
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);
|
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||||
|
|
||||||
return savedAppsAsync.when(
|
// Keep previous value during refresh, if available.
|
||||||
loading: () => _savedAppsLoadingCard(context),
|
final addressableIds = savedAppsAsync.valueOrNull;
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
|
||||||
data: (addressableIds) {
|
|
||||||
if (addressableIds.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final identifiers = addressableIds
|
// Show loading only on first load (when no value exists yet).
|
||||||
.map((id) {
|
if (addressableIds == null) {
|
||||||
final parts = id.split(':');
|
return _savedAppsLoadingCard(context);
|
||||||
return parts.length >= 3 ? parts[2] : null;
|
}
|
||||||
})
|
|
||||||
.whereType<String>()
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
if (identifiers.isEmpty) {
|
final identifiers = _toIdentifiers(addressableIds);
|
||||||
return const SizedBox.shrink();
|
return _SavedAppsList(identifiers: identifiers);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _SavedAppsList(
|
Set<String> _toIdentifiers(Set<String> addressableIds) {
|
||||||
identifiers: identifiers,
|
return addressableIds
|
||||||
hasLoadedOnce: hasLoadedOnce,
|
.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(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -1562,75 +1599,15 @@ class _SavedAppsSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _SavedAppsList extends ConsumerWidget {
|
Widget _emptyState(BuildContext context) {
|
||||||
const _SavedAppsList({
|
final theme = Theme.of(context);
|
||||||
required this.identifiers,
|
return Padding(
|
||||||
required this.hasLoadedOnce,
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
});
|
child: Text(
|
||||||
|
'No saved apps yet',
|
||||||
final Set<String> identifiers;
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
final ValueNotifier<bool> hasLoadedOnce;
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||||
|
|
||||||
@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(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget {
|
|||||||
// Force the search to hit the default relay group (relay.zapstore.dev)
|
// Force the search to hit the default relay group (relay.zapstore.dev)
|
||||||
// so a connection appears in Debug Info when searching.
|
// so a connection appears in Debug Info when searching.
|
||||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'search-results',
|
subscriptionPrefix: 'app-search-results',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
|
|||||||
final poolState = ref.watch(poolStateProvider);
|
final poolState = ref.watch(poolStateProvider);
|
||||||
final subscriptions = poolState?.subscriptions ?? {};
|
final subscriptions = poolState?.subscriptions ?? {};
|
||||||
|
|
||||||
// Filter to only "updates" subscription
|
// Filter to only "app-updates" subscription
|
||||||
final updatesSubs = subscriptions.entries
|
final updatesSubs = subscriptions.entries
|
||||||
.where((e) => e.key.startsWith('updates'))
|
.where((e) => e.key.startsWith('app-updates'))
|
||||||
.map((e) => e.value);
|
.map((e) => e.value);
|
||||||
|
|
||||||
// Check relay status for updates subscription only
|
// Check relay status for updates subscription only
|
||||||
@@ -61,7 +61,7 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
|
|||||||
|
|
||||||
final statusColor = hasActiveConnection ? Colors.green : Colors.grey;
|
final statusColor = hasActiveConnection ? Colors.green : Colors.grey;
|
||||||
final statusText = hasActiveConnection
|
final statusText = hasActiveConnection
|
||||||
? 'Connected · Checking for updates'
|
? 'Connected · Streaming updates'
|
||||||
: 'Offline';
|
: 'Offline';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -147,16 +147,7 @@ class _UpdatesContent extends HookConsumerWidget {
|
|||||||
.map((entry) => entry.key)
|
.map((entry) => entry.key)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
if (activeAppIds.isEmpty) {
|
// Always use the same widget type to preserve scroll position
|
||||||
return _UpdatesListBody(
|
|
||||||
automaticUpdates: automaticUpdates,
|
|
||||||
manualUpdates: manualUpdates,
|
|
||||||
installingApps: const [],
|
|
||||||
upToDateApps: upToDateApps,
|
|
||||||
uncatalogedApps: uncatalogedApps,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _UpdatesListBodyWithInstallingAppIds(
|
return _UpdatesListBodyWithInstallingAppIds(
|
||||||
installingAppIds: activeAppIds,
|
installingAppIds: activeAppIds,
|
||||||
automaticUpdates: automaticUpdates,
|
automaticUpdates: automaticUpdates,
|
||||||
@@ -184,27 +175,32 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 = {
|
final updateAppIds = {
|
||||||
...automaticUpdates.map((a) => a.identifier),
|
...automaticUpdates.map((a) => a.identifier),
|
||||||
...manualUpdates.map((a) => a.identifier),
|
...manualUpdates.map((a) => a.identifier),
|
||||||
};
|
};
|
||||||
|
|
||||||
final installingApps = installingAppsState.models
|
// Only query for installing apps if there are active operations
|
||||||
.where(
|
final List<App> installingApps;
|
||||||
(app) =>
|
if (installingAppIds.isEmpty) {
|
||||||
installingAppIds.contains(app.identifier) &&
|
installingApps = const [];
|
||||||
!updateAppIds.contains(app.identifier),
|
} else {
|
||||||
)
|
final installingAppsState = ref.watch(
|
||||||
.toList();
|
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(
|
return _UpdatesListBody(
|
||||||
automaticUpdates: automaticUpdates,
|
automaticUpdates: automaticUpdates,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||||
subscriptionPrefix: 'user-apps',
|
subscriptionPrefix: 'app-user-apps',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget {
|
|||||||
final abbreviatedNpub =
|
final abbreviatedNpub =
|
||||||
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||||
|
|
||||||
return GestureDetector(
|
return Row(
|
||||||
onTap: () => launchUrl(
|
mainAxisSize: MainAxisSize.min,
|
||||||
Uri.parse('https://npub.world/$npub'),
|
children: [
|
||||||
mode: LaunchMode.externalApplication,
|
GestureDetector(
|
||||||
),
|
onTap: () {
|
||||||
child: Row(
|
Clipboard.setData(ClipboardData(text: npub));
|
||||||
mainAxisSize: MainAxisSize.min,
|
},
|
||||||
children: [
|
child: Icon(
|
||||||
Icon(
|
Icons.copy,
|
||||||
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,
|
size: 12,
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
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.
|
/// Checks if storage should be cleared on this launch, and clears the marker.
|
||||||
/// Call this BEFORE initializing storage.
|
/// Call this BEFORE initializing storage.
|
||||||
Future<void> maybeClearStorage(String dbPath) async {
|
Future<void> maybeClearStorage(String dbPath) async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final marker = File('${dir.path}/$_markerFileName');
|
final marker = File('${dir.path}/$_markerFileName');
|
||||||
if (await marker.exists()) {
|
if (await marker.exists()) {
|
||||||
final dbFile = File(dbPath);
|
final dbFile = File(dbPath);
|
||||||
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
|
|||||||
|
|
||||||
/// Sets a marker file and triggers a native app restart.
|
/// Sets a marker file and triggers a native app restart.
|
||||||
Future<void> restartApp() async {
|
Future<void> restartApp() async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final marker = File('${dir.path}/$_markerFileName');
|
final marker = File('${dir.path}/$_markerFileName');
|
||||||
await marker.create();
|
await marker.create();
|
||||||
await _channel.invokeMethod('restart');
|
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_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:path/path.dart' as path;
|
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:permission_handler/permission_handler.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:workmanager/workmanager.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/dummy_package_manager.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.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).
|
/// This MUST be a top-level function (not a class method).
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
void callbackDispatcher() {
|
void callbackDispatcher() {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
ui.DartPluginRegistrant.ensureInitialized();
|
||||||
Workmanager().executeTask((task, inputData) async {
|
Workmanager().executeTask((task, inputData) async {
|
||||||
switch (task) {
|
switch (task) {
|
||||||
case kBackgroundUpdateTaskName:
|
case kBackgroundUpdateTaskName:
|
||||||
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
|
|||||||
try {
|
try {
|
||||||
// Delete the file if it exists
|
// Delete the file if it exists
|
||||||
final filePath = await task.filePath();
|
final filePath = await task.filePath();
|
||||||
final file = Directory(filePath).parent.listSync().firstWhere(
|
final file = File(filePath);
|
||||||
(f) => f.path.endsWith(task.filename),
|
if (await file.exists()) {
|
||||||
orElse: () => Directory(''),
|
|
||||||
);
|
|
||||||
if (file.path.isNotEmpty) {
|
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (await downloadDir.exists()) {
|
if (await downloadDir.exists()) {
|
||||||
final files = downloadDir.listSync();
|
final entities = downloadDir.listSync();
|
||||||
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
||||||
|
|
||||||
for (final file in files) {
|
for (final entity in entities) {
|
||||||
if (file is! Directory) {
|
if (entity is File) {
|
||||||
final stat = await file.stat();
|
final stat = await entity.stat();
|
||||||
if (stat.modified.isBefore(cutoff)) {
|
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),
|
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
||||||
packageManagerProvider.overrideWith(
|
packageManagerProvider.overrideWith(
|
||||||
(ref) => Platform.isAndroid
|
(ref) => Platform.isAndroid
|
||||||
? AndroidPackageManager(ref)
|
? BackgroundPackageManager(ref)
|
||||||
: DummyPackageManager(ref),
|
: DummyPackageManager(ref),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize Purplebase with same DB path as main app
|
// 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');
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
||||||
|
|
||||||
await container.read(
|
await container.read(
|
||||||
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
|||||||
source: const RemoteSource(relays: 'AppCatalog', stream: false),
|
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) {
|
if (apps.isNotEmpty) {
|
||||||
final addressableIds = apps
|
final releaseFilters = apps
|
||||||
.map((app) => app.event.addressableId)
|
.map((app) => app.latestRelease.req?.filters.firstOrNull)
|
||||||
.toSet();
|
.nonNulls
|
||||||
await storage.query(
|
.toList();
|
||||||
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(),
|
if (releaseFilters.isNotEmpty) {
|
||||||
source: const LocalAndRemoteSource(
|
final List<Release> releases = await storage.query(
|
||||||
relays: 'AppCatalog',
|
Request<Release>(releaseFilters),
|
||||||
stream: false,
|
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
|
// Re-query apps from local to ensure relationships are loaded
|
||||||
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
|
|||||||
android: initializationSettingsAndroid,
|
android: initializationSettingsAndroid,
|
||||||
);
|
);
|
||||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||||
|
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||||
|
|
||||||
// Build notification content
|
// Build notification content
|
||||||
final title = updateCount == 1
|
final title = updateCount == 1
|
||||||
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
|
|||||||
// Navigation is handled by the app's normal launch flow
|
// Navigation is handled by the app's normal launch flow
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
|
||||||
// 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel background update checks
|
/// 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
|
/// Provider for the background update service
|
||||||
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
||||||
BackgroundUpdateService.new,
|
BackgroundUpdateService.new,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:models/models.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';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
|
||||||
/// Install status values from native side.
|
/// Install status values from native side.
|
||||||
@@ -14,6 +15,7 @@ enum InstallStatus {
|
|||||||
started,
|
started,
|
||||||
verifying,
|
verifying,
|
||||||
pendingUserAction,
|
pendingUserAction,
|
||||||
|
installing, // User accepted, system is now installing
|
||||||
alreadyInProgress,
|
alreadyInProgress,
|
||||||
success,
|
success,
|
||||||
failed,
|
failed,
|
||||||
@@ -26,6 +28,7 @@ extension InstallStatusX on InstallStatus {
|
|||||||
'started' => InstallStatus.started,
|
'started' => InstallStatus.started,
|
||||||
'verifying' => InstallStatus.verifying,
|
'verifying' => InstallStatus.verifying,
|
||||||
'pendingUserAction' => InstallStatus.pendingUserAction,
|
'pendingUserAction' => InstallStatus.pendingUserAction,
|
||||||
|
'installing' => InstallStatus.installing,
|
||||||
'alreadyInProgress' => InstallStatus.alreadyInProgress,
|
'alreadyInProgress' => InstallStatus.alreadyInProgress,
|
||||||
'success' => InstallStatus.success,
|
'success' => InstallStatus.success,
|
||||||
'failed' => InstallStatus.failed,
|
'failed' => InstallStatus.failed,
|
||||||
@@ -170,6 +173,17 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
case InstallStatus.alreadyInProgress:
|
||||||
// Real pending dialog exists - Kotlin will also send pendingUserAction event
|
// Real pending dialog exists - Kotlin will also send pendingUserAction event
|
||||||
// which transitions to Installing. Nothing to do here.
|
// which transitions to Installing. Nothing to do here.
|
||||||
@@ -587,6 +601,7 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = state.copyWith(installed: {...packages, ...preserved});
|
state = state.copyWith(installed: {...packages, ...preserved});
|
||||||
|
await InstalledPackagesSnapshot.save(state.installed);
|
||||||
|
|
||||||
// Clear operations for apps where the installed version matches the target version
|
// Clear operations for apps where the installed version matches the target version
|
||||||
// This catches installs that succeeded but we missed the event
|
// 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() {
|
void _processQueuedDownload() {
|
||||||
var activeDownloads = countOperations<Downloading>();
|
final activeDownloads = countOperations<Downloading>();
|
||||||
if (activeDownloads >= maxConcurrentDownloads) return;
|
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) {
|
for (final entry in state.operations.entries) {
|
||||||
if (activeDownloads >= maxConcurrentDownloads) break;
|
|
||||||
|
|
||||||
if (entry.value is DownloadQueued) {
|
if (entry.value is DownloadQueued) {
|
||||||
final queued = entry.value as DownloadQueued;
|
queuedItems[entry.key] = entry.value as DownloadQueued;
|
||||||
final downloadUrl = queued.target.urls.firstOrNull;
|
}
|
||||||
if (downloadUrl != null) {
|
}
|
||||||
|
|
||||||
|
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(
|
_startDownloadTask(
|
||||||
entry.key,
|
entry.key,
|
||||||
queued.target,
|
queued.target,
|
||||||
downloadUrl,
|
downloadUrl,
|
||||||
displayName: queued.displayName,
|
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_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
@@ -34,8 +35,27 @@ class SecureStorageService {
|
|||||||
final value = await _storage.read(key: _nwcKey);
|
final value = await _storage.read(key: _nwcKey);
|
||||||
return value?.isNotEmpty == true;
|
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>(
|
final secureStorageServiceProvider = Provider<SecureStorageService>(
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||||
subscriptionPrefix: 'updates',
|
subscriptionPrefix: 'app-updates',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget {
|
|||||||
tags: app.event.addressableIdTagMap,
|
tags: app.event.addressableIdTagMap,
|
||||||
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
|
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
|
||||||
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
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) {
|
List<String> _getPreviewIdentifiers(AppStack stack) {
|
||||||
final rawTags = _getRawAppTagValues(stack).toList()
|
final rawTags = _getRawAppTagValues(stack).toList()
|
||||||
..shuffle(Random(stack.id.hashCode));
|
..shuffle(Random(stack.id.hashCode));
|
||||||
return rawTags
|
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
|
||||||
.take(3)
|
|
||||||
.map(_extractIdentifier)
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort app stacks: franzap/following first, then by recency
|
/// 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));
|
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
return [...followed, ...others];
|
return [...followed, ...others];
|
||||||
} else {
|
} else {
|
||||||
final franzapStacks =
|
final franzapStacks = stacks
|
||||||
stacks.where((s) => s.pubkey == kFranzapPubkey).toList();
|
.where((s) => s.pubkey == kFranzapPubkey)
|
||||||
final otherStacks =
|
.toList();
|
||||||
stacks.where((s) => s.pubkey != kFranzapPubkey).toList();
|
final otherStacks = stacks
|
||||||
|
.where((s) => s.pubkey != kFranzapPubkey)
|
||||||
|
.toList();
|
||||||
|
|
||||||
franzapStacks.shuffle(random);
|
franzapStacks.shuffle(random);
|
||||||
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
|
||||||
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
scrollController.position.maxScrollExtent - 200) {
|
scrollController.position.maxScrollExtent - 200) {
|
||||||
// Load more if we haven't shown all stacks yet
|
// Load more if we haven't shown all stacks yet
|
||||||
if (visibleCount.value < sortedStacks.length) {
|
if (visibleCount.value < sortedStacks.length) {
|
||||||
visibleCount.value = (visibleCount.value + _kBatchSize)
|
visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
|
||||||
.clamp(0, sortedStacks.length);
|
0,
|
||||||
|
sortedStacks.length,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
if (col * 2 < displayedStacks.length)
|
if (col * 2 < displayedStacks.length)
|
||||||
_StackCard(
|
_StackCard(
|
||||||
stack: displayedStacks[col * 2],
|
stack: displayedStacks[col * 2],
|
||||||
author: authorsMap[displayedStacks[col * 2].event.pubkey],
|
author:
|
||||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey),
|
authorsMap[displayedStacks[col * 2].event.pubkey],
|
||||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
isAuthorLoading: isAuthorLoading(
|
||||||
|
displayedStacks[col * 2].event.pubkey,
|
||||||
|
),
|
||||||
|
previewIdentifiers:
|
||||||
|
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
|
||||||
appsMap: appsMap,
|
appsMap: appsMap,
|
||||||
),
|
),
|
||||||
// Bottom item
|
// Bottom item
|
||||||
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_StackCard(
|
_StackCard(
|
||||||
stack: displayedStacks[col * 2 + 1],
|
stack: displayedStacks[col * 2 + 1],
|
||||||
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey],
|
author:
|
||||||
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey),
|
authorsMap[displayedStacks[col * 2 + 1]
|
||||||
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [],
|
.event
|
||||||
|
.pubkey],
|
||||||
|
isAuthorLoading: isAuthorLoading(
|
||||||
|
displayedStacks[col * 2 + 1].event.pubkey,
|
||||||
|
),
|
||||||
|
previewIdentifiers:
|
||||||
|
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
|
||||||
|
[],
|
||||||
appsMap: appsMap,
|
appsMap: appsMap,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||||
.colorScheme
|
|
||||||
.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.8),
|
.withValues(alpha: 0.8),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(
|
||||||
.colorScheme
|
context,
|
||||||
.surfaceContainerHighest
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
fadeInDuration: const Duration(milliseconds: 200),
|
fadeInDuration: const Duration(milliseconds: 200),
|
||||||
placeholder: (_, __) => Container(
|
placeholder: (_, __) => Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
errorWidget: (_, __, ___) => Container(
|
errorWidget: (_, __, ___) => Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.broken_image_outlined,
|
Icons.broken_image_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.apps_outlined,
|
Icons.apps_outlined,
|
||||||
size: 16,
|
size: 16,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ class InstallButton extends ConsumerWidget {
|
|||||||
return switch (operation) {
|
return switch (operation) {
|
||||||
DownloadQueued() => _buildSimpleButton(
|
DownloadQueued() => _buildSimpleButton(
|
||||||
context,
|
context,
|
||||||
'Queued',
|
'Queued for download',
|
||||||
null,
|
null,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
showSpinner: true,
|
showSpinner: true,
|
||||||
@@ -146,12 +146,12 @@ class InstallButton extends ConsumerWidget {
|
|||||||
isWarning: true,
|
isWarning: true,
|
||||||
),
|
),
|
||||||
|
|
||||||
ReadyToInstall() => _buildAsyncButton(
|
ReadyToInstall() => _buildSimpleButton(
|
||||||
context,
|
context,
|
||||||
ref,
|
'Queued for ${isInstalled ? 'update' : 'install'}',
|
||||||
text: isInstalled ? 'Update' : 'Install',
|
null, // Not tappable - system advances automatically
|
||||||
onPressed: () => _triggerInstall(ref),
|
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
|
showSpinner: true,
|
||||||
),
|
),
|
||||||
|
|
||||||
Installing(:final isSilent) => _buildSimpleButton(
|
Installing(:final isSilent) => _buildSimpleButton(
|
||||||
@@ -539,11 +539,6 @@ class InstallButton extends ConsumerWidget {
|
|||||||
pm.resumeDownload(app.identifier);
|
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 {
|
Future<void> _retryInstall(WidgetRef ref) async {
|
||||||
final pm = ref.read(packageManagerProvider.notifier);
|
final pm = ref.read(packageManagerProvider.notifier);
|
||||||
await pm.retryInstall(app.identifier);
|
await pm.retryInstall(app.identifier);
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
|||||||
},
|
},
|
||||||
// NOTE: It must stream=true
|
// NOTE: It must stream=true
|
||||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||||
subscriptionPrefix: 'latest',
|
subscriptionPrefix: 'app-latest',
|
||||||
),
|
),
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
// Always mirror storage state and ensure olderApps don't duplicate the live head
|
// 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 poolState = ref.watch(poolStateProvider);
|
||||||
final subscriptions = poolState?.subscriptions ?? {};
|
final subscriptions = poolState?.subscriptions ?? {};
|
||||||
|
|
||||||
// Filter to only "latest" subscription
|
// Filter to only "app-latest" subscription
|
||||||
final latestSubs = subscriptions.entries
|
final latestSubs = subscriptions.entries
|
||||||
.where((e) => e.key.startsWith('latest'))
|
.where((e) => e.key.startsWith('app-latest'))
|
||||||
.map((e) => e.value);
|
.map((e) => e.value);
|
||||||
|
|
||||||
// Check relay status for latest subscription
|
// 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"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "4d065ba"
|
ref: "6cbedbe"
|
||||||
resolved-ref: "4d065badcef18ea397dff40c46fcce114ecca12a"
|
resolved-ref: "6cbedbe6ae58a952c564979002258840921b041b"
|
||||||
url: "https://github.com/purplebase/amber_signer"
|
url: "https://github.com/purplebase/amber_signer"
|
||||||
source: git
|
source: git
|
||||||
version: "0.2.0"
|
version: "0.2.0"
|
||||||
@@ -581,26 +581,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.2"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -653,10 +653,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1141,10 +1141,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.7"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1261,10 +1261,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ dependency_overrides:
|
|||||||
# path: ../amber_signer
|
# path: ../amber_signer
|
||||||
git:
|
git:
|
||||||
url: https://github.com/purplebase/amber_signer
|
url: https://github.com/purplebase/amber_signer
|
||||||
ref: 4d065ba
|
ref: 6cbedbe
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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.
|
For detailed API, see models/purplebase READMEs in pub cache.
|
||||||
|
|||||||
Reference in New Issue
Block a user