From 77316a5984b9e8143d8289f1fb3b98359acea696 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 25 Dec 2025 19:49:14 -0300 Subject: [PATCH] Revamped package manager --- android/app/build.gradle.kts | 1 + .../kotlin/dev/zapstore/alpha/MainActivity.kt | 2 +- .../plugins/AndroidPackageManagerPlugin.kt | 1581 ++++++++--------- .../alpha/plugins/InstallResultReceiver.kt | 264 ++- lib/main.dart | 42 +- lib/screens/app_detail_screen.dart | 12 +- lib/screens/profile_screen.dart | 24 +- lib/screens/updates_screen.dart | 217 ++- lib/services/background_update_service.dart | 6 +- lib/services/download/download_info.dart | 84 - .../download/download_persistence.dart | 166 -- lib/services/download/download_service.dart | 455 ----- lib/services/download/installation_queue.dart | 239 --- .../android_package_manager.dart | 708 ++++++-- .../dummy_package_manager.dart | 106 +- .../package_manager/install_operation.dart | 202 +++ .../package_manager/package_manager.dart | 1093 +++++++++++- lib/services/updates_service.dart | 64 +- lib/utils/extensions.dart | 2 +- lib/widgets/app_card.dart | 12 +- lib/widgets/app_detail_widgets.dart | 16 +- lib/widgets/auth_widgets.dart | 11 +- lib/widgets/install_button.dart | 1057 +++++------ lib/widgets/install_button_state.dart | 172 -- lib/widgets/sign_in_button.dart | 11 +- lib/widgets/version_pill_widget.dart | 35 +- 26 files changed, 3447 insertions(+), 3135 deletions(-) delete mode 100644 lib/services/download/download_info.dart delete mode 100644 lib/services/download/download_persistence.dart delete mode 100644 lib/services/download/download_service.dart delete mode 100644 lib/services/download/installation_queue.dart create mode 100644 lib/services/package_manager/install_operation.dart delete mode 100644 lib/widgets/install_button_state.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4585738..e52c683 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -98,4 +98,5 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("androidx.lifecycle:lifecycle-process:2.7.0") } diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt index 90b28e8..86750bf 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/MainActivity.kt @@ -9,7 +9,7 @@ class MainActivity: FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - // Register the unified package manager plugin + // Register plugins flutterEngine.plugins.add(AndroidPackageManagerPlugin()) flutterEngine.plugins.add(AppRestartPlugin()) } diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 67b25c5..b8b146f 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -5,481 +5,661 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.content.pm.PackageManager -import android.app.role.RoleManager -import android.app.admin.DevicePolicyManager import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.UserManager import android.provider.Settings import android.system.Os import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import java.io.File import java.io.FileInputStream -import java.io.IOException import java.security.MessageDigest private const val TAG = "AndroidPackageManager" +/** Uninstall polling timeout - 30 seconds is plenty for user to confirm/cancel */ +private const val UNINSTALL_TIMEOUT_MS = 30_000L +private const val UNINSTALL_POLL_INTERVAL_MS = 500L + +/** Watchdog timeouts (bounded with backoff to avoid false negatives on slow devices) */ +private const val VERIFY_WATCHDOG_MS = 10_000L +private const val INSTALL_WATCHDOG_MS = 10_000L +private const val MAX_INSTALL_WATCHDOG_MS = 120_000L + /** - * AndroidPackageManagerPlugin following Accrescent's proven silent installation approach. - * - * Silent Installation Behavior: - * - Apps installed BY Zapstore: Updates are completely silent (no user prompt) - * - Apps installed by OTHER sources: First update requires user confirmation to transfer - * installer ownership. After accepting once, all subsequent updates are silent. - * - * This is Android's security model to prevent malicious "store hijacking" - even with - * APP_MARKET category and UPDATE_PACKAGES_WITHOUT_USER_ACTION permission. - * - * Requirements: - * - Android 12+ (API 31+) for USER_ACTION_NOT_REQUIRED - * - APP_MARKET category in AndroidManifest.xml - * - UPDATE_PACKAGES_WITHOUT_USER_ACTION permission - * - ENFORCE_UPDATE_OWNERSHIP permission (Android 14+) + * Install status values emitted via EventChannel. + * These form a simple state machine with no hanging states. */ -class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { - private lateinit var channel: MethodChannel +object InstallStatus { + const val STARTED = "started" + const val VERIFYING = "verifying" + const val PENDING_USER_ACTION = "pendingUserAction" + const val ALREADY_IN_PROGRESS = "alreadyInProgress" + const val SUCCESS = "success" + const val FAILED = "failed" + const val CANCELLED = "cancelled" +} + +/** + * Structured error codes for failures. + * Dart uses these for reliable error categorization instead of parsing strings. + */ +object ErrorCode { + const val DOWNLOAD_FAILED = "downloadFailed" + const val HASH_MISMATCH = "hashMismatch" + const val INVALID_FILE = "invalidFile" + const val INSTALL_FAILED = "installFailed" + const val CERT_MISMATCH = "certMismatch" + const val PERMISSION_DENIED = "permissionDenied" + const val INSUFFICIENT_STORAGE = "insufficientStorage" + const val INCOMPATIBLE = "incompatible" + const val BLOCKED = "blocked" + const val ALREADY_IN_PROGRESS = "alreadyInProgress" + const val INSTALL_TIMEOUT = "installTimeout" +} + +/** + * AndroidPackageManagerPlugin - Event-driven architecture for clean state management. + * + * Architecture: + * - MethodChannel: Fire-and-forget commands (install, uninstall, etc.) + * - EventChannel: Streams install status events to Dart + * - ProcessLifecycleOwner: Auto-detects app foreground state (no Dart involvement) + * + * The native side is the single source of truth for install state. + * Dart simply reacts to events - no polling, no probing, no hanging awaits. + */ +class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, + EventChannel.StreamHandler, DefaultLifecycleObserver { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + /** Per-app watchdog generation counters (cancels old scheduled callbacks) */ + private val watchdogGen = mutableMapOf() + private val watchdogDeadlineMs = mutableMapOf() + + /** Track verification threads so we can "ping" and avoid false timeouts */ + private val verificationThreads = mutableMapOf() + + private var eventSink: EventChannel.EventSink? = null companion object { - // Store pending install results keyed by sessionId - completed when broadcast arrives - private val pendingInstallResults = mutableMapOf() + private var instance: AndroidPackageManagerPlugin? = null - // Map sessionId to packageName for reverse lookup + /** Map sessionId to packageName for reverse lookup in broadcasts */ private val sessionToPackage = mutableMapOf() - // Store pending user action intents keyed by packageName - for re-launching after foreground + /** Pending user action intents - stored for re-launch when app returns to foreground */ private val pendingUserActionIntents = mutableMapOf() - - // Track whether the pending user action prompt was deferred (app backgrounded or launch failed). - // Only deferred prompts should be re-launched on app resume to avoid double system dialogs. - private val pendingUserActionDeferred = mutableMapOf() - // Track app foreground state - set by Dart via setAppForegroundState + /** Buffer last-known event per appId when Dart isn't listening yet */ + private val bufferedEvents = mutableMapOf>() + + /** App foreground state - auto-managed by ProcessLifecycleOwner */ private var isAppInForeground = true - // Reference to context for static methods (set during onAttachedToEngine) private var appContext: Context? = null /** - * Set the app's foreground state. Called from Dart when lifecycle changes. + * Called by InstallResultReceiver when a broadcast arrives. + * Emits the status event to Dart via EventChannel. */ - fun setAppForegroundState(foreground: Boolean) { - isAppInForeground = foreground - Log.d(TAG, "App foreground state: $foreground") - } - - /** - * Check if the app is currently in foreground. - */ - fun isAppInForeground(): Boolean = isAppInForeground - - /** - * Check if we're already waiting for a result for this package. - */ - fun hasPendingResultForPackage(packageName: String): Boolean { - return sessionToPackage.containsValue(packageName) - } - - /** - * Called by InstallResultReceiver when installation completes/fails. - * Completes the pending method channel result so Dart await finishes. - */ - fun completeInstallResult(sessionId: Int, resultMap: Map, context: Context? = null) { - val result = pendingInstallResults.remove(sessionId) - val packageName = resultMap["packageName"] as? String ?: sessionToPackage[sessionId] + fun onInstallResult( + sessionId: Int, + status: String, + packageName: String, + message: String? = null, + isUpdate: Boolean = false, + confirmIntent: Intent? = null, + errorCode: String? = null + ) { + val pkg = packageName.ifEmpty { sessionToPackage[sessionId] ?: "unknown" } - // Clear all tracking for this session/package - sessionToPackage.remove(sessionId) - if (packageName != null) { - pendingUserActionIntents.remove(packageName) - pendingUserActionDeferred.remove(packageName) - Log.d(TAG, "Cleared all tracking for $packageName (session $sessionId)") - } + Log.d(TAG, "onInstallResult: pkg=$pkg, status=$status, msg=$message, errorCode=$errorCode, sessionId=$sessionId") - // For failed/cancelled installs, try to abandon the session to clean up - val isSuccess = resultMap["isSuccess"] as? Boolean ?: false - val ctx = context ?: appContext - if (!isSuccess && ctx != null) { - try { - val packageInstaller = ctx.packageManager.packageInstaller - packageInstaller.abandonSession(sessionId) - Log.d(TAG, "Abandoned failed session $sessionId") - } catch (e: Exception) { - // Session might already be abandoned or completed - Log.d(TAG, "Could not abandon session $sessionId: ${e.message}") + // Handle pending user action - store intent for potential re-launch + if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { + pendingUserActionIntents[pkg] = confirmIntent + + // Auto-launch dialog if app is in foreground + if (isAppInForeground) { + launchConfirmDialog(pkg, confirmIntent) } } - if (result != null) { - Log.d(TAG, "Completing install result for session $sessionId: $resultMap") - result.success(resultMap) - } else { - Log.w(TAG, "No pending result found for session $sessionId") + // Clean up tracking on terminal states + if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) { + sessionToPackage.remove(sessionId) + pendingUserActionIntents.remove(pkg) + + if (status != InstallStatus.SUCCESS) { + abandonSession(sessionId) + } + } + + val inst = instance + if (inst == null) { + Log.e(TAG, "CRITICAL: Cannot emit event - plugin instance is null! pkg=$pkg, status=$status") + bufferEvent(pkg, status, message, errorCode) + return + } + inst.emitInstallStatus(pkg, status, message, errorCode) + } + + private fun bufferEvent( + appId: String, + status: String, + message: String? = null, + errorCode: String? = null + ) { + val event = mutableMapOf( + "appId" to appId, + "status" to status + ) + if (message != null) event["message"] = message + if (errorCode != null) event["errorCode"] = errorCode + bufferedEvents[appId] = event + } + + private fun launchConfirmDialog(packageName: String, intent: Intent) { + val ctx = appContext ?: return + try { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ctx.startActivity(intent) + Log.d(TAG, "Launched confirmation dialog for $packageName") + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) } } - /** - * Check if we have a pending result for this session (used by receiver) - */ - fun hasPendingResult(sessionId: Int): Boolean { - return pendingInstallResults.containsKey(sessionId) + private fun abandonSession(sessionId: Int) { + val ctx = appContext ?: return + try { + ctx.packageManager.packageInstaller.abandonSession(sessionId) + } catch (e: Exception) { + Log.d(TAG, "Could not abandon session $sessionId: ${e.message}") + } } - /** - * Store a pending user action intent for later re-launch. - */ - fun storePendingUserActionIntent(packageName: String, intent: Intent) { - pendingUserActionIntents[packageName] = intent - Log.d(TAG, "Stored pending user action intent for $packageName") - } - - /** - * Mark whether a pending user action prompt was deferred. - * - * - deferred=true: prompt was NOT shown (or failed to show). Safe to re-launch on resume. - * - deferred=false: prompt was shown while in foreground. Do NOT re-launch on resume. - */ - fun setPendingUserActionDeferred(packageName: String, deferred: Boolean) { - pendingUserActionDeferred[packageName] = deferred - Log.d(TAG, "Pending user action deferred for $packageName: $deferred") - } - - /** - * Check if there's a pending user action for a package - */ - fun hasPendingUserAction(packageName: String): Boolean { - return pendingUserActionIntents.containsKey(packageName) - } + fun isAppInForeground(): Boolean = isAppInForeground } - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_package_manager") - channel.setMethodCallHandler(this) - context = flutterPluginBinding.applicationContext + + // ═══════════════════════════════════════════════════════════════════════════════ + // FLUTTER PLUGIN LIFECYCLE + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext appContext = context + instance = this - // Clean up ALL existing sessions on startup - start fresh - abandonAllSessions() + methodChannel = MethodChannel(binding.binaryMessenger, "android_package_manager") + methodChannel.setMethodCallHandler(this) + eventChannel = EventChannel(binding.binaryMessenger, "android_package_manager/events") + eventChannel.setStreamHandler(this) + + // Auto-detect foreground state via ProcessLifecycleOwner + mainHandler.post { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + cleanupStaleSessions() Log.d(TAG, "AndroidPackageManagerPlugin initialized") } - /** - * Abandon ALL PackageInstaller sessions owned by this app. - * Called on startup to ensure clean state. - */ - private fun abandonAllSessions() { - try { - val packageInstaller = context.packageManager.packageInstaller - val mySessions = packageInstaller.mySessions - var abandonedCount = 0 - - for (session in mySessions) { - // Never abandon active sessions: doing so can cause "session files in use" and/or - // break in-progress installs when the engine re-attaches. - if (session.isActive) { - Log.d(TAG, "Startup cleanup: skipping active session ${session.sessionId} for ${session.appPackageName}") - continue - } - Log.d(TAG, "Abandoning startup session ${session.sessionId} for ${session.appPackageName}") - try { - packageInstaller.abandonSession(session.sessionId) - abandonedCount++ - } catch (e: Exception) { - Log.w(TAG, "Failed to abandon session ${session.sessionId}", e) - } - } - - // Clear all in-memory state - pendingInstallResults.clear() - sessionToPackage.clear() - pendingUserActionIntents.clear() - pendingUserActionDeferred.clear() - - if (abandonedCount > 0) { - Log.d(TAG, "Startup cleanup: abandoned $abandonedCount sessions") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to cleanup sessions on startup", e) - } - } - - /** - * Find an existing session for a package, if any. - * Returns the session info or null if no session exists. - */ - private fun findExistingSession(packageName: String): PackageInstaller.SessionInfo? { - return try { - val packageInstaller = context.packageManager.packageInstaller - packageInstaller.mySessions.find { it.appPackageName == packageName } - } catch (e: Exception) { - Log.w(TAG, "Failed to check existing sessions", e) - null - } - } - - /** - * Abandon any existing session for a package. - * Returns true if a session was abandoned. - */ - private fun abandonExistingSession(packageName: String): Boolean { - val existingSession = findExistingSession(packageName) - if (existingSession != null) { - try { - val packageInstaller = context.packageManager.packageInstaller - packageInstaller.abandonSession(existingSession.sessionId) - pendingInstallResults.remove(existingSession.sessionId) - sessionToPackage.remove(existingSession.sessionId) - pendingUserActionIntents.remove(packageName) - pendingUserActionDeferred.remove(packageName) - Log.d(TAG, "Abandoned existing session ${existingSession.sessionId} for $packageName") - return true - } catch (e: Exception) { - Log.w(TAG, "Failed to abandon session for $packageName", e) - } - } - return false - } - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - // Clear any pending results on detach - pendingInstallResults.clear() + mainHandler.post { + ProcessLifecycleOwner.get().lifecycle.removeObserver(this) + } + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + eventSink = null + instance = null + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // LIFECYCLE OBSERVER (Auto foreground detection) + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onStart(owner: LifecycleOwner) { + // App came to foreground + val wasForeground = isAppInForeground + isAppInForeground = true + Log.d(TAG, "App foregrounded") + + if (!wasForeground) { + // Re-launch any pending install dialogs + for ((packageName, intent) in pendingUserActionIntents.toMap()) { + Log.d(TAG, "Re-launching pending dialog for $packageName") + launchConfirmDialog(packageName, intent) + } + } + } + + override fun onStop(owner: LifecycleOwner) { + // App went to background + isAppInForeground = false + Log.d(TAG, "App backgrounded") + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // EVENT CHANNEL STREAM HANDLER + // ═══════════════════════════════════════════════════════════════════════════════ + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + // Flush buffered events (if any) so Dart can reconcile state. + val sink = eventSink ?: return + mainHandler.post { + if (bufferedEvents.isNotEmpty()) { + for (event in bufferedEvents.values) { + try { + sink.success(event) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush buffered event: $event", e) + } + } + bufferedEvents.clear() + } + } + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + private fun emitInstallStatus( + appId: String, + status: String, + message: String? = null, + errorCode: String? = null + ) { + val emitNow = emit@{ + // Update watchdog regardless of whether Dart is listening. + updateWatchdog(appId, status) + + val sink = eventSink + if (sink == null) { + Log.e(TAG, "CRITICAL: Cannot emit event - eventSink is null! appId=$appId, status=$status, msg=$message") + bufferEvent(appId, status, message, errorCode) + return@emit + } + + val event = mutableMapOf( + "appId" to appId, + "status" to status + ) + if (message != null) { + event["message"] = message + } + if (errorCode != null) { + event["errorCode"] = errorCode + } + Log.d(TAG, "Emitting to Dart: $event") + sink.success(event) + } + + // If we're already on the main thread (common for MethodChannel calls), + // emit synchronously so the UI can transition immediately. + if (Looper.myLooper() == Looper.getMainLooper()) { + emitNow() + } else { + mainHandler.post { emitNow() } + } } + private fun bumpWatchdogGen(appId: String): Int { + val next = (watchdogGen[appId] ?: 0) + 1 + watchdogGen[appId] = next + return next + } + + private fun clearWatchdog(appId: String) { + bumpWatchdogGen(appId) + watchdogDeadlineMs.remove(appId) + } + + private fun scheduleWatchdog(appId: String, initialDelayMs: Long) { + val gen = bumpWatchdogGen(appId) + val now = System.currentTimeMillis() + val deadline = watchdogDeadlineMs[appId] ?: (now + MAX_INSTALL_WATCHDOG_MS).also { + watchdogDeadlineMs[appId] = it + } + + mainHandler.postDelayed({ + if (watchdogGen[appId] != gen) return@postDelayed + + // "Ping" reality before timing out. + val hasSession = hasActiveSession(appId) + val hasPendingUi = pendingUserActionIntents.containsKey(appId) + val verifyThread = verificationThreads[appId] + val verifyingAlive = verifyThread?.isAlive == true + + // If work is still happening, extend (bounded by deadline). + val now2 = System.currentTimeMillis() + if (now2 < deadline && (hasSession || hasPendingUi || verifyingAlive)) { + // Nudge Dart to show the most accurate state (esp. after reconnect). + if (hasPendingUi) { + emitInstallStatus(appId, InstallStatus.PENDING_USER_ACTION, "User confirmation pending") + } else if (hasSession) { + emitInstallStatus(appId, InstallStatus.STARTED) + } else if (verifyingAlive) { + emitInstallStatus(appId, InstallStatus.VERIFYING) + } + // Backoff: next check is another INSTALL_WATCHDOG_MS. + scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) + return@postDelayed + } + + // Deadline exceeded or no evidence of progress: fail fast and cleanup. + Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive)") + abandonExistingSession(appId) + emitInstallStatus( + appId, + InstallStatus.FAILED, + "Installation timed out. Please retry.", + ErrorCode.INSTALL_TIMEOUT + ) + clearWatchdog(appId) + }, initialDelayMs) + } + + private fun updateWatchdog(appId: String, status: String) { + when (status) { + InstallStatus.VERIFYING -> { + // Start watchdog for verify stage + scheduleWatchdog(appId, VERIFY_WATCHDOG_MS) + } + InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> { + // Start/refresh watchdog for install stage + scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) + } + InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED -> { + clearWatchdog(appId) + verificationThreads.remove(appId) + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // METHOD CHANNEL HANDLER + // ═══════════════════════════════════════════════════════════════════════════════ + override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "install" -> { val filePath = call.argument("filePath") - if (filePath == null) { - result.error("MISSING_ARGUMENT", "File path is required", null) + val packageName = call.argument("packageName") + val expectedHash = call.argument("expectedHash") + val expectedSize = call.argument("expectedSize")?.toLong() + + if (filePath == null || packageName == null) { + result.error("MISSING_ARGUMENT", "filePath and packageName required", null) return } - val expectedHash = call.argument("expectedHash") - // Dart integers come as Integer, not Long, so we need to safely convert - val expectedSize = call.argument("expectedSize")?.toLong() - val skipVerification = call.argument("skipVerification") ?: false - installApk(filePath, expectedHash, expectedSize, skipVerification, result) + installApk(filePath, packageName, expectedHash, expectedSize, result) } + "canInstallSilently" -> { val packageName = call.argument("packageName") result.success(canInstallSilently(packageName)) } - "hasUnknownSourcesPermission" -> { - result.success(hasUnknownSourcesPermission()) - } + + "hasUnknownSourcesPermission" -> result.success(hasUnknownSourcesPermission()) + + "requestInstallPermission" -> requestInstallPermission(result) + "getInstalledApps" -> { val includeSystem = call.argument("includeSystemApps") ?: false result.success(getInstalledApps(includeSystem)) } - "requestInstallPermission" -> { - requestInstallPermission(result) - } + "uninstall" -> { val packageName = call.argument("packageName") if (packageName == null) { - result.error("MISSING_ARGUMENT", "Package name is required", null) + result.error("MISSING_ARGUMENT", "packageName required", null) return } uninstallApp(packageName, result) } + "launchApp" -> { val packageName = call.argument("packageName") if (packageName == null) { - result.error("MISSING_ARGUMENT", "Package name is required", null) + result.error("MISSING_ARGUMENT", "packageName required", null) return } launchApp(packageName, result) } - "retryPendingInstall" -> { - val packageName = call.argument("packageName") - if (packageName == null) { - result.error("MISSING_ARGUMENT", "Package name is required", null) - return - } - retryPendingInstall(packageName, result) - } - "setAppForegroundState" -> { - val foreground = call.argument("foreground") ?: true - setAppForegroundState(foreground) - result.success(null) - } - else -> { - result.notImplemented() - } + + else -> result.notImplemented() } } - - private fun canInstallSilently(packageName: String? = null): Boolean { - try { - // Android 12+ (API 31+) is required for USER_ACTION_NOT_REQUIRED to work - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - return false + + // ═══════════════════════════════════════════════════════════════════════════════ + // INSTALLATION + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun installApk( + filePath: String, + packageName: String, + expectedHash: String?, + expectedSize: Long?, + result: Result + ) { + val file = File(filePath) + if (!file.exists()) { + emitInstallStatus(packageName, InstallStatus.FAILED, "APK file not found", ErrorCode.DOWNLOAD_FAILED) + result.success(mapOf("started" to false, "error" to "APK file not found", "errorCode" to ErrorCode.DOWNLOAD_FAILED)) + return + } + + if (hasActiveSession(packageName)) { + pendingUserActionIntents[packageName]?.let { launchConfirmDialog(packageName, it) } + // IMPORTANT: Emit an event so Dart can transition out of active states. + if (pendingUserActionIntents.containsKey(packageName)) { + emitInstallStatus(packageName, InstallStatus.PENDING_USER_ACTION, "User confirmation pending") + } else { + emitInstallStatus(packageName, InstallStatus.ALREADY_IN_PROGRESS, "Installation already in progress", ErrorCode.ALREADY_IN_PROGRESS) } - - // 1) UPDATE_PACKAGES_WITHOUT_USER_ACTION permission (the main one we rely on) - val hasUpdateWithoutUserActionPerm = try { - context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") == PackageManager.PERMISSION_GRANTED - } catch (_: Throwable) { false } - - // 2) Device owner (enterprise) can generally install without user action - val isDeviceOwner = try { - val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - dpm.isDeviceOwnerApp(context.packageName) - } catch (_: Throwable) { false } - - // 3) Default package installer role (rare; typically system store) - val hasInstallerRole = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val roleManager = context.getSystemService(Context.ROLE_SERVICE) as RoleManager - val installerRole = "android.app.role.PACKAGE_INSTALLER" - roleManager.isRoleAvailable(installerRole) && - roleManager.isRoleHeld(installerRole) - } else false - } catch (_: Throwable) { false } - - // 4) Check if we can request package installs (basic requirement) - val canRequestInstalls = try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.packageManager.canRequestPackageInstalls() - } else true - } catch (_: Throwable) { false } - - // Additional check for installer ownership if package name provided - val weAreInstaller = if (packageName != null) { - try { - val installerPackageName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - context.packageManager.getInstallSourceInfo(packageName).installingPackageName + result.success(mapOf("started" to false, "alreadyInProgress" to true)) + return + } + + if (expectedHash != null && expectedSize != null) { + // Emit verifying status before starting hash check + emitInstallStatus(packageName, InstallStatus.VERIFYING) + result.success(mapOf("started" to true, "verifying" to true)) + + val t = Thread { + val verification = verifyApk(file, expectedHash, expectedSize) + mainHandler.post { + if (verification.isSuccess) { + startInstallSession(file, packageName, result = null) // Result already sent } else { - @Suppress("DEPRECATION") - context.packageManager.getInstallerPackageName(packageName) + emitInstallStatus( + packageName, + InstallStatus.FAILED, + verification.errorMessage, + verification.errorCode + ) } - val isInstaller = installerPackageName == context.packageName - isInstaller - } catch (e: PackageManager.NameNotFoundException) { - // Package not installed yet, so we can install it silently (first install) - Log.d(TAG, "Package $packageName not installed yet (NameNotFoundException)") - true - } catch (e: Exception) { - // If we can't verify we're the installer, assume we're not - Log.w(TAG, "Failed to check installer for $packageName", e) - false + verificationThreads.remove(packageName) } - } else { - true // No specific package check - } - - // Can install silently if we have the permission OR (we're the installer/device owner/have role) - val hasSpecialPrivilege = hasUpdateWithoutUserActionPerm || isDeviceOwner || hasInstallerRole - - // CRITICAL: Even with UPDATE_PACKAGES_WITHOUT_USER_ACTION permission, - // Android requires one-time user confirmation to transfer installer ownership. - // For UI categorization, we must check if we're already the installer. - // If a specific package is being checked, installer ownership is REQUIRED for truly silent updates. - val canSilently = if (packageName != null) { - // For specific package checks: must be installer OR have special privileges AND be installer - // Actually, even with privileges, first update of non-owned app requires user action - canRequestInstalls && weAreInstaller - } else { - // General capability check (no specific package) - canRequestInstalls && hasSpecialPrivilege - } - - return canSilently - } catch (e: Throwable) { - Log.w(TAG, "canInstallSilently check failed", e) - return false - } - } - - private fun hasUnknownSourcesPermission(): Boolean { - return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.packageManager.canRequestPackageInstalls() - } else true - } catch (_: Throwable) { false } - } - - private fun requestInstallPermission(result: Result) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (!context.packageManager.canRequestPackageInstalls()) { - try { - val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { - data = Uri.parse("package:${context.packageName}") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - result.success(mapOf( - "success" to true, - "message" to "Permission request launched" - )) - } catch (e: Exception) { - result.success(mapOf( - "success" to false, - "message" to "Failed to launch permission request: ${e.message}" - )) - } - } else { - result.success(mapOf( - "success" to true, - "message" to "Permission already granted" - )) } + verificationThreads[packageName] = t + t.start() } else { - result.success(mapOf( - "success" to true, - "message" to "Permission not required on this Android version" - )) + startInstallSession(file, packageName, result) } } - - /** - * Result of APK verification (for background thread use) - */ + + private fun startInstallSession(apkFile: File, packageName: String, result: Result?) { + try { + val userManager = context.getSystemService(UserManager::class.java) + val installBlocked = userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) || + userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) + + if (installBlocked) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Installation blocked by device policy", ErrorCode.BLOCKED) + result?.success(mapOf("started" to false, "error" to "Blocked by policy", "errorCode" to ErrorCode.BLOCKED)) + return + } + + val packageInstaller = context.packageManager.packageInstaller + val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + if (pkgInfo == null) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Invalid APK file", ErrorCode.INVALID_FILE) + result?.success(mapOf("started" to false, "error" to "Invalid APK", "errorCode" to ErrorCode.INVALID_FILE)) + return + } + + // CRITICAL: Use the passed packageName for tracking, not the APK's internal package name + // This ensures Dart's operation key matches the events we emit + // The APK's packageName should match, but we log a warning if not + val trackingPackageName = packageName + if (pkgInfo.packageName != packageName) { + Log.w(TAG, "Package name mismatch: tracking='$packageName', APK contains='${pkgInfo.packageName}'. Using tracking name for events.") + } + + val isUpdate = try { + context.packageManager.getPackageInfo(pkgInfo.packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { false } + + abandonExistingSession(trackingPackageName) + + val sessionParams = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ).apply { + // Set app package name for proper installer tracking + setAppPackageName(pkgInfo.packageName) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + // Explicitly set ourselves as the installer for update ownership + setInstallerPackageName(context.packageName) + } + setInstallLocation(pkgInfo.installLocation) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setRequestUpdateOwnership(true) + } + } + + val sessionId = packageInstaller.createSession(sessionParams) + val session = packageInstaller.openSession(sessionId) + // Use tracking name so events match Dart's operation key + sessionToPackage[sessionId] = trackingPackageName + + FileInputStream(apkFile).use { fis -> + val fileSize = Os.fstat(fis.fd).st_size + val sessionStream = session.openWrite(apkFile.name, 0, fileSize) + fis.copyTo(sessionStream) + fis.close() + session.fsync(sessionStream) + sessionStream.close() + } + + val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { + putExtra("sessionId", sessionId) + // Use tracking name so events match Dart's operation key + putExtra("packageName", trackingPackageName) + putExtra("isUpdate", isUpdate) + } + val pendingIntent = PendingIntent.getBroadcast( + context.applicationContext, sessionId, intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + session.commit(pendingIntent.intentSender) + session.close() + + emitInstallStatus(trackingPackageName, InstallStatus.STARTED) + result?.success(mapOf("started" to true, "sessionId" to sessionId, "packageName" to trackingPackageName)) + + } catch (e: SecurityException) { + emitInstallStatus(packageName, InstallStatus.FAILED, "Permission denied: ${e.message}", ErrorCode.PERMISSION_DENIED) + result?.success(mapOf("started" to false, "error" to "Permission denied", "errorCode" to ErrorCode.PERMISSION_DENIED)) + } catch (e: Exception) { + emitInstallStatus(packageName, InstallStatus.FAILED, e.message ?: "Installation failed", ErrorCode.INSTALL_FAILED) + result?.success(mapOf("started" to false, "error" to e.message, "errorCode" to ErrorCode.INSTALL_FAILED)) + } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // SESSION MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun hasActiveSession(packageName: String): Boolean { + return sessionToPackage.containsValue(packageName) || + findExistingSession(packageName) != null + } + + private fun findExistingSession(packageName: String): PackageInstaller.SessionInfo? { + return try { + context.packageManager.packageInstaller.mySessions + .find { it.appPackageName == packageName } + } catch (_: Exception) { null } + } + + private fun abandonExistingSession(packageName: String) { + val session = findExistingSession(packageName) ?: return + try { + context.packageManager.packageInstaller.abandonSession(session.sessionId) + sessionToPackage.remove(session.sessionId) + pendingUserActionIntents.remove(packageName) + } catch (_: Exception) {} + } + + private fun cleanupStaleSessions() { + try { + val installer = context.packageManager.packageInstaller + installer.mySessions.filter { !it.isActive }.forEach { session -> + try { installer.abandonSession(session.sessionId) } catch (_: Exception) {} + } + sessionToPackage.clear() + pendingUserActionIntents.clear() + } catch (_: Exception) {} + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // APK VERIFICATION + // ═══════════════════════════════════════════════════════════════════════════════ + private data class VerificationResult( - val isSuccess: Boolean, - val errorMessage: String = "" + val isSuccess: Boolean, + val errorMessage: String = "", + val errorCode: String? = null ) - /** - * Verify APK on background thread - returns result instead of using Flutter Result - */ - private fun verifyApkBackground( - apkFile: File, - expectedHash: String, - expectedSize: Long - ): VerificationResult { - val actualSize = apkFile.length() - - // First, verify this is a valid APK/ZIP file - if (!isValidApk(apkFile)) { - val errorMsg = """ - Invalid APK file. The downloaded file is not a valid Android package. - This may indicate: - • Incomplete download - • Corrupted file - • Wrong file type - - File size: ${String.format("%.2f", actualSize / 1024.0 / 1024.0)} MB ($actualSize bytes) - Please try downloading again. - """.trimIndent() - - apkFile.delete() - return VerificationResult(false, errorMsg) + private fun verifyApk(file: File, expectedHash: String, expectedSize: Long): VerificationResult { + if (!isValidApkFormat(file)) { + file.delete() + return VerificationResult(false, "Invalid APK file format", ErrorCode.INVALID_FILE) } - Log.d(TAG, "APK file format validation passed") - - // Calculate SHA-256 hash for verification val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(8192) - FileInputStream(apkFile).use { fis -> + FileInputStream(file).use { fis -> + val buffer = ByteArray(8192) var bytesRead: Int while (fis.read(buffer).also { bytesRead = it } != -1) { digest.update(buffer, 0, bytesRead) @@ -488,335 +668,156 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { val actualHash = digest.digest().joinToString("") { "%02x".format(it) } if (actualHash.lowercase() != expectedHash.lowercase()) { - val expectedSizeMb = String.format("%.2f", expectedSize / 1024.0 / 1024.0) - val actualSizeMb = String.format("%.2f", actualSize / 1024.0 / 1024.0) - val sizeDiff = actualSize - expectedSize - val sizeDiffMb = String.format("%.2f", kotlin.math.abs(sizeDiff) / 1024.0 / 1024.0) - val sizeDiffSign = if (sizeDiff > 0) "+" else "" - - val errorMsg = """ - Hash verification failed. File may be corrupted or tampered. - Expected hash: $expectedHash - Actual hash: $actualHash - Expected size: $expectedSizeMb MB ($expectedSize bytes) - Actual size: $actualSizeMb MB ($actualSize bytes) - Difference: $sizeDiffSign$sizeDiffMb MB ($sizeDiffSign$sizeDiff bytes) - """.trimIndent() - - return VerificationResult(false, errorMsg) + return VerificationResult( + false, + "Hash verification failed. Expected: $expectedHash, Actual: $actualHash", + ErrorCode.HASH_MISMATCH + ) } - - Log.d(TAG, "APK verification successful: size=$actualSize bytes (expected $expectedSize), hash=$actualHash") return VerificationResult(true) } - - private fun isValidApk(apkFile: File): Boolean { - // Check if file is a valid ZIP/APK by verifying ZIP magic bytes - // APK files are ZIP archives, so they should start with PK\x03\x04 (0x504B0304) - try { - FileInputStream(apkFile).use { fis -> - val magicBytes = ByteArray(4) - val bytesRead = fis.read(magicBytes) - if (bytesRead < 4) { - Log.w(TAG, "File too small to be a valid APK: ${apkFile.length()} bytes") - return false - } - - // Check for ZIP magic number: 0x504B0304 (PK\x03\x04) - val isZip = magicBytes[0] == 0x50.toByte() && - magicBytes[1] == 0x4B.toByte() && - magicBytes[2] == 0x03.toByte() && - magicBytes[3] == 0x04.toByte() - - if (!isZip) { - Log.w(TAG, "File does not have valid ZIP/APK magic bytes") - return false - } - - return true + + private fun isValidApkFormat(file: File): Boolean { + return try { + FileInputStream(file).use { fis -> + val magic = ByteArray(4) + if (fis.read(magic) < 4) return false + magic[0] == 0x50.toByte() && magic[1] == 0x4B.toByte() && + magic[2] == 0x03.toByte() && magic[3] == 0x04.toByte() } - } catch (e: Exception) { - Log.e(TAG, "Error checking APK validity", e) + } catch (_: Exception) { false } + } + + // ═══════════════════════════════════════════════════════════════════════════════ + // PERMISSIONS + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun canInstallSilently(packageName: String? = null): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + Log.d(TAG, "canInstallSilently($packageName): false - API < 31") return false } + + val canRequest = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else true + } catch (_: Throwable) { false } + + if (!canRequest) { + Log.d(TAG, "canInstallSilently($packageName): false - no install permission") + return false + } + + if (packageName != null) { + return try { + val installSource = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager.getInstallSourceInfo(packageName) + } else null + + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) { + installSource.installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(packageName) + } + + val ourPackage = context.packageName + val result = installer == ourPackage + + Log.d(TAG, "canInstallSilently($packageName): installer=$installer, us=$ourPackage, result=$result") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && installSource != null) { + Log.d(TAG, " initiatingPackage=${installSource.initiatingPackageName}") + Log.d(TAG, " originatingPackage=${installSource.originatingPackageName}") + } + + result + } catch (_: PackageManager.NameNotFoundException) { + Log.d(TAG, "canInstallSilently($packageName): false - package not found") + false // Package not found = can't silently install it + } catch (e: Exception) { + Log.d(TAG, "canInstallSilently($packageName): false - error: ${e.message}") + false + } + } + + // Check general capability (no specific package) + val hasPermission = try { + context.checkSelfPermission("android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION") == + PackageManager.PERMISSION_GRANTED + } catch (_: Throwable) { false } + + Log.d(TAG, "canInstallSilently(general): hasUpdatePermission=$hasPermission") + return hasPermission } - private fun installApk( - filePath: String, - expectedHash: String?, - expectedSize: Long?, - skipVerification: Boolean, - result: Result - ) { - val file = File(filePath) - if (!file.exists()) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "The APK file was not found" - )) - return - } - - // Verify APK if hash and size are provided, unless skipping verification - if (skipVerification) { - Log.w(TAG, "⚠️ RECKLESS MODE: APK verification SKIPPED by user request ⚠️") - Log.w(TAG, "Installing APK without hash verification: $filePath") - proceedWithInstall(file, filePath, result) - } else if (expectedHash != null && expectedSize != null) { - // Run hash verification on background thread to avoid UI jank - Thread { - val verificationResult = verifyApkBackground(file, expectedHash, expectedSize) - android.os.Handler(context.mainLooper).post { - if (verificationResult.isSuccess) { - proceedWithInstall(file, filePath, result) - } else { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to verificationResult.errorMessage - )) - } + private fun hasUnknownSourcesPermission(): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.packageManager.canRequestPackageInstalls() + } else true + } catch (_: Throwable) { false } + } + + private fun requestInstallPermission(result: Result) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls()) { + try { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - }.start() + context.startActivity(intent) + result.success(mapOf("success" to true)) + } catch (e: Exception) { + result.success(mapOf("success" to false, "message" to e.message)) + } } else { - Log.w(TAG, "APK verification skipped - hash or size not provided") - proceedWithInstall(file, filePath, result) + result.success(mapOf("success" to true)) } } - private fun proceedWithInstall(file: File, filePath: String, result: Result) { - try { - installApp(file, result) - } catch (e: SecurityException) { - // Fallback to system installer - launchSystemInstaller(filePath, result) - } catch (e: Exception) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Installation failed: ${e.message}" - )) - } - } - - private fun installApp(apkFile: File, result: Result) { - // Check UserManager restrictions (like Accrescent does) - val userManager = context.getSystemService(UserManager::class.java) - val installBlocked = - userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_APPS) || - userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES) || - userManager.hasUserRestriction(UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY) - - if (installBlocked) { - Log.w(TAG, "Installation blocked by UserManager policy") - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Installation blocked by device policy" - )) - return - } - - val packageInstaller = context.packageManager.packageInstaller - - // Get package info from the APK - val pkgInfo = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) - if (pkgInfo == null) { - throw IOException("Invalid APK file") - } - - val packageName = pkgInfo.packageName - val isUpdate = try { - context.packageManager.getPackageInfo(packageName, 0) - true - } catch (e: PackageManager.NameNotFoundException) { - false - } - - // CRITICAL: Check if we're already waiting for a result for this package - // This prevents double-prompts and "session files in use" errors - if (hasPendingResultForPackage(packageName)) { - Log.d(TAG, "Already have pending result for $packageName") - - // Only re-show the dialog if the prompt was actually deferred. - // If the prompt was already shown in the foreground, re-launching it causes double prompts. - val pendingIntent = pendingUserActionIntents[packageName] - val deferred = pendingUserActionDeferred[packageName] == true - if (pendingIntent != null && deferred && isAppInForeground()) { - Log.d(TAG, "Re-launching deferred dialog for $packageName") - try { - pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - context.startActivity(pendingIntent) - pendingUserActionDeferred[packageName] = false - } catch (e: Exception) { - Log.w(TAG, "Failed to re-launch dialog", e) - } - } - - // Tell Dart that installation is already in progress - don't start a new one - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Installation already in progress", - "packageName" to packageName, - "alreadyInProgress" to true - )) - return - } - - // Check for orphaned system session (no pending result, but session exists) - val existingSession = findExistingSession(packageName) - if (existingSession != null) { - Log.d(TAG, "Found orphaned session ${existingSession.sessionId} for $packageName, abandoning it") - abandonExistingSession(packageName) - } - - Log.d(TAG, "Installing $packageName (update=$isUpdate)") - - // Match Accrescent's session setup exactly - // The APP_MARKET category in the manifest grants silent install privileges - val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - sessionParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) - } - sessionParams.setInstallLocation(pkgInfo.installLocation) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - sessionParams.setPackageSource(PackageInstaller.PACKAGE_SOURCE_STORE) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - sessionParams.setRequestUpdateOwnership(true) - } - - val sessionId = packageInstaller.createSession(sessionParams) - val session = packageInstaller.openSession(sessionId) - - Log.d(TAG, "Created new session $sessionId for $packageName") - - // Transfer the APK file - match Accrescent's approach exactly - FileInputStream(apkFile).use { fileInputStream -> - // Use Os.fstat for accurate file size (like Accrescent) - val fileDescriptor = fileInputStream.fd - val fileSize = Os.fstat(fileDescriptor).st_size - - // Use APK file name instead of generic "package" - val sessionStream = session.openWrite(apkFile.name, 0, fileSize) - - fileInputStream.copyTo(sessionStream) - - // Close in Accrescent's order: file first, then session - fileInputStream.close() - session.fsync(sessionStream) - sessionStream.close() - } - - // Store pending result BEFORE committing - will be completed by InstallResultReceiver - pendingInstallResults[sessionId] = result - sessionToPackage[sessionId] = packageName - Log.d(TAG, "Stored pending result for session $sessionId ($packageName), awaiting broadcast...") - - // Create pending intent for installation result - val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { - putExtra("sessionId", sessionId) - putExtra("packageName", packageName) - putExtra("isUpdate", isUpdate) - } - val pendingIntentForBroadcast = PendingIntent.getBroadcast( - context.applicationContext, - sessionId, // Use sessionId as requestCode for uniqueness - intent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - // Commit the session - result will be delivered via broadcast to InstallResultReceiver - // which will call completeInstallResult() to finish the method channel call - session.commit(pendingIntentForBroadcast.intentSender) - session.close() - - // DO NOT call result.success() here - wait for InstallResultReceiver broadcast - // The Dart side will block on await until completeInstallResult() is called - } + // ═══════════════════════════════════════════════════════════════════════════════ + // UNINSTALL + // ═══════════════════════════════════════════════════════════════════════════════ - private fun launchSystemInstaller(filePath: String, result: Result) { - try { - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(Uri.parse("file://$filePath"), "application/vnd.android.package-archive") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - - context.startActivity(intent) - - result.success(mapOf( - "isSuccess" to true, - "errorMessage" to "", - "fallbackMethod" to "system_installer" - )) - } catch (e: Exception) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Failed to launch installer: ${e.message}" - )) - } - } - private fun uninstallApp(packageName: String, result: Result) { try { - // Check if the package is installed - val pm = context.packageManager try { - pm.getPackageInfo(packageName, 0) - } catch (e: PackageManager.NameNotFoundException) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Package not found: $packageName" - )) + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + result.success(mapOf("isSuccess" to false, "errorMessage" to "Package not found")) return } - // Launch system uninstaller val intent = Intent(Intent.ACTION_DELETE).apply { data = Uri.parse("package:$packageName") addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - context.startActivity(intent) - // Poll for package removal since ACTION_DELETE has no callback - // This runs on a background thread to avoid blocking the main thread + // Poll for removal with 30 second timeout Thread { - val timeoutMs = 120_000L // 2 minute timeout for user to confirm - val pollIntervalMs = 500L val startTime = System.currentTimeMillis() - while (System.currentTimeMillis() - startTime < timeoutMs) { + while (System.currentTimeMillis() - startTime < UNINSTALL_TIMEOUT_MS) { try { - Thread.sleep(pollIntervalMs) - - // Check if package is still installed - try { - pm.getPackageInfo(packageName, 0) - // Package still exists, keep polling - } catch (e: PackageManager.NameNotFoundException) { - // Package removed! Success - Log.d(TAG, "Package $packageName successfully uninstalled") - android.os.Handler(context.mainLooper).post { - result.success(mapOf( - "isSuccess" to true, - "packageName" to packageName - )) - } - return@Thread + Thread.sleep(UNINSTALL_POLL_INTERVAL_MS) + context.packageManager.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + mainHandler.post { + result.success(mapOf("isSuccess" to true, "packageName" to packageName)) } - } catch (e: InterruptedException) { - break - } + return@Thread + } catch (_: InterruptedException) { break } } - // Timeout - check one more time then treat as cancelled val stillInstalled = try { - pm.getPackageInfo(packageName, 0) + context.packageManager.getPackageInfo(packageName, 0) true - } catch (e: PackageManager.NameNotFoundException) { - false - } + } catch (_: PackageManager.NameNotFoundException) { false } - android.os.Handler(context.mainLooper).post { + mainHandler.post { result.success(mapOf( "isSuccess" to !stillInstalled, "packageName" to packageName, @@ -825,210 +826,84 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { } }.start() - // DO NOT call result.success() here - wait for polling to complete - } catch (e: Exception) { - Log.e(TAG, "Uninstall failed for $packageName", e) - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Uninstall failed: ${e.message}" - )) - } - } - - private fun launchApp(packageName: String, result: Result) { - try { - val pm = context.packageManager - - // Check if the package is installed - try { - pm.getPackageInfo(packageName, 0) - } catch (e: PackageManager.NameNotFoundException) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Package not found: $packageName" - )) - return - } - - // Get the launch intent for the package - val launchIntent = pm.getLaunchIntentForPackage(packageName) - if (launchIntent == null) { - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "No launch activity found for package: $packageName" - )) - return - } - - // Add flags to ensure the app launches properly - launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - - // Launch the app - context.startActivity(launchIntent) - - result.success(mapOf( - "isSuccess" to true, - "errorMessage" to "", - "packageName" to packageName - )) - - } catch (e: Exception) { - Log.e(TAG, "Launch failed for $packageName", e) - result.success(mapOf( - "isSuccess" to false, - "errorMessage" to "Launch failed: ${e.message}" - )) + result.success(mapOf("isSuccess" to false, "errorMessage" to e.message)) } } - /** - * Launch a pending install prompt when app returns to foreground. - * Called from Dart after setAppForegroundState(true). - * - * Returns success if: - * - There's a pending user action intent (stored when app was backgrounded) - * - OR there's an active session in the system - */ - private fun retryPendingInstall(packageName: String, result: Result) { - // First check if we have a stored user action intent - val pendingIntent = pendingUserActionIntents[packageName] - if (pendingIntent != null) { - val deferred = pendingUserActionDeferred[packageName] == true - if (!deferred) { - // Prompt was already shown while app was in foreground. - // Do not re-launch it on resume (prevents double system dialogs). - result.success(mapOf( - "isSuccess" to false, - "hasPending" to true, - "relaunched" to false, - "promptAlreadyShown" to true, - "packageName" to packageName - )) + // ═══════════════════════════════════════════════════════════════════════════════ + // APP LAUNCH + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun launchApp(packageName: String, result: Result) { + try { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent == null) { + result.success(mapOf("isSuccess" to false, "errorMessage" to "No launch activity")) return } - - Log.d(TAG, "Re-launching deferred user action for $packageName") - try { - pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - context.startActivity(pendingIntent) - pendingUserActionDeferred[packageName] = false - result.success(mapOf( - "isSuccess" to true, - "hasPending" to true, - "relaunched" to true, - "promptAlreadyShown" to false, - "packageName" to packageName - )) - return - } catch (e: Exception) { - Log.e(TAG, "Failed to launch install prompt for $packageName", e) - // Clear the stale intent - pendingUserActionIntents.remove(packageName) - pendingUserActionDeferred.remove(packageName) - } - } - - // Check if there's a system session waiting - val existingSession = findExistingSession(packageName) - if (existingSession != null) { - Log.d(TAG, "Found system session ${existingSession.sessionId} for $packageName but no user action intent") - // Session exists but we don't have the intent - user needs to re-trigger install - result.success(mapOf( - "isSuccess" to false, - "hasPending" to true, - "sessionPending" to true, - "relaunched" to false, - "promptAlreadyShown" to false, - "packageName" to packageName - )) - } else { - // No pending intent and no active session - result.success(mapOf( - "isSuccess" to false, - "hasPending" to false, - "relaunched" to false, - "promptAlreadyShown" to false, - "packageName" to packageName - )) + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(launchIntent) + result.success(mapOf("isSuccess" to true)) + } catch (e: Exception) { + result.success(mapOf("isSuccess" to false, "errorMessage" to e.message)) } } - + + // ═══════════════════════════════════════════════════════════════════════════════ + // INSTALLED APPS + // ═══════════════════════════════════════════════════════════════════════════════ + private fun getInstalledApps(includeSystemApps: Boolean): List> { val pm = context.packageManager - val packages = pm.getInstalledPackages(0) - val out = mutableListOf>() - - for (pkg in packages) { - val appInfo = pkg.applicationInfo ?: continue + return pm.getInstalledPackages(0).mapNotNull { pkg -> + val appInfo = pkg.applicationInfo ?: return@mapNotNull null val isSystem = (appInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 - if (!includeSystemApps && isSystem) continue + if (!includeSystemApps && isSystem) return@mapNotNull null - val name = appInfo.loadLabel(pm)?.toString() - val bundleId = pkg.packageName - val versionName = pkg.versionName - val versionCode: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { pkg.longVersionCode } else { @Suppress("DEPRECATION") pkg.versionCode.toLong() } - // Get signature hash - val signatureHash = try { - getSignatureHash(bundleId) - } catch (e: Exception) { - "" - } - - // Check if we can silently update this package (we are the installer) - val canSilentlyUpdate = canInstallSilently(bundleId) - - out.add(mapOf( - "name" to name, - "bundleId" to bundleId, - "versionName" to versionName, + mapOf( + "name" to appInfo.loadLabel(pm)?.toString(), + "bundleId" to pkg.packageName, + "versionName" to pkg.versionName, "versionCode" to versionCode, - "signatureHash" to signatureHash, - "canInstallSilently" to canSilentlyUpdate, - )) + "signatureHash" to getSignatureHash(pkg.packageName), + "canInstallSilently" to canInstallSilently(pkg.packageName) + ) } - return out } private fun getSignatureHash(packageName: String): String { - val pm = context.packageManager - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - pm.getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()) - ) - } else { - @Suppress("DEPRECATION") - pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) - } - - val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - packageInfo.signingInfo?.let { signingInfo -> - if (signingInfo.hasMultipleSigners()) { - signingInfo.apkContentsSigners - } else { - signingInfo.signingCertificateHistory - } - } ?: emptyArray() - } else { - @Suppress("DEPRECATION") - packageInfo.signatures ?: emptyArray() - } - - if (signatures.isEmpty()) return "" - - // Get SHA-256 hash of first signature - val signature = signatures[0] - val digest = MessageDigest.getInstance("SHA-256") - val hashBytes = digest.digest(signature.toByteArray()) - return hashBytes.joinToString("") { "%02x".format(it) } + return try { + val pm = context.packageManager + val pkgInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of( + PackageManager.GET_SIGNING_CERTIFICATES.toLong())) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + } + + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkgInfo.signingInfo?.let { si -> + if (si.hasMultipleSigners()) si.apkContentsSigners else si.signingCertificateHistory + } ?: emptyArray() + } else { + @Suppress("DEPRECATION") + pkgInfo.signatures ?: emptyArray() + } + + if (signatures.isEmpty()) return "" + + MessageDigest.getInstance("SHA-256") + .digest(signatures[0].toByteArray()) + .joinToString("") { "%02x".format(it) } + } catch (_: Exception) { "" } } - -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt index 0136719..fe43ec1 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt @@ -1,30 +1,22 @@ package dev.zapstore.alpha.plugins -import android.app.NotificationManager -import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build import android.util.Log -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService private const val TAG = "InstallResultReceiver" /** - * BroadcastReceiver to handle APK installation results. + * BroadcastReceiver for APK installation results. * - * This receiver completes the pending method channel Result stored in - * AndroidPackageManagerPlugin, allowing Dart's await to finish. + * Translates Android PackageInstaller status codes to simple status events + * that are emitted to Dart via EventChannel. * - * For STATUS_PENDING_USER_ACTION (installer takeover prompt): - * - If app is in foreground: launch confirmation dialog immediately - * - If app is backgrounded: store intent for later launch via retryPendingInstall - * - * We DON'T complete the Result for pending user action - we wait for the - * actual success/failure that follows after user confirms/cancels. + * This receiver is stateless - it simply maps broadcasts to events. + * All state management happens in AndroidPackageManagerPlugin. */ class InstallResultReceiver : BroadcastReceiver() { @@ -32,174 +24,154 @@ class InstallResultReceiver : BroadcastReceiver() { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) ?: intent.getStringExtra("packageName") - ?: "unknown" + ?: "" val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) val isUpdate = intent.getBooleanExtra("isUpdate", false) - - Log.d(TAG, "Install result: status=$status, package=$packageName, sessionId=$sessionId, message=$message") - + + Log.d(TAG, "Received: status=$status, package=$packageName, session=$sessionId, msg=$message") + when (status) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { - Log.d(TAG, "User confirmation required for $packageName (installer takeover)") + // User confirmation required - extract the dialog intent + val confirmIntent = extractConfirmIntent(intent) - // Extract confirmation dialog intent - val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) - } else { - @Suppress("DEPRECATION") - intent.getParcelableExtra(Intent.EXTRA_INTENT) - } - if (confirmIntent != null) { - // Always store the intent for potential re-launch from retryPendingInstall - AndroidPackageManagerPlugin.storePendingUserActionIntent(packageName, confirmIntent) - - // Only launch dialog if app is in foreground - if (AndroidPackageManagerPlugin.isAppInForeground()) { - confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - try { - context.startActivity(confirmIntent) - // Prompt was shown while in foreground: do NOT re-launch on resume. - AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, false) - } catch (e: Exception) { - Log.w(TAG, "Failed to launch confirmation dialog", e) - // Launch failed -> treat as deferred so it can be re-launched later. - AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true) - showUserActionNotification(context, sessionId, confirmIntent, packageName) - } - } else { - Log.d(TAG, "App backgrounded, deferring user action for $packageName") - AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true) - } - } - - // DON'T complete the Result here - wait for the actual success/failure - // that will come after user confirms or cancels the dialog. - // The same sessionId will be used for the final result. + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.PENDING_USER_ACTION, + packageName = packageName, + isUpdate = isUpdate, + confirmIntent = confirmIntent + ) } PackageInstaller.STATUS_SUCCESS -> { - Log.d(TAG, "Installation successful: $packageName (sessionId=$sessionId)") - // Complete the pending method channel result - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to true, - "errorMessage" to "", - "packageName" to packageName, - "isUpdate" to isUpdate - )) - } - - PackageInstaller.STATUS_FAILURE -> { - Log.w(TAG, "Installation failed: $packageName - $message (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Installation failed"), - "packageName" to packageName - ), context) + Log.d(TAG, "Installation successful: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.SUCCESS, + packageName = packageName, + isUpdate = isUpdate + ) } PackageInstaller.STATUS_FAILURE_ABORTED -> { - Log.d(TAG, "Installation cancelled: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to "Installation was cancelled by user", - "packageName" to packageName, - "cancelled" to true - ), context) + Log.d(TAG, "Installation cancelled: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.CANCELLED, + packageName = packageName, + message = "Installation was cancelled" + ) + } + + PackageInstaller.STATUS_FAILURE -> { + Log.w(TAG, "Installation failed: $packageName - $message") + // Try to determine error code from message content + val errorCode = categorizeFailure(message) + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation failed", + errorCode = errorCode + ) } PackageInstaller.STATUS_FAILURE_BLOCKED -> { - Log.w(TAG, "Installation blocked: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Installation blocked by device policy"), - "packageName" to packageName - ), context) + Log.w(TAG, "Installation blocked: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation blocked by device policy", + errorCode = ErrorCode.BLOCKED + ) } PackageInstaller.STATUS_FAILURE_CONFLICT -> { - Log.w(TAG, "Installation conflict: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Installation conflicts with existing package"), - "packageName" to packageName - ), context) + Log.w(TAG, "Installation conflict: $packageName") + // Conflict typically means signature mismatch + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation conflicts with existing package", + errorCode = ErrorCode.CERT_MISMATCH + ) } PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> { - Log.w(TAG, "Installation incompatible: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Package is incompatible with this device"), - "packageName" to packageName - ), context) + Log.w(TAG, "Incompatible package: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Package is incompatible with this device", + errorCode = ErrorCode.INCOMPATIBLE + ) } PackageInstaller.STATUS_FAILURE_INVALID -> { - Log.w(TAG, "Installation invalid: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Invalid installation package"), - "packageName" to packageName - ), context) + Log.w(TAG, "Invalid package: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Invalid installation package", + errorCode = ErrorCode.INVALID_FILE + ) } PackageInstaller.STATUS_FAILURE_STORAGE -> { - Log.w(TAG, "Installation failed - storage: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Insufficient storage space"), - "packageName" to packageName - ), context) + Log.w(TAG, "Storage failure: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Insufficient storage space", + errorCode = ErrorCode.INSUFFICIENT_STORAGE + ) } else -> { - Log.w(TAG, "Unknown installation status $status: $packageName (sessionId=$sessionId)") - AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( - "isSuccess" to false, - "errorMessage" to (message ?: "Installation failed with status $status"), - "packageName" to packageName - ), context) + Log.w(TAG, "Unknown status $status: $packageName") + AndroidPackageManagerPlugin.onInstallResult( + sessionId = sessionId, + status = InstallStatus.FAILED, + packageName = packageName, + message = message ?: "Installation failed (status: $status)", + errorCode = ErrorCode.INSTALL_FAILED + ) } } } - private fun showUserActionNotification( - context: Context, - sessionId: Int, - confirmIntent: Intent, - packageName: String - ) { - // Check notification permission on Android 13+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) - != android.content.pm.PackageManager.PERMISSION_GRANTED) { - return - } + /** + * Categorize a generic failure message to determine the error code. + */ + private fun categorizeFailure(message: String?): String { + if (message == null) return ErrorCode.INSTALL_FAILED + val lower = message.lowercase() + return when { + lower.contains("signature") || + lower.contains("certificate") || + lower.contains("update_incompatible") -> ErrorCode.CERT_MISMATCH + lower.contains("storage") || lower.contains("space") -> ErrorCode.INSUFFICIENT_STORAGE + lower.contains("permission") || lower.contains("denied") -> ErrorCode.PERMISSION_DENIED + else -> ErrorCode.INSTALL_FAILED } - - val notificationManager = context.getSystemService() - if (notificationManager?.areNotificationsEnabled() == true) { - val pendingIntent = PendingIntent.getActivity( - context, - sessionId, - confirmIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT - ) - - val notification = NotificationCompat.Builder(context, "install_channel") - .setSmallIcon(android.R.drawable.ic_dialog_info) - .setContentTitle("Installation requires confirmation") - .setContentText("Tap to continue installing $packageName") - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .build() - - try { - notificationManager.notify(sessionId, notification) - } catch (e: Exception) { - Log.w(TAG, "Failed to show notification", e) - } + } + + /** + * Extract the confirmation dialog intent from a PENDING_USER_ACTION broadcast. + */ + private fun extractConfirmIntent(intent: Intent): Intent? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) } } } diff --git a/lib/main.dart b/lib/main.dart index 2937c41..7a752af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,7 +12,6 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; -import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/theme.dart'; @@ -272,36 +271,49 @@ Future onSignInSuccess(Ref ref) async { ); } -/// Observes app lifecycle events and re-syncs package state when app regains focus +/// Observes app lifecycle events and manages package/storage state class _AppLifecycleObserver with WidgetsBindingObserver { _AppLifecycleObserver(this._ref); final WidgetRef _ref; + /// Handle permission grants that happened while app was backgrounded + Future _checkPermissionGrants() async { + final packageManager = _ref.read(packageManagerProvider.notifier); + if (packageManager is! AndroidPackageManager) return; + + final hasPermission = await packageManager.hasPermission(); + if (!hasPermission) return; + + // Advance any operations waiting for permission + final state = _ref.read(packageManagerProvider); + final awaitingPermission = state.operations.entries + .where((e) => e.value is AwaitingPermission) + .map((e) => e.key) + .toList(growable: false); + + for (final appId in awaitingPermission) { + await packageManager.onPermissionGranted(appId); + } + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { - final downloadService = _ref.read(downloadServiceProvider.notifier); final notifier = _ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; + final packageManager = _ref.read(packageManagerProvider.notifier); if (state == AppLifecycleState.resumed) { - // Process any pending installations - unawaited(downloadService.setAppForeground(true)); + // Sync installed packages to detect installs that completed while backgrounded + unawaited(packageManager.syncInstalledPackages()); - // Re-sync installed packages from Android system - unawaited( - _ref.read(packageManagerProvider.notifier).syncInstalledPackages(), - ); - // Note: UpdateNotifier automatically triggers checkForUpdates() when - // packageManagerProvider state changes, so we don't need to call it explicitly + // Check for permission grants that happened in settings + unawaited(_checkPermissionGrants()); - // Force immediate health check on storage/relay connections - // This detects stale connections after system sleep and triggers reconnection + // Reconnect storage/relay connections notifier.connect(); } else if (state == AppLifecycleState.paused) { - // Note: paused corresponds to Android's `onStop` - unawaited(downloadService.setAppForeground(false)); notifier.disconnect(); } } diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index e4b7091..7de82a3 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -140,10 +140,9 @@ class _AppDetailContent extends HookConsumerWidget { final latestMetadata = app.latestFileMetadata; // Check if app is installed for menu options - final installedPackage = ref - .watch(packageManagerProvider) - .where((p) => p.appId == app.identifier) - .firstOrNull; + final installedPackage = ref.watch( + installedPackageProvider(app.identifier), + ); final isInstalled = installedPackage != null; // Show skeleton while relationships are loading @@ -634,7 +633,9 @@ class _AppDetailContent extends HookConsumerWidget { // Save to local storage and publish to relays await ref.storage.save({signedStack}); - ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social')); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); if (context.mounted) { context.showInfo( @@ -675,7 +676,6 @@ class _AppDetailContent extends HookConsumerWidget { Future _openApp(BuildContext context, WidgetRef ref, App app) async { try { final packageManager = ref.read(packageManagerProvider.notifier); - context.showInfo('Launching ${app.name ?? app.identifier}...'); await packageManager.launchApp(app.identifier); } catch (e) { if (!context.mounted) return; diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 7c8988b..6a4c417 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -218,10 +218,8 @@ class _AuthenticationSection extends ConsumerWidget { } Widget _buildSignInOptions(BuildContext context, WidgetRef ref) { - final packageManager = ref.watch(packageManagerProvider); - final isAmberInstalled = packageManager.any( - (p) => p.appId == kAmberPackageId, - ); + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1248,23 +1246,18 @@ class _SavedAppsList extends ConsumerWidget { class _AboutSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final manager = ref.watch(packageManagerProvider); - final zsPackage = manager.firstWhereOrNull( - (i) => i.appId == kZapstoreAppIdentifier, - ); + final pmState = ref.watch(packageManagerProvider); + final zsPackage = pmState.installed[kZapstoreAppIdentifier]; if (zsPackage == null) { - final isLoading = manager.isEmpty; + final isLoading = pmState.installed.isEmpty; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'About', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('About', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 16), if (isLoading) ...[ Row( @@ -1318,10 +1311,7 @@ class _AboutSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'About', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('About', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 16), ListTile( leading: ClipOval( diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 2e3c991..1886a6d 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -2,7 +2,7 @@ import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/download/download_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -18,9 +18,7 @@ class UpdatesScreen extends HookConsumerWidget { final categorized = ref.watch(categorizedAppsProvider); if (categorized.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( @@ -43,13 +41,16 @@ enum _UpdatesItemType { manualApp, upToDateHeader, upToDateApp, + uncatalogedHeader, + uncatalogedApp, } class _UpdatesItem { final _UpdatesItemType type; final App? app; + final PackageInfo? packageInfo; - const _UpdatesItem(this.type, [this.app]); + const _UpdatesItem(this.type, {this.app, this.packageInfo}); } class _UpdatesContent extends HookConsumerWidget { @@ -62,26 +63,33 @@ class _UpdatesContent extends HookConsumerWidget { final automaticUpdates = categorized.automaticUpdates; final manualUpdates = categorized.manualUpdates; final upToDateApps = categorized.upToDateApps; + final uncatalogedApps = categorized.uncatalogedApps; - final downloads = ref.watch(downloadServiceProvider); - final installingAppIds = downloads.entries - .where((entry) => entry.value.isActiveOrInstalling) + // Watch operations from PackageManager + final operations = ref.watch( + packageManagerProvider.select((s) => s.operations), + ); + final activeAppIds = operations.entries + .where((entry) => entry.value.isActive) .map((entry) => entry.key) .toSet(); - if (installingAppIds.isEmpty) { + + if (activeAppIds.isEmpty) { return _UpdatesListBody( automaticUpdates: automaticUpdates, manualUpdates: manualUpdates, installingApps: const [], upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, ); } return _UpdatesListBodyWithInstallingAppIds( - installingAppIds: installingAppIds, + installingAppIds: activeAppIds, automaticUpdates: automaticUpdates, manualUpdates: manualUpdates, upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, ); } } @@ -92,12 +100,14 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { required this.automaticUpdates, required this.manualUpdates, required this.upToDateApps, + required this.uncatalogedApps, }); final Set installingAppIds; final List automaticUpdates; final List manualUpdates; final List upToDateApps; + final List uncatalogedApps; @override Widget build(BuildContext context, WidgetRef ref) { @@ -128,6 +138,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { manualUpdates: manualUpdates, installingApps: installingApps, upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, ); } } @@ -138,19 +149,22 @@ class _UpdatesListBody extends StatelessWidget { required this.manualUpdates, required this.installingApps, required this.upToDateApps, + required this.uncatalogedApps, }); final List automaticUpdates; final List manualUpdates; final List installingApps; final List upToDateApps; + final List uncatalogedApps; @override Widget build(BuildContext context) { if (automaticUpdates.isEmpty && manualUpdates.isEmpty && installingApps.isEmpty && - upToDateApps.isEmpty) { + upToDateApps.isEmpty && + uncatalogedApps.isEmpty) { final theme = Theme.of(context); return Center( @@ -209,14 +223,14 @@ class _UpdatesListBody extends StatelessWidget { if (installingApps.isNotEmpty) { items.add(const _UpdatesItem(_UpdatesItemType.installingHeader)); for (final app in installingApps) { - items.add(_UpdatesItem(_UpdatesItemType.installingApp, app)); + items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app)); } } if (automaticUpdates.isNotEmpty) { items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader)); for (final app in automaticUpdates) { - items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app)); + items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app: app)); } } @@ -224,18 +238,28 @@ class _UpdatesListBody extends StatelessWidget { items.add(const _UpdatesItem(_UpdatesItemType.manualHeader)); items.add(const _UpdatesItem(_UpdatesItemType.manualInfoBox)); for (final app in manualUpdates) { - items.add(_UpdatesItem(_UpdatesItemType.manualApp, app)); + items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app)); } } if (upToDateApps.isNotEmpty) { items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader)); for (final app in upToDateApps) { - items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app)); + items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app: app)); + } + } + + if (uncatalogedApps.isNotEmpty) { + items.add(const _UpdatesItem(_UpdatesItemType.uncatalogedHeader)); + for (final pkg in uncatalogedApps) { + items.add( + _UpdatesItem(_UpdatesItemType.uncatalogedApp, packageInfo: pkg), + ); } } return ListView.builder( + key: const PageStorageKey('updates_list'), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; @@ -244,6 +268,7 @@ class _UpdatesListBody extends StatelessWidget { return _InstallingHeader(count: installingApps.length); case _UpdatesItemType.installingApp: return AppCard( + key: ValueKey('installing_${item.app?.identifier}'), app: item.app, showUpdateArrow: false, showUpdateButton: true, @@ -259,6 +284,7 @@ class _UpdatesListBody extends StatelessWidget { ); case _UpdatesItemType.automaticApp: return AppCard( + key: ValueKey('automatic_${item.app?.identifier}'), app: item.app, showUpdateArrow: true, showUpdateButton: true, @@ -276,6 +302,7 @@ class _UpdatesListBody extends StatelessWidget { return _ManualUpdatesInfoBox(); case _UpdatesItemType.manualApp: return AppCard( + key: ValueKey('manual_${item.app?.identifier}'), app: item.app, showUpdateArrow: true, showUpdateButton: true, @@ -286,10 +313,18 @@ class _UpdatesListBody extends StatelessWidget { return _UpToDateHeader(count: upToDateApps.length); case _UpdatesItemType.upToDateApp: return AppCard( + key: ValueKey('uptodate_${item.app?.identifier}'), app: item.app, showUpdateArrow: false, showDescription: false, ); + case _UpdatesItemType.uncatalogedHeader: + return _UncatalogedHeader(count: uncatalogedApps.length); + case _UpdatesItemType.uncatalogedApp: + return _UncatalogedAppCard( + key: ValueKey('uncataloged_${item.packageInfo?.appId}'), + packageInfo: item.packageInfo!, + ); } }, ); @@ -309,10 +344,7 @@ class _InstallingHeader extends StatelessWidget { children: [ Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary), const SizedBox(width: 8), - Text( - 'Installing', - style: context.textTheme.titleMedium, - ), + Text('Installing', style: context.textTheme.titleMedium), const SizedBox(width: 8), CountBadge(count: count, color: AppColors.darkPillBackground), ], @@ -350,10 +382,7 @@ class _UpdatesSectionHeader extends ConsumerWidget { Icon(icon, size: 20, color: AppColors.darkActionPrimary), const SizedBox(width: 8), Flexible( - child: Text( - title, - style: context.textTheme.titleMedium, - ), + child: Text(title, style: context.textTheme.titleMedium), ), const SizedBox(width: 8), CountBadge(count: count, color: AppColors.darkPillBackground), @@ -361,19 +390,18 @@ class _UpdatesSectionHeader extends ConsumerWidget { ), AsyncButtonBuilder( onPressed: () async { - final downloadService = ref.read( - downloadServiceProvider.notifier, - ); - for (final app in apps) { - final release = app.latestRelease.value; - if (release != null) { - try { - await downloadService.downloadApp(app, release); - } catch (_) { - // Ignore download start failures per-app to keep others going - } - } - } + final pm = ref.read(packageManagerProvider.notifier); + final items = apps + .where((app) => app.latestFileMetadata != null) + .map( + (app) => ( + appId: app.identifier, + target: app.latestFileMetadata!, + displayName: app.name, + ), + ) + .toList(); + await pm.queueDownloads(items); }, builder: (context, child, callback, buttonState) { const pillBg = AppColors.darkPillBackground; @@ -475,13 +503,14 @@ class _UpToDateHeader extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Row( children: [ - Icon(Icons.check_circle, size: 20, color: AppColors.darkActionPrimary), - const SizedBox(width: 8), - Text( - 'Up to date', - style: context.textTheme.titleMedium, + Icon( + Icons.check_circle, + size: 20, + color: AppColors.darkActionPrimary, ), const SizedBox(width: 8), + Text('Up to date', style: context.textTheme.titleMedium), + const SizedBox(width: 8), CountBadge(count: count, color: AppColors.darkPillBackground), ], ), @@ -489,3 +518,109 @@ class _UpToDateHeader extends StatelessWidget { } } +class _UncatalogedHeader extends StatelessWidget { + const _UncatalogedHeader({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.help_outline, + size: 20, + color: AppColors.darkOnSurfaceSecondary, + ), + const SizedBox(width: 8), + Text('Other installed', style: context.textTheme.titleMedium), + const SizedBox(width: 8), + CountBadge(count: count, color: AppColors.darkPillBackground), + ], + ), + ); + } +} + +class _UncatalogedAppCard extends StatelessWidget { + const _UncatalogedAppCard({super.key, required this.packageInfo}); + + final PackageInfo packageInfo; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), + border: Border.all( + color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + ), + ), + child: Row( + children: [ + // Generic app icon + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + Icons.android, + color: AppColors.darkOnSurfaceSecondary, + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + packageInfo.name ?? packageInfo.appId, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w900, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + packageInfo.appId, + style: context.textTheme.bodySmall?.copyWith( + color: AppColors.darkOnSurfaceSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // Version pill + Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.darkPillBackground, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + packageInfo.version, + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 6700823..f09b9a7 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -169,12 +169,12 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { final packageManager = container.read(packageManagerProvider.notifier); await packageManager.syncInstalledPackages(); - final packages = container.read(packageManagerProvider); - if (packages.isEmpty) { + final pmState = container.read(packageManagerProvider); + if (pmState.installed.isEmpty) { return true; // No installed apps to check } - final installedIds = packages.map((p) => p.appId).toSet(); + final installedIds = pmState.installed.keys.toSet(); // Query for apps with updates from relay final storage = container.read(storageNotifierProvider.notifier); diff --git a/lib/services/download/download_info.dart b/lib/services/download/download_info.dart deleted file mode 100644 index 25de38c..0000000 --- a/lib/services/download/download_info.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:background_downloader/background_downloader.dart'; -import 'package:models/models.dart'; - -/// Stale download threshold - downloads older than this will be cleaned up -const staleDownloadThreshold = Duration(days: 7); - -/// Maximum concurrent downloads allowed -const maxConcurrentDownloads = 3; - -/// Wrapper around DownloadTask with app-specific metadata -class DownloadInfo { - const DownloadInfo({ - required this.appId, - required this.task, - required this.fileMetadata, - this.status = TaskStatus.enqueued, - this.progress = 0.0, - this.isInstalling = false, - this.isReadyToInstall = false, - this.skipVerificationOnInstall = false, - this.errorDetails, - }); - - final String appId; - final DownloadTask task; - final FileMetadata fileMetadata; - final TaskStatus status; - final double progress; - final bool isInstalling; - final bool isReadyToInstall; - final bool skipVerificationOnInstall; - final String? errorDetails; - - String get taskId => task.taskId; - String get fileName => task.filename; - - /// True when this download is actively progressing, queued, paused, retrying, - /// or currently installing. - bool get isActiveOrInstalling { - if (isInstalling) return true; - return switch (status) { - TaskStatus.running || - TaskStatus.enqueued || - TaskStatus.paused || - TaskStatus.waitingToRetry => true, - _ => false, - }; - } - - DownloadInfo copyWith({ - TaskStatus? status, - double? progress, - bool? isInstalling, - bool? isReadyToInstall, - bool? skipVerificationOnInstall, - String? errorDetails, - }) { - return DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - status: status ?? this.status, - progress: progress ?? this.progress, - isInstalling: isInstalling ?? this.isInstalling, - isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall, - skipVerificationOnInstall: - skipVerificationOnInstall ?? this.skipVerificationOnInstall, - errorDetails: errorDetails ?? this.errorDetails, - ); - } -} - -/// Queued download waiting for a slot -class QueuedDownload { - final String appId; - final String appName; - final FileMetadata fileMetadata; - - const QueuedDownload({ - required this.appId, - required this.appName, - required this.fileMetadata, - }); -} diff --git a/lib/services/download/download_persistence.dart b/lib/services/download/download_persistence.dart deleted file mode 100644 index ec61c62..0000000 --- a/lib/services/download/download_persistence.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; -import 'package:models/models.dart'; - -import 'download_info.dart'; - -/// Handles persistence and restoration of download state from background_downloader's database -class DownloadPersistence { - DownloadPersistence(this._downloader, this._storage); - - final FileDownloader _downloader; - final StorageNotifier _storage; - - /// Restore download state from background_downloader's persisted database. - /// Returns a map of appId -> DownloadInfo for restored downloads. - Future> restoreState() async { - final restoredState = {}; - - try { - final trackedRecords = await _downloader.database.allRecords( - group: FileDownloader.defaultGroup, - ); - - for (final record in trackedRecords) { - final task = record.task; - if (task is! DownloadTask) continue; - - // Parse metadata JSON to get appId and metadataId - final metaDataJson = task.metaData; - if (metaDataJson.isEmpty) { - await cleanupTask(task); - continue; - } - - final (appId, metadataId) = _parseMetadata(metaDataJson); - if (appId == null) { - await cleanupTask(task); - continue; - } - - // Check if task is stale - final taskAge = DateTime.now().difference(record.task.creationTime); - if (taskAge > staleDownloadThreshold) { - await cleanupTask(task); - continue; - } - - // Try to load FileMetadata from purplebase - final fileMetadata = await _loadFileMetadata(metadataId, task.filename); - if (fileMetadata == null) { - await cleanupTask(task); - continue; - } - - // Check if file exists for completed tasks - if (record.status == TaskStatus.complete) { - if (!await _fileExists(task)) { - await _downloader.database.deleteRecordWithId(task.taskId); - continue; - } - } - - // Create DownloadInfo - restoredState[appId] = DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - status: record.status, - progress: record.progress, - isReadyToInstall: record.status == TaskStatus.complete, - ); - } - } catch (e) { - debugPrint('Failed to restore download state: $e'); - } - - return restoredState; - } - - /// Parse metadata JSON, handling both legacy and new formats - (String? appId, String? metadataId) _parseMetadata(String metaDataJson) { - try { - final decoded = jsonDecode(metaDataJson) as Map; - return (decoded['appId'] as String?, decoded['metadataId'] as String?); - } catch (_) { - // Legacy format - metaData is just the appId - return (metaDataJson, null); - } - } - - /// Load FileMetadata from purplebase by ID or hash fallback - Future _loadFileMetadata( - String? metadataId, - String filename, - ) async { - // Try by metadataId first - if (metadataId != null) { - try { - final results = _storage.querySync( - RequestFilter(ids: {metadataId}).toRequest(), - ); - if (results.isNotEmpty) return results.first; - } catch (_) {} - } - - // Fallback: extract hash from filename - final hash = extractHashFromFilename(filename); - if (hash != null) { - try { - final results = _storage.querySync( - RequestFilter(search: hash).toRequest(), - ); - if (results.isNotEmpty) return results.first; - } catch (_) {} - } - - return null; - } - - /// Check if the downloaded file exists - Future _fileExists(DownloadTask task) async { - try { - final filePath = await task.filePath(); - return await File(filePath).exists(); - } catch (_) { - return false; - } - } - - /// Clean up a stale or invalid task - Future cleanupTask(DownloadTask task) async { - try { - await _downloader.cancelTaskWithId(task.taskId); - } catch (_) {} - - try { - final filePath = await task.filePath(); - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - - try { - await _downloader.database.deleteRecordWithId(task.taskId); - } catch (_) {} - } - - /// Extract hash from filename (format: {hash}.apk) - static String? extractHashFromFilename(String filename) { - final dotIndex = filename.lastIndexOf('.'); - if (dotIndex > 0) { - return filename.substring(0, dotIndex); - } - return filename.isNotEmpty ? filename : null; - } - - /// Encode metadata for storage in DownloadTask - static String encodeMetadata(String appId, String metadataId) { - return jsonEncode({'appId': appId, 'metadataId': metadataId}); - } -} - diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart deleted file mode 100644 index 25d8c62..0000000 --- a/lib/services/download/download_service.dart +++ /dev/null @@ -1,455 +0,0 @@ -import 'dart:io'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; - -import '../../utils/extensions.dart'; -import '../package_manager/package_manager.dart'; -import 'download_info.dart'; -import 'download_persistence.dart'; -import 'installation_queue.dart'; - -export 'download_info.dart'; - -/// Download service - orchestrates downloads and installations -class DownloadService extends StateNotifier> { - DownloadService(this.ref) : super({}) { - _installQueue = InstallationQueue(ref); - _initialize(); - } - - final Ref ref; - late final FileDownloader _downloader; - late final DownloadPersistence _persistence; - late final InstallationQueue _installQueue; - - // Download queue for overflow (>3 concurrent) - final List _downloadQueue = []; - - @override - void dispose() { - _downloader.unregisterCallbacks(); - super.dispose(); - } - - Future _initialize() async { - _downloader = FileDownloader(); - - // Configure downloader - try { - await _downloader.configure( - globalConfig: [ - (Config.requestTimeout, const Duration(seconds: 20)), - (Config.resourceTimeout, const Duration(minutes: 30)), - (Config.checkAvailableSpace, Config.never), - ], - androidConfig: [(Config.useCacheDir, false)], - ); - } catch (_) {} - - // Configure notifications - _downloader.configureNotificationForGroup( - FileDownloader.defaultGroup, - running: const TaskNotification( - 'Downloading {displayName}', - '{progress}', - ), - complete: const TaskNotification('Download complete', '{displayName}'), - error: const TaskNotification('Download failed', '{displayName}'), - paused: const TaskNotification('Download paused', '{displayName}'), - progressBar: true, - ); - - // Set up callbacks - _downloader.registerCallbacks( - taskStatusCallback: _handleTaskUpdate, - taskProgressCallback: _handleTaskUpdate, - ); - - // Set up installation queue callbacks - _installQueue.setCallbacks( - updateState: (appId, updater) { - final current = state[appId]; - if (current != null) { - state = {...state, appId: updater(current)}; - } - }, - removeFromState: (appId) { - state = Map.from(state)..remove(appId); - }, - getState: (appId) => state[appId], - ); - - // Restore persisted downloads - _persistence = DownloadPersistence( - _downloader, - ref.read(storageNotifierProvider.notifier), - ); - await _restoreDownloads(); - } - - Future _restoreDownloads() async { - final restored = await _persistence.restoreState(); - state = restored; - - // Resume incomplete downloads - for (final entry in restored.entries) { - final info = entry.value; - if (_shouldResume(info.status)) { - try { - final isActive = await _downloader.taskForId(info.taskId) != null; - if (!isActive) { - await _downloader.resume(info.task); - } - } catch (e) { - state = { - ...state, - entry.key: info.copyWith( - status: TaskStatus.failed, - errorDetails: 'Could not resume download. Please retry.', - ), - }; - } - } - } - } - - bool _shouldResume(TaskStatus status) { - return status == TaskStatus.paused || - status == TaskStatus.running || - status == TaskStatus.enqueued || - status == TaskStatus.waitingToRetry; - } - - // ============ Public API ============ - - /// Set app foreground state (called from lifecycle observer) - Future setAppForeground(bool inForeground) async { - final stalledApps = state.entries - .where((e) => e.value.isInstalling) - .map((e) => e.key) - .toList(); - - await _installQueue.setAppForeground(inForeground); - - if (inForeground && stalledApps.isNotEmpty) { - await _installQueue.handleStalledApps(stalledApps); - } - } - - /// Start downloading an app - Future downloadApp(App app, Release release) async { - final fileMetadata = app.latestFileMetadata; - if (fileMetadata == null) { - throw Exception('No file metadata available for this release'); - } - - final appId = app.identifier; - - // Check if already downloading or queued - if (state.containsKey(appId)) { - final existing = state[appId]!; - if (existing.status == TaskStatus.running || - existing.status == TaskStatus.enqueued) { - return; - } - } - if (_downloadQueue.any((q) => q.appId == appId)) { - return; - } - - // Check concurrent limit - final activeCount = _countActiveDownloads(); - if (activeCount >= maxConcurrentDownloads) { - _downloadQueue.add( - QueuedDownload( - appId: appId, - appName: app.name ?? appId, - fileMetadata: fileMetadata, - ), - ); - return; - } - - await _startDownload(appId, app.name ?? appId, fileMetadata); - } - - /// Pause a download - Future pauseDownload(String appId) async { - final info = state[appId]; - if (info == null) return; - - try { - await _downloader.pause(info.task); - } catch (_) {} - } - - /// Resume a paused download - Future resumeDownload(String appId) async { - final info = state[appId]; - if (info == null) return; - - try { - await _downloader.resume(info.task); - } catch (_) {} - } - - /// Cancel a download - Future cancelDownload(String appId) async { - final info = state[appId]; - if (info == null) return; - - try { - await _downloader.cancel(info.task); - state = Map.from(state)..remove(appId); - - final filePath = await info.task.filePath(); - await File(filePath).delete().catchError((_) => File(filePath)); - } catch (_) {} - } - - /// Install from downloaded file - Future installFromDownloaded(String appId) async { - final info = state[appId]; - if (info == null || !info.isReadyToInstall) return; - - try { - final filePath = await info.task.filePath(); - if (!await File(filePath).exists()) { - state = Map.from(state)..remove(appId); - return; - } - - state = { - ...state, - appId: info.copyWith(isReadyToInstall: false, errorDetails: null), - }; - - _installQueue.enqueue(appId); - } catch (_) { - state = { - ...state, - appId: info.copyWith(isInstalling: false, isReadyToInstall: true), - }; - } - } - - /// Mark a download as ready to install (for reckless mode) - void markReadyToInstall( - String appId, { - bool skipVerificationOnInstall = false, - }) { - final info = state[appId]; - if (info == null) return; - - state = { - ...state, - appId: info.copyWith( - isInstalling: false, - isReadyToInstall: true, - skipVerificationOnInstall: skipVerificationOnInstall, - errorDetails: null, - ), - }; - } - - /// Clear error for retry - void clearError(String appId) { - final info = state[appId]; - if (info?.errorDetails != null) { - state = {...state, appId: info!.copyWith(errorDetails: null)}; - } - } - - /// Get download info (prefer using downloadInfoProvider) - DownloadInfo? getDownloadInfo(String appId) => state[appId]; - - /// Download with explicit metadata (used for alternative artifact selection) - Future downloadAppWithMetadata( - String appId, - String appName, - FileMetadata fileMetadata, - ) async { - // Check if already downloading or queued - if (state.containsKey(appId)) { - final existing = state[appId]!; - if (existing.status == TaskStatus.running || - existing.status == TaskStatus.enqueued) { - return; - } - } - if (_downloadQueue.any((q) => q.appId == appId)) { - return; - } - - // Check concurrent limit - final activeCount = _countActiveDownloads(); - if (activeCount >= maxConcurrentDownloads) { - _downloadQueue.add( - QueuedDownload( - appId: appId, - appName: appName, - fileMetadata: fileMetadata, - ), - ); - return; - } - - await _startDownload(appId, appName, fileMetadata); - } - - // ============ Internal ============ - - int _countActiveDownloads() { - return state.values - .where( - (info) => - info.status == TaskStatus.running || - info.status == TaskStatus.enqueued || - info.status == TaskStatus.waitingToRetry, - ) - .length; - } - - Future _startDownload( - String appId, - String appName, - FileMetadata fileMetadata, - ) async { - final packageManager = ref.read(packageManagerProvider.notifier); - final platform = packageManager.platform; - - String? downloadUrl; - if (fileMetadata.platforms.contains(platform) || - fileMetadata.platforms.isEmpty) { - downloadUrl = fileMetadata.urls.firstOrNull; - } - - if (downloadUrl == null || downloadUrl.isEmpty) return; - - final fileName = '${fileMetadata.hash}${packageManager.packageExtension}'; - final metaData = DownloadPersistence.encodeMetadata(appId, fileMetadata.id); - - final task = DownloadTask( - taskId: - '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}', - url: downloadUrl, - filename: fileName, - updates: Updates.statusAndProgress, - requiresWiFi: false, - retries: 3, - allowPause: true, - metaData: metaData, - displayName: appName, - ); - - state = { - ...state, - appId: DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - progress: 0.0, - ), - }; - - try { - final result = await _downloader.enqueue(task); - if (!result) { - state = Map.from(state)..remove(appId); - } - } catch (_) { - state = Map.from(state)..remove(appId); - } - } - - void _startNextQueuedDownload() { - if (_downloadQueue.isEmpty) return; - - if (_countActiveDownloads() < maxConcurrentDownloads) { - final next = _downloadQueue.removeAt(0); - _startDownload(next.appId, next.appName, next.fileMetadata); - } - } - - void _handleTaskUpdate(TaskUpdate update) { - String? appId; - for (final entry in state.entries) { - if (entry.value.taskId == update.task.taskId) { - appId = entry.key; - break; - } - } - if (appId == null) return; - - final current = state[appId]!; - - if (update is TaskStatusUpdate) { - _handleStatusUpdate(appId, current, update); - } else if (update is TaskProgressUpdate) { - _handleProgressUpdate(appId, current, update); - } - } - - void _handleStatusUpdate( - String appId, - DownloadInfo current, - TaskStatusUpdate update, - ) { - switch (update.status) { - case TaskStatus.failed: - String error = 'Download failed'; - if (update.exception != null) { - error = update.exception.toString(); - if (error.length > 200) error = '${error.substring(0, 197)}...'; - } - state = { - ...state, - appId: current.copyWith(status: update.status, errorDetails: error), - }; - _startNextQueuedDownload(); - - case TaskStatus.canceled: - state = {...state, appId: current.copyWith(status: update.status)}; - _startNextQueuedDownload(); - - case TaskStatus.complete: - state = {...state, appId: current.copyWith(status: update.status)}; - _installQueue.handleDownloadComplete(appId); - _startNextQueuedDownload(); - - default: - state = {...state, appId: current.copyWith(status: update.status)}; - } - } - - void _handleProgressUpdate( - String appId, - DownloadInfo current, - TaskProgressUpdate update, - ) { - double progress = current.progress; - if (update.progress.isFinite && - !update.progress.isNaN && - update.progress >= 0.0) { - progress = update.progress.clamp(0.0, 1.0); - } - state = {...state, appId: current.copyWith(progress: progress)}; - } -} - -/// Provider for the download service -final downloadServiceProvider = - StateNotifierProvider>( - DownloadService.new, - ); - -/// Provider for getting download info for a specific app -final downloadInfoProvider = Provider.family(( - ref, - appId, -) { - return ref.watch(downloadServiceProvider)[appId]; -}); diff --git a/lib/services/download/installation_queue.dart b/lib/services/download/installation_queue.dart deleted file mode 100644 index f62c153..0000000 --- a/lib/services/download/installation_queue.dart +++ /dev/null @@ -1,239 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../package_manager/android_package_manager.dart'; -import '../package_manager/package_manager.dart'; -import 'download_info.dart'; - -/// Callback to update state in the main service -typedef StateUpdater = - void Function(String appId, DownloadInfo Function(DownloadInfo) updater); - -/// Callback to remove from state -typedef StateRemover = void Function(String appId); - -/// Callback to get current state -typedef StateGetter = DownloadInfo? Function(String appId); - -/// Manages sequential installation of downloaded apps -class InstallationQueue { - InstallationQueue(this._ref); - - final Ref _ref; - - final List _queue = []; - final Set _pendingInstallations = {}; - bool _isProcessing = false; - bool _isAppInForeground = true; - - // Callbacks set by the service - late StateUpdater _updateState; - late StateRemover _removeFromState; - late StateGetter _getState; - - void setCallbacks({ - required StateUpdater updateState, - required StateRemover removeFromState, - required StateGetter getState, - }) { - _updateState = updateState; - _removeFromState = removeFromState; - _getState = getState; - } - - /// Set app foreground state - Future setAppForeground(bool inForeground) async { - final wasBackground = !_isAppInForeground; - _isAppInForeground = inForeground; - - final packageManager = _ref.read(packageManagerProvider.notifier); - if (packageManager is AndroidPackageManager) { - await packageManager.setAppForegroundState(inForeground); - } - - if (inForeground && wasBackground) { - await _handleReturnToForeground(); - } - } - - /// Handle download completion - add to installation queue - Future handleDownloadComplete(String appId) async { - final downloadInfo = _getState(appId); - if (downloadInfo == null) return; - - // Check silent install from PackageInfo (computed during syncInstalledPackages) - final packages = _ref.read(packageManagerProvider); - final packageInfo = packages.where((p) => p.appId == appId).firstOrNull; - final canSilent = packageInfo?.canInstallSilently ?? false; - - // If backgrounded and not silent install, defer - if (!_isAppInForeground && !canSilent) { - _pendingInstallations.add(appId); - _updateState(appId, (info) => info.copyWith(isReadyToInstall: true)); - return; - } - - // Add to queue and process - if (!_queue.contains(appId)) { - _queue.add(appId); - } - _processQueue(); - } - - /// Add to installation queue manually (e.g., from installFromDownloaded) - void enqueue(String appId) { - if (!_queue.contains(appId)) { - _queue.add(appId); - } - _processQueue(); - } - - /// Handle returning to foreground - Future _handleReturnToForeground() async { - // Process pending installations - if (_pendingInstallations.isNotEmpty) { - for (final appId in _pendingInstallations) { - if (!_queue.contains(appId)) { - _queue.add(appId); - } - } - _pendingInstallations.clear(); - _processQueue(); - } - } - - /// Process stalled apps (called by service with list of stalled appIds) - Future handleStalledApps(List stalledAppIds) async { - final packageManager = _ref.read(packageManagerProvider.notifier); - - for (final appId in stalledAppIds) { - if (packageManager is AndroidPackageManager) { - final retry = await packageManager.retryPendingInstall(appId); - - if (retry.hasPending && - (retry.relaunched || retry.promptAlreadyShown)) { - continue; - } - - if (retry.hasPending && retry.sessionPending) { - _updateState( - appId, - (info) => - info.copyWith(isInstalling: false, isReadyToInstall: true), - ); - continue; - } - } - - // No pending prompt - mark ready to install - _updateState( - appId, - (info) => info.copyWith(isInstalling: false, isReadyToInstall: true), - ); - } - - // Reset lock if there were stalled apps without prompts - final stalledWithoutPrompt = stalledAppIds.where((appId) { - final info = _getState(appId); - return info != null && !info.isInstalling && info.isReadyToInstall; - }).toList(); - - if (stalledWithoutPrompt.isNotEmpty) { - _isProcessing = false; - } - - _processQueue(); - } - - /// Process installation queue sequentially - Future _processQueue() async { - if (_isProcessing || _queue.isEmpty) return; - - _isProcessing = true; - - try { - while (_queue.isNotEmpty) { - final appId = _queue.removeAt(0); - final downloadInfo = _getState(appId); - - if (downloadInfo == null) continue; - - try { - final filePath = await downloadInfo.task.filePath(); - - _updateState( - appId, - (info) => - info.copyWith(isInstalling: true, isReadyToInstall: false), - ); - - final packageManager = _ref.read(packageManagerProvider.notifier); - await packageManager.install( - appId, - filePath, - expectedHash: downloadInfo.fileMetadata.hash, - expectedSize: downloadInfo.fileMetadata.size ?? 0, - skipVerification: downloadInfo.skipVerificationOnInstall, - ); - - // Clean up file after successful install - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (_) {} - - _removeFromState(appId); - - if (_queue.isNotEmpty) { - await Future.delayed(const Duration(milliseconds: 500)); - } - } catch (e) { - _handleInstallError(appId, downloadInfo, e.toString()); - } - } - } finally { - _isProcessing = false; - } - } - - void _handleInstallError( - String appId, - DownloadInfo downloadInfo, - String errorMessage, - ) { - // Already in progress - put back and stop - if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) { - _queue.insert(0, appId); - _updateState( - appId, - (info) => info.copyWith(isInstalling: true, isReadyToInstall: false), - ); - return; - } - - final isCertMismatch = - errorMessage.contains('signatures do not match') || - errorMessage.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || - errorMessage.contains('UPDATE_INCOMPATIBLE'); - - final wasCancelled = errorMessage.contains('cancelled'); - - if (wasCancelled) { - _removeFromState(appId); - } else { - _updateState( - appId, - (info) => info.copyWith( - isInstalling: false, - isReadyToInstall: true, - errorDetails: isCertMismatch - ? 'CERTIFICATE_MISMATCH' - : errorMessage.replaceFirst('Exception: ', ''), - ), - ); - } - } -} diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index eb4be63..96db75a 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -1,17 +1,71 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'package:models/models.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -/// Android implementation of PackageManager with integrated installation. -final class AndroidPackageManager extends PackageManager { - AndroidPackageManager(super.ref); +/// Install status values from native side. +/// +/// IMPORTANT: The native side sends these as strings via EventChannel, so we +/// parse them into this enum for type-safe switching. +enum InstallStatus { + started, + verifying, + pendingUserAction, + alreadyInProgress, + success, + failed, + cancelled, +} + +extension InstallStatusX on InstallStatus { + static InstallStatus? tryParse(String? raw) { + return switch (raw) { + 'started' => InstallStatus.started, + 'verifying' => InstallStatus.verifying, + 'pendingUserAction' => InstallStatus.pendingUserAction, + 'alreadyInProgress' => InstallStatus.alreadyInProgress, + 'success' => InstallStatus.success, + 'failed' => InstallStatus.failed, + 'cancelled' => InstallStatus.cancelled, + _ => null, + }; + } +} + +/// Error codes from native side (structured, reliable) +class NativeErrorCode { + static const downloadFailed = 'downloadFailed'; + static const hashMismatch = 'hashMismatch'; + static const invalidFile = 'invalidFile'; + static const installFailed = 'installFailed'; + static const certMismatch = 'certMismatch'; + static const permissionDenied = 'permissionDenied'; + static const insufficientStorage = 'insufficientStorage'; + static const incompatible = 'incompatible'; + static const blocked = 'blocked'; + static const alreadyInProgress = 'alreadyInProgress'; + static const installTimeout = 'installTimeout'; +} + +/// Android implementation of PackageManager using event-driven architecture. +/// +/// The native side streams install status events via EventChannel. +/// Foreground state is auto-detected by ProcessLifecycleOwner on the native side. +/// No polling, no probing, no hanging awaits. +final class AndroidPackageManager extends PackageManager { + AndroidPackageManager(super.ref) { + _setupEventStream(); + } + + static const _methodChannel = MethodChannel('android_package_manager'); + static const _eventChannel = EventChannel('android_package_manager/events'); - static const MethodChannel _channel = MethodChannel( - 'android_package_manager', - ); bool _supportsSilentInstall = false; + int _syncGeneration = 0; + StreamSubscription? _eventSubscription; @override String get platform => 'android-arm64-v8a'; @@ -19,80 +73,404 @@ final class AndroidPackageManager extends PackageManager { @override String get packageExtension => '.apk'; + @override + bool get supportsSilentInstall => _supportsSilentInstall; + + // ═══════════════════════════════════════════════════════════════════════════ + // EVENT STREAM HANDLING + // ═══════════════════════════════════════════════════════════════════════════ + + void _setupEventStream() { + _eventSubscription = _eventChannel.receiveBroadcastStream().listen( + _handleInstallEvent, + onError: (_) {}, // Events will resume when stream reconnects + ); + } + + /// Handle install status events from native side. + /// Events arrive sequentially (Dart is single-threaded), no lock needed. + void _handleInstallEvent(dynamic event) { + if (event is! Map) { + debugPrint('[PackageManager] Ignoring non-map event: $event'); + return; + } + + final appId = event['appId'] as String?; + final statusRaw = event['status'] as String?; + final message = event['message'] as String?; + final errorCode = event['errorCode'] as String?; + final status = InstallStatusX.tryParse(statusRaw); + + debugPrint( + '[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode', + ); + + if (appId == null || statusRaw == null) { + debugPrint('[PackageManager] Ignoring event with null appId or status'); + return; + } + + if (status == null) { + debugPrint( + '[PackageManager] Ignoring event with unknown status: $statusRaw', + ); + return; + } + + // Get target from existing operation state - no separate tracking needed + final existingOp = getOperation(appId); + if (existingOp == null) { + debugPrint( + '[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event', + ); + debugPrint( + '[PackageManager] Current operations: ${state.operations.keys.toList()}', + ); + return; + } + + debugPrint( + '[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})', + ); + + final target = existingOp.target; + final filePath = existingOp.filePath; + + switch (status) { + case InstallStatus.verifying: + // Kotlin started hash verification - show Verifying state + if (filePath != null) { + setOperation(appId, Verifying(target: target, filePath: filePath)); + } + break; + + case InstallStatus.started: + // Install session started - transition to Installing + if (filePath != null) { + final pkg = state.installed[appId]; + final isSilent = pkg?.canInstallSilently ?? false; + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: isSilent), + ); + } + break; + + case InstallStatus.pendingUserAction: + // User action required. Ensure we don't get stuck in Verifying if the + // STARTED event was missed; show Installing state. + if (filePath != null && existingOp is! Installing) { + final pkg = state.installed[appId]; + final isSilent = pkg?.canInstallSilently ?? false; + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: isSilent), + ); + } + break; + + case InstallStatus.alreadyInProgress: + // Native is stuck thinking an install is in progress. + // Treat as failure so user can see the issue and retry manually. + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.installFailed, + message: 'Android is busy. Please try again in a moment.', + filePath: filePath, + ), + ); + // Move on to next app in queue + _advanceAfterDelay(); + break; + + case InstallStatus.success: + _deleteFile(filePath); + // CRITICAL: Update installed package info DIRECTLY from target metadata. + // We cannot rely on syncInstalledPackages() here because Android's package + // database may not have committed yet, causing a race condition where + // we get stale data and show "Update" instead of "Open". + _updateInstalledPackage(appId, target); + clearOperation(appId); + // Sync in background to get accurate info (signature hash, etc.) + // but our state machine doesn't depend on it. + unawaited(syncInstalledPackages()); + // Advance to next queued install + _advanceAfterDelay(); + break; + + case InstallStatus.failed: + setOperation( + appId, + OperationFailed( + target: target, + type: _errorCodeToFailureType(errorCode, message), + message: message ?? 'Installation failed', + filePath: filePath, + ), + ); + // Advance to next queued install + _advanceAfterDelay(); + break; + + case InstallStatus.cancelled: + if (filePath != null) { + setOperation( + appId, + AwaitingUserAction(target: target, filePath: filePath), + ); + } else { + clearOperation(appId); + } + // Advance to next queued install + _advanceAfterDelay(); + break; + } + } + + @override + void onInstallReady(String appId) { + _tryAdvanceNextInstall(); + } + + /// Advance to next install after a delay, giving Android time to clean up. + void _advanceAfterDelay() { + Future.delayed(const Duration(seconds: 1), _tryAdvanceNextInstall); + } + + /// Try to start the next app in ReadyToInstall state. + /// Only advances if no other app is currently installing (one dialog at a time). + void _tryAdvanceNextInstall() { + // Check if any app is currently in an active install state + final hasActiveInstall = state.operations.values.any( + (op) => op is Verifying || op is Installing || op is Uninstalling, + ); + + if (hasActiveInstall) { + debugPrint('[PackageManager] Not advancing - install already active'); + return; + } + + // Get next app ready to install + final readyToInstall = getReadyToInstall(); + if (readyToInstall.isEmpty) { + debugPrint('[PackageManager] No apps waiting in ReadyToInstall'); + return; + } + + final nextAppId = readyToInstall.first; + debugPrint('[PackageManager] Advancing to next install: $nextAppId'); + + // Trigger install (fire-and-forget, events will drive state) + unawaited(triggerInstall(nextAppId)); + } + + /// Convert native error code to FailureType. + /// Uses structured error code when available, falls back to message parsing. + FailureType _errorCodeToFailureType(String? errorCode, String? message) { + // Use structured error code when available (reliable) + if (errorCode != null) { + return switch (errorCode) { + NativeErrorCode.downloadFailed => FailureType.downloadFailed, + NativeErrorCode.hashMismatch => FailureType.hashMismatch, + NativeErrorCode.invalidFile => FailureType.invalidFile, + NativeErrorCode.installFailed => FailureType.installFailed, + NativeErrorCode.certMismatch => FailureType.certMismatch, + NativeErrorCode.permissionDenied => FailureType.permissionDenied, + NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, + NativeErrorCode.incompatible => FailureType.installFailed, + NativeErrorCode.blocked => FailureType.permissionDenied, + NativeErrorCode.installTimeout => FailureType.installFailed, + _ => FailureType.installFailed, + }; + } + + // Fallback: categorize by message content (legacy, less reliable) + if (message == null) return FailureType.installFailed; + final lower = message.toLowerCase(); + if (lower.contains('signature') || + lower.contains('certificate') || + lower.contains('update_incompatible')) { + return FailureType.certMismatch; + } + if (lower.contains('storage') || lower.contains('space')) { + return FailureType.insufficientStorage; + } + if (lower.contains('hash') || lower.contains('verification')) { + return FailureType.hashMismatch; + } + if (lower.contains('invalid') || lower.contains('corrupt')) { + return FailureType.invalidFile; + } + if (lower.contains('permission') || lower.contains('denied')) { + return FailureType.permissionDenied; + } + return FailureType.installFailed; + } + + void _deleteFile(String? path) { + if (path == null) return; + try { + final file = File(path); + if (file.existsSync()) file.deleteSync(); + } catch (_) {} + } + + /// Directly update installed package from target metadata. + /// This ensures we don't depend on Android's package DB timing. + void _updateInstalledPackage(String appId, FileMetadata target) { + final existingPkg = state.installed[appId]; + final newPkg = PackageInfo( + appId: appId, + name: existingPkg?.name, + version: target.version, + versionCode: target.versionCode, + // Keep existing signature hash if available, will be updated by sync + signatureHash: existingPkg?.signatureHash ?? '', + installTime: DateTime.now(), + canInstallSilently: existingPkg?.canInstallSilently ?? false, + ); + state = state.copyWith(installed: {...state.installed, appId: newPkg}); + } + + @override + void dispose() { + _eventSubscription?.cancel(); + super.dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALLATION (Fire-and-forget - results come via events) + // ═══════════════════════════════════════════════════════════════════════════ + @override Future install( String appId, String filePath, { required String expectedHash, required int expectedSize, - bool skipVerification = false, + required FileMetadata target, }) async { - await _ensureInstallPermission(); + // Permission is already checked by _proceedToInstall in base class + // No need for redundant _ensureInstallPermission() call here final file = File(filePath); if (!file.existsSync()) { throw Exception('APK file not found: $filePath'); } - final result = await _channel - .invokeMethod>('install', { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'skipVerification': skipVerification, - }); + // Native side is the source of truth for install state; it will emit + // VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events. - final resultMap = Map.from(result ?? {}); + try { + final result = await _methodChannel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + }) + .timeout(const Duration(seconds: 30), onTimeout: () => null); - // If installation is already in progress, do NOT treat this call as success. - // Returning silently would cause callers to delete APK files / advance queues while the - // original session is still active, which can lead to "session files in use" races. - if (resultMap['alreadyInProgress'] == true) { - throw Exception('INSTALL_ALREADY_IN_PROGRESS'); + final resultMap = Map.from(result ?? {}); + + if (resultMap['alreadyInProgress'] == true) { + // Native is stuck thinking an install is in progress. + // Treat as failure so user can see and retry manually. + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.installFailed, + message: 'Android is busy. Please try again in a moment.', + filePath: filePath, + ), + ); + _advanceAfterDelay(); + return; + } + + if (resultMap['started'] != true) { + final error = + resultMap['error'] as String? ?? 'Failed to start install'; + final errorCode = resultMap['errorCode'] as String?; + setOperation( + appId, + OperationFailed( + target: target, + type: _errorCodeToFailureType(errorCode, error), + message: error, + filePath: filePath, + ), + ); + // Don't auto-advance after failure + } + // If started, wait for events via EventChannel + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.installFailed, + message: e.toString(), + filePath: filePath, + ), + ); + // Don't auto-advance after failure } - - if (!(resultMap['isSuccess'] == true)) { - final error = resultMap['errorMessage'] ?? 'Installation failed'; - throw Exception(error); - } - - // Refresh installed packages state - await syncInstalledPackages(); } @override Future uninstall(String appId) async { - final result = await _channel.invokeMethod>( - 'uninstall', - {'packageName': appId}, - ); + final result = await _methodChannel + .invokeMethod>('uninstall', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 60), // Uninstall needs user confirmation + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'Uninstall timed out', + }, + ); final resultMap = Map.from(result ?? {}); - if (!(resultMap['isSuccess'] == true)) { - final wasCancelled = resultMap['cancelled'] == true; - if (wasCancelled) { + if (resultMap['isSuccess'] != true) { + if (resultMap['cancelled'] == true) { throw Exception('Uninstall was cancelled'); } - throw Exception('Uninstallation failed'); + throw Exception(resultMap['errorMessage'] ?? 'Uninstallation failed'); } - // Refresh installed packages state - await syncInstalledPackages(); + // Remove from installed immediately (don't wait for sync) + state = state.copyWith(installed: Map.from(state.installed)..remove(appId)); + + // Background sync for consistency + syncInstalledPackages(); } + // ═══════════════════════════════════════════════════════════════════════════ + // PERMISSIONS + // ═══════════════════════════════════════════════════════════════════════════ + @override Future requestPermission() async { try { - final result = await _channel.invokeMethod>( - 'requestInstallPermission', - ); + final result = await _methodChannel + .invokeMethod>('requestInstallPermission') + .timeout( + const Duration(seconds: 5), + onTimeout: () => { + 'success': false, + 'message': 'Request timed out', + }, + ); final resultMap = Map.from(result ?? {}); - if (!(resultMap['success'] == true)) { - final error = resultMap['message'] ?? 'Failed to request permission'; - throw Exception(error); + if (resultMap['success'] != true) { + throw Exception(resultMap['message'] ?? 'Failed to request permission'); } } catch (e) { throw Exception( @@ -109,47 +487,23 @@ final class AndroidPackageManager extends PackageManager { @override Future hasPermission() async { try { - final hasUnknownSources = - await _channel.invokeMethod('hasUnknownSourcesPermission') ?? + return await _methodChannel + .invokeMethod('hasUnknownSourcesPermission') + .timeout(const Duration(seconds: 5), onTimeout: () => false) ?? false; - return hasUnknownSources; - } catch (e) { + } catch (_) { return false; } } - @override - bool get supportsSilentInstall { - // Cached capability determined asynchronously via plugin - return _supportsSilentInstall; - } + // ═══════════════════════════════════════════════════════════════════════════ + // APP LAUNCH + // ═══════════════════════════════════════════════════════════════════════════ - /// Check if automatic, unattended updates are possible - bool get supportsAutomaticUpdates { - return _supportsSilentInstall; - } - - /// Install an update silently if possible, otherwise fall back to user confirmation - Future installUpdate( - String appId, - String filePath, { - required String expectedHash, - required int expectedSize, - }) async { - // Install regardless - the system will handle showing confirmation if needed - await install( - appId, - filePath, - expectedHash: expectedHash, - expectedSize: expectedSize, - ); - } - - /// Launch an installed app by its package identifier @override Future launchApp(String appId) async { try { - final result = await _channel + final result = await _methodChannel .invokeMethod>('launchApp', { 'packageName': appId, }) @@ -157,143 +511,141 @@ final class AndroidPackageManager extends PackageManager { const Duration(seconds: 10), onTimeout: () => { 'isSuccess': false, - 'errorMessage': 'App launch timed out after 10 seconds', + 'errorMessage': 'App launch timed out', }, ); final resultMap = Map.from(result ?? {}); - if (!(resultMap['isSuccess'] == true)) { - final error = resultMap['errorMessage'] ?? 'Failed to launch app'; - throw Exception(error); + if (resultMap['isSuccess'] != true) { + throw Exception(resultMap['errorMessage'] ?? 'Failed to launch app'); } } catch (e) { throw Exception('Failed to launch app: $e'); } } - /// Re-launch a pending install prompt that was backgrounded. - /// Returns structured info so callers can avoid double-prompts and bad state resets. - Future retryPendingInstall(String appId) async { - try { - final result = await _channel.invokeMethod>( - 'retryPendingInstall', - {'packageName': appId}, - ); - - final resultMap = Map.from(result ?? {}); - return RetryPendingInstallResult.fromMap(resultMap); - } catch (e) { - return const RetryPendingInstallResult( - hasPending: false, - relaunched: false, - sessionPending: false, - promptAlreadyShown: false, - ); - } - } - - /// Set app foreground state on the native side. - /// Called from DownloadService when app lifecycle changes. - Future setAppForegroundState(bool foreground) async { - try { - await _channel.invokeMethod('setAppForegroundState', { - 'foreground': foreground, - }); - } catch (e) { - // Ignore errors - best effort - } - } + // ═══════════════════════════════════════════════════════════════════════════ + // SYNC INSTALLED PACKAGES + // ═══════════════════════════════════════════════════════════════════════════ @override Future syncInstalledPackages() async { + final syncGen = ++_syncGeneration; try { - // Check and cache silent install capability (general check) - final canInstallSilentlyGeneral = - await _channel.invokeMethod('canInstallSilently') ?? false; - _supportsSilentInstall = canInstallSilentlyGeneral; - - // Get installed apps via method channel, excluding system apps - // Each app includes per-package canInstallSilently status + // Single native call - getInstalledApps already returns canInstallSilently per-app final installedApps = - await _channel.invokeMethod>('getInstalledApps', { - 'includeSystemApps': false, - }) ?? + await _methodChannel + .invokeMethod>('getInstalledApps', { + 'includeSystemApps': false, + }) + .timeout( + const Duration(seconds: 10), + onTimeout: () => [], + ) ?? []; - final packages = []; + if (syncGen != _syncGeneration) return; + + final packages = {}; + var anyCanInstallSilently = false; for (final appObj in installedApps) { final app = Map.from(appObj as Map); final appId = app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; + final name = app['name'] as String?; final version = app['versionName'] as String? ?? '0.0.0'; final versionCode = app['versionCode'] as int?; final signatureHash = app['signatureHash'] as String? ?? ''; final canInstallSilently = app['canInstallSilently'] as bool? ?? false; + if (canInstallSilently) anyCanInstallSilently = true; + if (appId.isNotEmpty) { - packages.add( - PackageInfo( - appId: appId, - version: version, - versionCode: versionCode, - installTime: null, // Method channel doesn't provide install time - signatureHash: signatureHash, - canInstallSilently: canInstallSilently, - ), + packages[appId] = PackageInfo( + appId: appId, + name: name, + version: version, + versionCode: versionCode, + signatureHash: signatureHash, + installTime: null, + canInstallSilently: canInstallSilently, ); } } - state = packages; - } catch (e) { - // Fallback to empty state on error - state = []; - } - } + if (syncGen != _syncGeneration) return; - Future _ensureInstallPermission() async { - if (!Platform.isAndroid) return; + // Derive general silent install capability from per-app data + _supportsSilentInstall = anyCanInstallSilently; - try { - final status = await Permission.requestInstallPackages.status; - if (status.isGranted) { - return; + // Preserve any packages we've directly updated (they have installTime set) + // This prevents race conditions where sync returns stale data + final preserved = {}; + for (final entry in state.installed.entries) { + if (entry.value.installTime != null && + !packages.containsKey(entry.key)) { + // We directly updated this but sync doesn't have it yet - keep ours + preserved[entry.key] = entry.value; + } else if (entry.value.installTime != null && + packages.containsKey(entry.key)) { + // Both have it - use sync data but preserve installTime marker + final syncPkg = packages[entry.key]!; + preserved[entry.key] = PackageInfo( + appId: syncPkg.appId, + name: syncPkg.name, + version: syncPkg.version, + versionCode: syncPkg.versionCode, + signatureHash: syncPkg.signatureHash, + installTime: entry.value.installTime, + canInstallSilently: syncPkg.canInstallSilently, + ); + } } - final newStatus = await Permission.requestInstallPackages.request(); - if (!newStatus.isGranted) { - throw Exception( - 'Install permission required. Please enable "Install unknown apps" ' - 'for Zapstore and try again.', - ); + state = state.copyWith(installed: {...packages, ...preserved}); + + // Clear operations for apps where the installed version matches the target version + // This catches installs that succeeded but we missed the event + // IMPORTANT: Don't clear operations where we're updating to a NEWER version + for (final appId in packages.keys) { + final op = getOperation(appId); + if (op == null) continue; + if (op is! Installing && + op is! Verifying && + op is! AwaitingUserAction) { + continue; + } + + final installedPkg = packages[appId]; + final targetVc = op.target.versionCode; + final installedVc = installedPkg?.versionCode; + final targetV = op.target.version; + final installedV = installedPkg?.version; + + final completed = + (targetVc != null && + installedVc != null && + installedVc >= targetVc) || + (installedV != null && installedV == targetV); + + // Only clear if we can establish completion reliably. + if (completed) { + debugPrint( + '[PackageManager] Sync: clearing completed operation for $appId ' + '(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)', + ); + clearOperation(appId); + } else { + debugPrint( + '[PackageManager] Sync: keeping operation for $appId ' + '(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)', + ); + } } - } catch (e) { - throw Exception('Unable to request install permission: $e'); + } catch (_) { + // Don't clobber state on transient errors } } } - -final class RetryPendingInstallResult { - const RetryPendingInstallResult({ - required this.hasPending, - required this.relaunched, - required this.sessionPending, - required this.promptAlreadyShown, - }); - - factory RetryPendingInstallResult.fromMap(Map map) { - return RetryPendingInstallResult( - hasPending: map['hasPending'] == true, - relaunched: map['relaunched'] == true, - sessionPending: map['sessionPending'] == true, - promptAlreadyShown: map['promptAlreadyShown'] == true, - ); - } - - final bool hasPending; - final bool relaunched; - final bool sessionPending; - final bool promptAlreadyShown; -} diff --git a/lib/services/package_manager/dummy_package_manager.dart b/lib/services/package_manager/dummy_package_manager.dart index b09ea53..0f41b3a 100644 --- a/lib/services/package_manager/dummy_package_manager.dart +++ b/lib/services/package_manager/dummy_package_manager.dart @@ -1,32 +1,35 @@ +import 'package:models/models.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -/// Dummy implementation of PackageManager for testing +/// Dummy implementation of PackageManager for testing and non-Android platforms final class DummyPackageManager extends PackageManager { DummyPackageManager(super.ref) { - // Initialize state with mock packages - state = [ - PackageInfo( - appId: 'com.example.test', - version: '1.0.0', - versionCode: 1, - signatureHash: 'dummy_signature_1', - installTime: DateTime.now().subtract(const Duration(days: 1)), - ), - PackageInfo( - appId: 'dev.zapstore.alpha', - version: '1.0.0', - versionCode: 1, - signatureHash: 'dummy_signature_2', - installTime: DateTime.now().subtract(const Duration(hours: 12)), - ), - PackageInfo( - appId: 'com.dummy.browser', - version: '2.1.0', - versionCode: 210, - signatureHash: 'dummy_signature_3', - installTime: DateTime.now().subtract(const Duration(days: 5)), - ), - ]; + state = PackageManagerState( + installed: { + 'com.example.test': const PackageInfo( + appId: 'com.example.test', + name: 'Test App', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_1', + ), + 'dev.zapstore.alpha': const PackageInfo( + appId: 'dev.zapstore.alpha', + name: 'Zapstore', + version: '1.0.0', + versionCode: 1, + signatureHash: 'dummy_signature_2', + ), + 'com.dummy.browser': const PackageInfo( + appId: 'com.dummy.browser', + name: 'Dummy Browser', + version: '2.1.0', + versionCode: 210, + signatureHash: 'dummy_signature_3', + ), + }, + operations: const {}, + ); } @override @@ -35,57 +38,58 @@ final class DummyPackageManager extends PackageManager { @override String get packageExtension => '.apk'; + @override + bool get supportsSilentInstall => false; + @override Future install( String appId, String filePath, { required String expectedHash, required int expectedSize, - bool skipVerification = false, + required FileMetadata target, }) async { - // Mock implementation - just add to list if not already present - if (!state.any((p) => p.appId == appId)) { - state = [ - ...state, - PackageInfo( - appId: appId, - version: '1.0.0', - versionCode: 1, - signatureHash: 'mock_signature', - installTime: DateTime.now(), - ), - ]; - } + // Mock: simulate install delay then add to installed + await Future.delayed(const Duration(milliseconds: 500)); + + final newInstalled = Map.from(state.installed); + newInstalled[appId] = PackageInfo( + appId: appId, + version: target.version, + versionCode: target.versionCode, + signatureHash: 'mock_signature', + installTime: DateTime.now(), + ); + state = state.copyWith(installed: newInstalled); + + // Clear the operation since install completed + clearOperation(appId); } @override Future uninstall(String appId) async { - state = state.where((p) => p.appId != appId).toList(); + await Future.delayed(const Duration(milliseconds: 300)); + + final newInstalled = Map.from(state.installed); + newInstalled.remove(appId); + state = state.copyWith(installed: newInstalled); } @override Future launchApp(String appId) async { - // Mock implementation - just simulate launching - if (!state.any((p) => p.appId == appId)) { + if (!state.installed.containsKey(appId)) { throw Exception('App not installed: $appId'); } - // In a real implementation, this would launch the app await Future.delayed(const Duration(milliseconds: 500)); } @override Future requestPermission() async { - // Mock implementation - always succeeds + // Mock: always succeeds } @override - Future hasPermission() async { - // Mock implementation - always has permission - return true; - } - - @override - bool get supportsSilentInstall => false; + Future hasPermission() async => true; @override Future syncInstalledPackages() async { diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart new file mode 100644 index 0000000..4b024df --- /dev/null +++ b/lib/services/package_manager/install_operation.dart @@ -0,0 +1,202 @@ +import 'package:models/models.dart'; + +/// Stale download threshold - operations older than this will be cleaned up +const staleOperationThreshold = Duration(days: 7); + +/// Maximum concurrent downloads allowed +const maxConcurrentDownloads = 3; + +// ═══════════════════════════════════════════════════════════════════════════════ +// INSTALL OPERATION STATE MACHINE +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Represents an active install operation for an app. +/// When there's no operation, the app simply has no entry in the operations map. +sealed class InstallOperation { + /// The target file metadata being installed (works for both FileMetadata and SoftwareAsset) + final FileMetadata target; + + const InstallOperation({required this.target}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DOWNLOAD PHASE (Cancel allowed) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Waiting for download slot (max concurrent reached) +class DownloadQueued extends InstallOperation { + final String? displayName; + + const DownloadQueued({required super.target, this.displayName}); +} + +/// Actively downloading +class Downloading extends InstallOperation { + final double progress; + final String taskId; + + const Downloading({ + required super.target, + required this.progress, + required this.taskId, + }); + + Downloading copyWith({double? progress}) { + return Downloading( + target: target, + progress: progress ?? this.progress, + taskId: taskId, + ); + } +} + +/// Download paused by user +class DownloadPaused extends InstallOperation { + final double progress; + final String taskId; + + const DownloadPaused({ + required super.target, + required this.progress, + required this.taskId, + }); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// VERIFICATION PHASE (Kotlin is verifying hash) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Verifying downloaded file hash (happens in Kotlin, visible to UI) +class Verifying extends InstallOperation { + final String filePath; + + const Verifying({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PERMISSION PHASE (Explicit for UX feedback) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Waiting for user to grant install permission +class AwaitingPermission extends InstallOperation { + final String filePath; + + const AwaitingPermission({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// INSTALL PHASE (No cancel - Android controls) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// File verified, waiting for install to be triggered +class ReadyToInstall extends InstallOperation { + final String filePath; + + const ReadyToInstall({required super.target, required this.filePath}); +} + +/// Native installation in progress +class Installing extends InstallOperation { + final String filePath; + final bool isSilent; + final DateTime startedAt; + + Installing({ + required super.target, + required this.filePath, + this.isSilent = false, + DateTime? startedAt, + }) : startedAt = startedAt ?? DateTime.now(); +} + +/// System dialog was dismissed/backgrounded - user can retry +/// Different from Failed: this is recoverable with a tap +class AwaitingUserAction extends InstallOperation { + final String filePath; + + const AwaitingUserAction({required super.target, required this.filePath}); +} + +/// Uninstalling app (for force update: uninstall → install) +class Uninstalling extends InstallOperation { + final String filePath; + + const Uninstalling({required super.target, required this.filePath}); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FAILURE STATE (Dismiss available) +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Operation failed - may be retryable depending on type +class OperationFailed extends InstallOperation { + final FailureType type; + final String message; + final String? filePath; + + const OperationFailed({ + required super.target, + required this.type, + required this.message, + this.filePath, + }); + + /// Whether this requires force update (uninstall + install) + bool get needsForceUpdate => type == FailureType.certMismatch; +} + +/// Types of failures that can occur during install operations +enum FailureType { + /// Network error, timeout, server error during download + downloadFailed, + + /// Hash doesn't match expected - can retry with reckless mode + hashMismatch, + + /// File is corrupted or not a valid APK + invalidFile, + + /// Generic installation error + installFailed, + + /// Certificate/signature mismatch - needs force update (uninstall + install) + certMismatch, + + /// User doesn't have install permission + permissionDenied, + + /// Not enough storage space + insufficientStorage, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPER EXTENSIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +extension InstallOperationX on InstallOperation { + /// Whether this operation is in a download state (cancel allowed) + bool get isDownloading => + this is DownloadQueued || this is Downloading || this is DownloadPaused; + + /// Whether this operation is actively processing (not waiting for user) + bool get isActive => + this is Downloading || + this is Verifying || + this is Installing || + this is Uninstalling; + + /// Whether this operation is in the verification phase + bool get isVerifying => this is Verifying; + + /// Get file path if available + String? get filePath => switch (this) { + Verifying(:final filePath) => filePath, + AwaitingPermission(:final filePath) => filePath, + ReadyToInstall(:final filePath) => filePath, + Installing(:final filePath) => filePath, + AwaitingUserAction(:final filePath) => filePath, + Uninstalling(:final filePath) => filePath, + OperationFailed(:final filePath) => filePath, + _ => null, + }; +} diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 78130a1..bf5dfd6 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -1,11 +1,21 @@ import 'dart:async'; +import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; +import 'package:zapstore/services/package_manager/install_operation.dart'; import 'package:zapstore/utils/version_utils.dart'; +export 'install_operation.dart'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE INFO +// ═══════════════════════════════════════════════════════════════════════════════ + /// Information about an installed package class PackageInfo extends Equatable { const PackageInfo({ @@ -13,31 +23,23 @@ class PackageInfo extends Equatable { required this.version, required this.versionCode, required this.signatureHash, + this.name, this.installTime, this.canInstallSilently = false, }); - /// The application identifier (bundle ID) final String appId; - - /// Version string (e.g., "1.2.3") + final String? name; final String version; - - /// Numeric version code (Android) or null for other platforms final int? versionCode; - - /// APK signature hash or certificate hash final String signatureHash; - - /// When the app was installed final DateTime? installTime; - - /// Whether this package can be silently updated (we are the installer) final bool canInstallSilently; @override List get props => [ appId, + name, version, versionCode, signatureHash, @@ -46,40 +48,973 @@ class PackageInfo extends Equatable { ]; } -/// Package management interface -abstract class PackageManager extends StateNotifier> { - PackageManager(this.ref) : super([]); +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE MANAGER STATE +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Combined state for all package-related data +class PackageManagerState extends Equatable { + const PackageManagerState({ + this.installed = const {}, + this.operations = const {}, + }); + + /// Map of appId → installed package info + final Map installed; + + /// Map of appId → active install operation + final Map operations; + + PackageManagerState copyWith({ + Map? installed, + Map? operations, + }) { + return PackageManagerState( + installed: installed ?? this.installed, + operations: operations ?? this.operations, + ); + } + + @override + List get props => [installed, operations]; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PACKAGE MANAGER BASE CLASS +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Package management interface - the single source of truth for: +/// 1. Installed packages +/// 2. Active install operations (download/verify/install) +/// +/// Architecture: +/// - Download phase: Managed by background_downloader (can pause/resume/cancel) +/// - Install phase: Platform-specific, event-driven (no hanging awaits) +abstract class PackageManager extends StateNotifier { + PackageManager(this.ref) : super(const PackageManagerState()) { + _downloaderInit = _initializeDownloader(); + } - /// Riverpod ref for accessing storage and other dependencies. final Ref ref; + late final FileDownloader _downloader; + late final Future _downloaderInit; - /// Install an APK or package from the given file path. - /// Returns when installation completes (success or failure). - /// Throws on failure. + Future _ensureDownloaderReady() => _downloaderInit; + + /// Hook called when an app transitions into [ReadyToInstall]. + /// + /// Default behavior is to immediately start installation. Platforms that must + /// serialize installs (Android PackageInstaller UI) should override this to + /// queue/advance one install at a time. + @protected + void onInstallReady(String appId) { + unawaited(triggerInstall(appId)); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INITIALIZATION + // ═══════════════════════════════════════════════════════════════════════════ + + Future _initializeDownloader() async { + _downloader = FileDownloader(); + + try { + await _downloader.configure( + globalConfig: [ + (Config.requestTimeout, const Duration(seconds: 20)), + (Config.resourceTimeout, const Duration(minutes: 30)), + (Config.checkAvailableSpace, Config.never), + ], + androidConfig: [(Config.useCacheDir, false)], + ); + } catch (_) {} + + _downloader.configureNotificationForGroup( + FileDownloader.defaultGroup, + running: const TaskNotification( + 'Downloading {displayName}', + '{progress}', + ), + // No 'complete' notification - download completion immediately triggers install + // so showing "Download complete" is redundant and creates notification clutter + complete: null, + error: const TaskNotification('Download failed', '{displayName}'), + paused: const TaskNotification('Download paused', '{displayName}'), + progressBar: true, + ); + + _downloader.registerCallbacks( + taskStatusCallback: _handleDownloadUpdate, + taskProgressCallback: _handleDownloadUpdate, + ); + + await _restoreOperations(); + } + + @override + void dispose() { + _downloader.unregisterCallbacks(); + super.dispose(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // QUERIES + // ═══════════════════════════════════════════════════════════════════════════ + + bool isInstalled(String appId) => state.installed.containsKey(appId); + + PackageInfo? getInstalled(String appId) => state.installed[appId]; + + InstallOperation? getOperation(String appId) => state.operations[appId]; + + bool hasOperation(String appId) => state.operations.containsKey(appId); + + int countOperations() => + state.operations.values.whereType().length; + + List getReadyToInstall() => state.operations.entries + .where((e) => e.value is ReadyToInstall) + .map((e) => e.key) + .toList(); + + List getAwaitingUserAction() => state.operations.entries + .where((e) => e.value is AwaitingUserAction) + .map((e) => e.key) + .toList(); + + // ═══════════════════════════════════════════════════════════════════════════ + // STATE MANAGEMENT (Public for subclass use) + // ═══════════════════════════════════════════════════════════════════════════ + + void setOperation(String appId, InstallOperation op) { + state = state.copyWith(operations: {...state.operations, appId: op}); + } + + void clearOperation(String appId) { + state = state.copyWith( + operations: Map.from(state.operations)..remove(appId), + ); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD OPERATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Start download - returns false if operation already exists for this app + /// [displayName] is shown in system notification (defaults to appId if null) + Future startDownload( + String appId, + FileMetadata target, { + String? displayName, + }) async { + await _ensureDownloaderReady(); + if (hasOperation(appId)) return false; + + final downloadUrl = target.urls.firstOrNull; + if (downloadUrl == null || downloadUrl.isEmpty) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); + return false; + } + + // Check download slots - only count active downloads, not queued + final activeDownloads = countOperations(); + + if (activeDownloads >= maxConcurrentDownloads) { + setOperation( + appId, + DownloadQueued(target: target, displayName: displayName), + ); + return true; + } + + await _startDownloadTask( + appId, + target, + downloadUrl, + displayName: displayName, + ); + return true; + } + + /// Queue multiple downloads at once - immediately marks all as queued, + /// then starts up to [maxConcurrentDownloads] actual downloads. + /// This prevents UI confusion when "Update All" is tapped. + Future queueDownloads( + List<({String appId, FileMetadata target, String? displayName})> items, + ) async { + await _ensureDownloaderReady(); + + // Filter out items that already have operations + final toQueue = items.where((item) => !hasOperation(item.appId)).toList(); + if (toQueue.isEmpty) return; + + // First, mark ALL items as queued immediately for responsive UI + for (final item in toQueue) { + final downloadUrl = item.target.urls.firstOrNull; + if (downloadUrl == null || downloadUrl.isEmpty) { + setOperation( + item.appId, + OperationFailed( + target: item.target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); + } else { + setOperation( + item.appId, + DownloadQueued(target: item.target, displayName: item.displayName), + ); + } + } + + // Now process the queue to start actual downloads + _processQueuedDownload(); + } + + Future pauseDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op is! Downloading) return; + + try { + final task = await _downloader.taskForId(op.taskId); + if (task is DownloadTask) { + await _downloader.pause(task); + } + } catch (_) {} + } + + Future resumeDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op is! DownloadPaused) return; + + try { + final task = await _downloader.taskForId(op.taskId); + if (task is DownloadTask) { + await _downloader.resume(task); + setOperation( + appId, + Downloading( + target: op.target, + progress: op.progress, + taskId: op.taskId, + ), + ); + } + } catch (_) {} + } + + Future cancelDownload(String appId) async { + await _ensureDownloaderReady(); + final op = getOperation(appId); + if (op == null || !op.isDownloading) return; + + if (op is Downloading) { + try { + await _downloader.cancelTaskWithId(op.taskId); + } catch (_) {} + } else if (op is DownloadPaused) { + try { + await _downloader.cancelTaskWithId(op.taskId); + } catch (_) {} + } + + clearOperation(appId); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALL OPERATIONS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Trigger install from ReadyToInstall state + Future triggerInstall(String appId) async { + final op = getOperation(appId); + if (op is! ReadyToInstall) return; + + if (!await File(op.filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + // Directly perform install - permission was already checked when + // transitioning to ReadyToInstall state. Don't call _proceedToInstall + // here as that would re-set ReadyToInstall and call onInstallReady again, + // causing an infinite loop. + await _performInstall(appId, op.target, op.filePath); + } + + /// Retry install from AwaitingUserAction state + Future retryInstall(String appId) async { + final op = getOperation(appId); + if (op is! AwaitingUserAction) return; + + if (!await File(op.filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + await _performInstall(appId, op.target, op.filePath); + } + + /// Force update (uninstall + install) from OperationFailed with certMismatch + Future forceUpdate(String appId) async { + final op = getOperation(appId); + if (op is! OperationFailed || !op.needsForceUpdate) return; + + final filePath = op.filePath; + if (filePath == null || !await File(filePath).exists()) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'Downloaded file not found. Please download again.', + ), + ); + return; + } + + setOperation(appId, Uninstalling(target: op.target, filePath: filePath)); + + try { + await uninstall(appId); + await _performInstall(appId, op.target, filePath); + } catch (e) { + final message = e.toString(); + if (!message.contains('cancelled')) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.installFailed, + message: 'Force update failed: $message', + filePath: filePath, + ), + ); + } else { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.certMismatch, + message: + 'Certificate mismatch. Uninstall current version to update.', + filePath: filePath, + ), + ); + } + } + } + + /// Dismiss error and clean up + void dismissError(String appId) { + final op = getOperation(appId); + if (op is! OperationFailed) return; + + final filePath = op.filePath; + if (filePath != null) { + _deleteFile(filePath); + } + clearOperation(appId); + } + + /// Called when permission is granted. + /// Advances the specified app AND all other apps awaiting permission. + Future onPermissionGranted(String appId) async { + // Collect all apps that need to advance (AwaitingPermission or permissionDenied failures) + final toAdvance = {}; + + for (final entry in state.operations.entries) { + final id = entry.key; + final op = entry.value; + switch (op) { + case AwaitingPermission(:final target, :final filePath): + toAdvance[id] = (target, filePath); + case OperationFailed(:final target, :final type, :final filePath) + when type == FailureType.permissionDenied && filePath != null: + toAdvance[id] = (target, filePath); + default: + continue; + } + } + + if (toAdvance.isEmpty) return; + + // Advance the requested app first (for responsive UX) + if (toAdvance.containsKey(appId)) { + final (target, filePath) = toAdvance.remove(appId)!; + // Permission is granted now: transition to ReadyToInstall and let the + // platform decide whether to auto-start or queue. + setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); + onInstallReady(appId); + } + + // Advance remaining apps + for (final entry in toAdvance.entries) { + final (target, filePath) = entry.value; + setOperation( + entry.key, + ReadyToInstall(target: target, filePath: filePath), + ); + onInstallReady(entry.key); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // DOWNLOAD INTERNALS + // ═══════════════════════════════════════════════════════════════════════════ + + Future _startDownloadTask( + String appId, + FileMetadata target, + String downloadUrl, { + String? displayName, + }) async { + final fileName = '${target.hash}$packageExtension'; + final metaData = _encodeTaskMetadata(appId, target.id); + + final task = DownloadTask( + taskId: + '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}', + url: downloadUrl, + filename: fileName, + updates: Updates.statusAndProgress, + requiresWiFi: false, + retries: 3, + allowPause: true, + metaData: metaData, + displayName: displayName ?? appId, + ); + + setOperation( + appId, + Downloading(target: target, progress: 0.0, taskId: task.taskId), + ); + + try { + final result = await _downloader.enqueue(task); + if (!result) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to start download', + ), + ); + } + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to start download: $e', + ), + ); + } + } + + void _handleDownloadUpdate(TaskUpdate update) { + // Fast path: decode appId from task metadata (no O(n) scan on each tick). + String? appId; + InstallOperation? operation; + + final metaData = update.task.metaData; + if (metaData.isNotEmpty) { + final (decodedAppId, _) = _parseTaskMetadata(metaData); + if (decodedAppId != null) { + final op = getOperation(decodedAppId); + // Ensure the operation actually matches this taskId (metadata could be stale). + if (op is Downloading && op.taskId == update.task.taskId) { + appId = decodedAppId; + operation = op; + } else if (op is DownloadPaused && op.taskId == update.task.taskId) { + appId = decodedAppId; + operation = op; + } + } + } + + // Fallback: old tasks without metadata, or mismatched metadata. + if (appId == null || operation == null) { + for (final entry in state.operations.entries) { + final op = entry.value; + if (op is Downloading && op.taskId == update.task.taskId) { + appId = entry.key; + operation = op; + break; + } else if (op is DownloadPaused && op.taskId == update.task.taskId) { + appId = entry.key; + operation = op; + break; + } + } + } + if (appId == null || operation == null) return; + + if (update is TaskStatusUpdate) { + _handleDownloadStatusUpdate(appId, operation, update); + } else if (update is TaskProgressUpdate) { + _handleDownloadProgressUpdate(appId, operation, update); + } + } + + void _handleDownloadStatusUpdate( + String appId, + InstallOperation operation, + TaskStatusUpdate update, + ) { + final target = operation.target; + + switch (update.status) { + case TaskStatus.running: + if (operation is DownloadPaused) { + setOperation( + appId, + Downloading( + target: target, + progress: operation.progress, + taskId: operation.taskId, + ), + ); + } + break; + + case TaskStatus.paused: + if (operation is Downloading) { + setOperation( + appId, + DownloadPaused( + target: target, + progress: operation.progress, + taskId: operation.taskId, + ), + ); + } + break; + + case TaskStatus.complete: + unawaited( + _handleDownloadComplete(appId, target, update.task as DownloadTask), + ); + break; + + case TaskStatus.failed: + String error = 'Download failed'; + if (update.exception != null) { + error = update.exception.toString(); + if (error.length > 200) error = '${error.substring(0, 197)}...'; + } + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: error, + ), + ); + _processQueuedDownload(); + break; + + case TaskStatus.canceled: + clearOperation(appId); + _processQueuedDownload(); + break; + + default: + break; + } + } + + void _handleDownloadProgressUpdate( + String appId, + InstallOperation operation, + TaskProgressUpdate update, + ) { + if (operation is! Downloading) return; + + double progress = operation.progress; + if (update.progress.isFinite && + !update.progress.isNaN && + update.progress >= 0.0) { + progress = update.progress.clamp(0.0, 1.0); + } + + // Throttle: update state only when the displayed percentage changes. + final oldPercent = (operation.progress * 100).floor(); + final newPercent = (progress * 100).floor(); + if (newPercent == oldPercent) return; + + setOperation(appId, operation.copyWith(progress: progress)); + } + + Future _handleDownloadComplete( + String appId, + FileMetadata target, + DownloadTask task, + ) async { + try { + final filePath = await task.filePath(); + _processQueuedDownload(); + await _proceedToInstall(appId, target, filePath); + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.downloadFailed, + message: 'Failed to access downloaded file: $e', + ), + ); + _processQueuedDownload(); + } + } + + void _processQueuedDownload() { + var activeDownloads = countOperations(); + if (activeDownloads >= maxConcurrentDownloads) return; + + // Start downloads until we fill all available slots + for (final entry in state.operations.entries) { + if (activeDownloads >= maxConcurrentDownloads) break; + + if (entry.value is DownloadQueued) { + final queued = entry.value as DownloadQueued; + final downloadUrl = queued.target.urls.firstOrNull; + if (downloadUrl != null) { + _startDownloadTask( + entry.key, + queued.target, + downloadUrl, + displayName: queued.displayName, + ); + activeDownloads++; + } + } + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INSTALL FLOW + // ═══════════════════════════════════════════════════════════════════════════ + + /// Check permission and proceed to install + Future _proceedToInstall( + String appId, + FileMetadata target, + String filePath, + ) async { + if (!await hasPermission()) { + setOperation( + appId, + AwaitingPermission(target: target, filePath: filePath), + ); + try { + await requestPermission(); + } catch (e) { + setOperation( + appId, + OperationFailed( + target: target, + type: FailureType.permissionDenied, + message: e.toString().replaceFirst('Exception: ', ''), + filePath: filePath, + ), + ); + return; + } + + if (!await hasPermission()) return; + + // Permission was just granted - advance ALL waiting apps, not just this one + await onPermissionGranted(appId); + return; + } + + // Permission was already granted before we checked - just advance this app + setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); + onInstallReady(appId); + } + + /// Perform the actual installation + Future _performInstall( + String appId, + FileMetadata target, + String filePath, + ) async { + try { + await install( + appId, + filePath, + expectedHash: target.hash, + expectedSize: target.size ?? 0, + target: target, + ); + // For event-driven platforms, install() returns immediately + // and results come via events. For sync platforms, it completes here. + } catch (e) { + final message = e.toString().replaceFirst('Exception: ', ''); + + if (message.contains('cancelled') || message.contains('ABORTED')) { + setOperation( + appId, + AwaitingUserAction(target: target, filePath: filePath), + ); + return; + } + + final isCertMismatch = + message.contains('signatures do not match') || + message.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || + message.contains('UPDATE_INCOMPATIBLE'); + + final isHashMismatch = message.contains('Hash verification failed'); + final isInvalidFile = message.contains('Invalid APK file'); + + setOperation( + appId, + OperationFailed( + target: target, + type: isInvalidFile + ? FailureType.invalidFile + : isHashMismatch + ? FailureType.hashMismatch + : isCertMismatch + ? FailureType.certMismatch + : FailureType.installFailed, + message: isCertMismatch + ? 'Certificate mismatch. Uninstall current version to update.' + : message, + filePath: filePath, + ), + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // RESTORATION + // ═══════════════════════════════════════════════════════════════════════════ + + Future _restoreOperations() async { + try { + final records = await _downloader.database.allRecords( + group: FileDownloader.defaultGroup, + ); + + for (final record in records) { + final task = record.task; + if (task is! DownloadTask) continue; + + final metaData = task.metaData; + if (metaData.isEmpty) { + await _cleanupTask(task); + continue; + } + + final (appId, metadataId) = _parseTaskMetadata(metaData); + if (appId == null) { + await _cleanupTask(task); + continue; + } + + final taskAge = DateTime.now().difference(task.creationTime); + if (taskAge > staleOperationThreshold) { + await _cleanupTask(task); + continue; + } + + final fileMetadata = await _loadFileMetadata(metadataId, task.filename); + if (fileMetadata == null) { + await _cleanupTask(task); + continue; + } + + await _restoreOperation(appId, record, task, fileMetadata); + } + } catch (e) { + debugPrint('Failed to restore operations: $e'); + } + } + + Future _restoreOperation( + String appId, + TaskRecord record, + DownloadTask task, + FileMetadata fileMetadata, + ) async { + switch (record.status) { + case TaskStatus.complete: + final filePath = await task.filePath(); + if (await File(filePath).exists()) { + await syncInstalledPackages(); + if (state.installed.containsKey(appId)) { + _deleteFile(filePath); + } else { + await _proceedToInstall(appId, fileMetadata, filePath); + } + } + break; + + case TaskStatus.running: + case TaskStatus.enqueued: + case TaskStatus.waitingToRetry: + setOperation( + appId, + Downloading( + target: fileMetadata, + progress: record.progress, + taskId: task.taskId, + ), + ); + try { + await _downloader.resume(task); + } catch (_) {} + break; + + case TaskStatus.paused: + setOperation( + appId, + DownloadPaused( + target: fileMetadata, + progress: record.progress, + taskId: task.taskId, + ), + ); + break; + + default: + await _cleanupTask(task); + break; + } + } + + Future _cleanupTask(DownloadTask task) async { + try { + await _downloader.cancelTaskWithId(task.taskId); + } catch (_) {} + + try { + final filePath = await task.filePath(); + _deleteFile(filePath); + } catch (_) {} + + try { + await _downloader.database.deleteRecordWithId(task.taskId); + } catch (_) {} + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + void _deleteFile(String filePath) { + try { + final file = File(filePath); + if (file.existsSync()) { + file.deleteSync(); + } + } catch (_) {} + } + + String _encodeTaskMetadata(String appId, String metadataId) { + return '$appId|$metadataId'; + } + + (String? appId, String? metadataId) _parseTaskMetadata(String metaData) { + final parts = metaData.split('|'); + if (parts.length >= 2) { + return (parts[0], parts[1]); + } + return (metaData.isNotEmpty ? metaData : null, null); + } + + Future _loadFileMetadata( + String? metadataId, + String filename, + ) async { + final storage = ref.read(storageNotifierProvider.notifier); + + if (metadataId != null) { + try { + final results = storage.querySync( + RequestFilter(ids: {metadataId}).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + final dotIndex = filename.lastIndexOf('.'); + final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename; + if (hash.isNotEmpty) { + try { + final results = storage.querySync( + RequestFilter(search: hash).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + return null; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // PLATFORM ABSTRACT METHODS + // ═══════════════════════════════════════════════════════════════════════════ + + /// Install a package from file path. + /// For event-driven platforms (Android), this returns immediately and + /// results come via EventChannel. The `target` parameter is passed through + /// for state management. Future install( String appId, String filePath, { required String expectedHash, required int expectedSize, - bool skipVerification = false, + required FileMetadata target, }); - /// Uninstall a package by its app identifier Future uninstall(String appId); - /// Launch an installed app by its app identifier Future launchApp(String appId); - /// Check if an app can be installed (version compatibility, etc.) - /// - /// Compares by versionCode first when available. If version codes are equal - /// or unavailable, falls back to semantic version comparison. - /// - /// Uses the current state for installed packages check. + Future requestPermission(); + + Future hasPermission(); + + bool get supportsSilentInstall; + + String get platform; + + String get packageExtension; + + Future syncInstalledPackages(); + bool canInstall(FileMetadata m, String version, {int? versionCode}) { - final installed = state - .where((p) => p.appId == m.appIdentifier) - .firstOrNull; + final installed = state.installed[m.appIdentifier]; if (installed == null) return true; return canUpgrade( @@ -89,52 +1024,74 @@ abstract class PackageManager extends StateNotifier> { } bool canUpdate(FileMetadata m) { - final installed = state - .where((p) => p.appId == m.appIdentifier) - .firstOrNull; + final installed = state.installed[m.appIdentifier]; if (installed == null) return false; return canUpgrade( installed.versionCode?.toString() ?? installed.version, m.versionCode?.toString() ?? m.version, ); } - - /// Request installation permissions from the user/system - Future requestPermission(); - - /// Check if the app has installation permissions - Future hasPermission(); - - /// Whether the platform supports silent installation - bool get supportsSilentInstall; - - /// Target platform identifier used to filter file metadata (e.g., '#f' tag) - /// Example: 'android-arm64-v8a' for Android arm64 builds - String get platform; - - /// File extension for packages on this platform - /// Example: '.apk' for Android, '.ipa' for iOS, '.dmg' for macOS - String get packageExtension; - - /// Refresh the internal state of installed packages - Future syncInstalledPackages(); - - /// Check if a package is installed - bool isInstalled(String appId) { - return state.any((p) => p.appId == appId); - } - - /// Get package info for a specific app - PackageInfo? getInfo(String appId) { - try { - return state.firstWhere((p) => p.appId == appId); - } catch (e) { - return null; - } - } } +// ═══════════════════════════════════════════════════════════════════════════════ +// PROVIDERS +// ═══════════════════════════════════════════════════════════════════════════════ + final packageManagerProvider = - StateNotifierProvider>( + StateNotifierProvider( DummyPackageManager.new, ); + +final installedPackageProvider = Provider.family(( + ref, + appId, +) { + return ref.watch(packageManagerProvider.select((s) => s.installed[appId])); +}); + +final installOperationProvider = Provider.family(( + ref, + appId, +) { + return ref.watch(packageManagerProvider.select((s) => s.operations[appId])); +}); + +final activeOperationsCountProvider = Provider((ref) { + return ref.watch(packageManagerProvider.select((s) => s.operations.length)); +}); + +final readyToInstallCountProvider = Provider((ref) { + return ref.watch( + packageManagerProvider.select( + (s) => s.operations.values.whereType().length, + ), + ); +}); + +final awaitingUserActionCountProvider = Provider((ref) { + return ref.watch( + packageManagerProvider.select( + (s) => s.operations.values.whereType().length, + ), + ); +}); + +/// Returns all installed packages as a list +final allInstalledPackagesProvider = Provider>((ref) { + return ref.watch( + packageManagerProvider.select((s) => s.installed.values.toList()), + ); +}); + +/// Returns packages installed on device but not tracked in relay data. +/// [knownAppIds] should be the set of app identifiers from relay storage. +final systemOnlyPackagesProvider = + Provider.family, Set>((ref, knownAppIds) { + return ref.watch( + packageManagerProvider.select( + (s) => s.installed.values + .where((pkg) => !knownAppIds.contains(pkg.appId)) + .toList(), + ), + ); + }); diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 7e02a58..b6f1a39 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/version_utils.dart'; /// Categorized apps state class CategorizedApps { @@ -13,24 +13,30 @@ class CategorizedApps { required this.automaticUpdates, required this.manualUpdates, required this.upToDateApps, + required this.uncatalogedApps, this.isLoading = false, }); final List automaticUpdates; final List manualUpdates; final List upToDateApps; + + /// Apps installed on device but not found in any relay catalog + final List uncatalogedApps; final bool isLoading; CategorizedApps copyWith({ List? automaticUpdates, List? manualUpdates, List? upToDateApps, + List? uncatalogedApps, bool? isLoading, }) { return CategorizedApps( automaticUpdates: automaticUpdates ?? this.automaticUpdates, manualUpdates: manualUpdates ?? this.manualUpdates, upToDateApps: upToDateApps ?? this.upToDateApps, + uncatalogedApps: uncatalogedApps ?? this.uncatalogedApps, isLoading: isLoading ?? this.isLoading, ); } @@ -39,6 +45,7 @@ class CategorizedApps { automaticUpdates: [], manualUpdates: [], upToDateApps: [], + uncatalogedApps: [], isLoading: true, ); } @@ -55,8 +62,9 @@ class CategorizedAppsNotifier extends Notifier { } // Watch installed packages - this is the source of truth - final packages = ref.watch(packageManagerProvider); - final installedIds = packages.map((p) => p.appId).toSet(); + final pmState = ref.watch(packageManagerProvider); + final installedPackages = pmState.installed.values.toList(); + final installedIds = pmState.installed.keys.toSet(); if (installedIds.isEmpty) { _hasLoadedOnce = true; @@ -64,6 +72,7 @@ class CategorizedAppsNotifier extends Notifier { automaticUpdates: [], manualUpdates: [], upToDateApps: [], + uncatalogedApps: [], isLoading: false, ); } @@ -96,34 +105,56 @@ class CategorizedAppsNotifier extends Notifier { isLoading: !_hasLoadedOnce, ), StorageError() => CategorizedApps.empty.copyWith(isLoading: false), - StorageData(:final models) => _categorize(models, packages), + StorageData(:final models) => _categorize(models, installedPackages), }; } - CategorizedApps _categorize(List apps, List packages) { + CategorizedApps _categorize( + List apps, + List installedPackages, + ) { _hasLoadedOnce = true; final automaticUpdates = []; final manualUpdates = []; final upToDateApps = []; - // Only process apps that are actually installed - final installedApps = apps.where((a) => a.installedPackage != null); + // Build lookup map from passed-in data (avoids separate provider reads) + final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg}; + + // Track ALL apps returned from relay query as "cataloged" + final catalogedAppIds = apps.map((a) => a.identifier).toSet(); + + // Only process apps that are actually installed (check against our map) + final installedApps = apps.where((a) => installedMap.containsKey(a.identifier)); for (final app in installedApps) { - if (app.hasUpdate) { - // Look up silent install status from package info - final pkg = packages.firstWhereOrNull((p) => p.appId == app.identifier); - if (pkg?.canInstallSilently ?? false) { + final pkg = installedMap[app.identifier]!; + final latest = app.latestFileMetadata; + + // Determine if update available using local data + final hasUpdate = latest != null && _hasUpdate(pkg, latest); + + if (hasUpdate) { + if (pkg.canInstallSilently) { automaticUpdates.add(app); } else { manualUpdates.add(app); } - } else if (app.isUpdated) { + } else { + // No update available - app is up to date upToDateApps.add(app); } } + // Find installed packages without catalog metadata + final uncatalogedApps = installedPackages + .where((pkg) => !catalogedAppIds.contains(pkg.appId)) + .toList() + ..sort((a, b) => (a.name ?? a.appId) + .toLowerCase() + .compareTo((b.name ?? b.appId).toLowerCase())); + int byName(App a, App b) => (a.name ?? a.identifier) .toLowerCase() .compareTo((b.name ?? b.identifier).toLowerCase()); @@ -139,10 +170,19 @@ class CategorizedAppsNotifier extends Notifier { automaticUpdates: automaticUpdates, manualUpdates: manualUpdates, upToDateApps: upToDateApps, + uncatalogedApps: uncatalogedApps, isLoading: false, ); } + /// Check if an update is available by comparing versions + bool _hasUpdate(PackageInfo installed, FileMetadata latest) { + if (latest.versionCode != null && installed.versionCode != null) { + return latest.versionCode! > installed.versionCode!; + } + return canUpgrade(installed.version, latest.version); + } + void _fetchAuthors(Iterable apps) { final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); if (authorPubkeys.isEmpty) return; diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 9bb43dd..9c81e0c 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -28,7 +28,7 @@ extension AppExt on App { /// Returns PackageInfo if installed, otherwise null PackageInfo? get installedPackage => - ref.read(packageManagerProvider.notifier).getInfo(identifier); + ref.read(packageManagerProvider).installed[identifier]; /// Whether the app is installed on the device bool get isInstalled => diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 289d0aa..79e2145 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -8,7 +8,7 @@ import 'package:markdown/markdown.dart' as md; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/url_utils.dart'; -import 'package:zapstore/services/download/download_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/widgets/zap_widgets.dart'; import 'common/profile_avatar.dart'; @@ -446,9 +446,9 @@ class _AppCardUpdateButtonSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); - final hasDownload = downloadInfo != null; - final shouldShow = app.hasUpdate || hasDownload; + final operation = ref.watch(installOperationProvider(app.identifier)); + final hasOperation = operation != null; + final shouldShow = app.hasUpdate || hasOperation; if (!shouldShow) return const SizedBox.shrink(); @@ -478,9 +478,9 @@ class _AppCardZapEncouragementSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); + final operation = ref.watch(installOperationProvider(app.identifier)); - final isActive = downloadInfo?.isActiveOrInstalling ?? false; + final isActive = operation?.isActive ?? false; final hasLud16 = publisher?.lud16?.trim().isNotEmpty ?? false; final canZap = publisher != null && hasLud16; final shouldShow = isActive && !app.isRelaySigned && canZap; diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 23a6e29..d4859b4 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -9,7 +9,7 @@ import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/services/bookmarks_service.dart'; -import 'package:zapstore/services/download/download_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -244,7 +244,9 @@ class SocialActionsRow extends HookConsumerWidget { // Save to local storage and publish to relays await ref.storage.save({signedStack}); - ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social')); + ref.storage.publish({ + signedStack, + }, source: RemoteSource(relays: 'social')); if (context.mounted) { context.showInfo( @@ -909,12 +911,8 @@ class DebugVersionsSection extends HookConsumerWidget { Release release, FileMetadata metadata, ) async { - // Use the download service with specific metadata - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.downloadAppWithMetadata( - app.identifier, - app.name ?? app.identifier, - metadata, - ); + // Use PackageManager to start download + final pm = ref.read(packageManagerProvider.notifier); + await pm.startDownload(app.identifier, metadata, displayName: app.name); } } diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart index ac06cd4..98206e8 100644 --- a/lib/widgets/auth_widgets.dart +++ b/lib/widgets/auth_widgets.dart @@ -19,10 +19,8 @@ class SignInPrompt extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final packageManager = ref.watch(packageManagerProvider); - final isAmberInstalled = packageManager.any( - (p) => p.appId == kAmberPackageId, - ); + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); final isLoading = useState(false); Future handleSignIn() async { @@ -32,7 +30,10 @@ class SignInPrompt extends HookConsumerWidget { context.showInfo( 'Install Amber to sign in with your Nostr identity', actions: [ - ('Open Amber', () async => context.push('/search/app/$kAmberNaddr')), + ( + 'Open Amber', + () async => context.push('/search/app/$kAmberNaddr'), + ), ], ); } else { diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 6c2d8b1..50b4727 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -1,18 +1,15 @@ import 'package:async_button_builder/async_button_builder.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/download/download_service.dart'; +import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/trusted_signers_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/install_alert_dialog.dart'; -import 'package:zapstore/widgets/install_button_state.dart'; -import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/theme.dart'; class InstallButton extends ConsumerWidget { @@ -29,60 +26,43 @@ class InstallButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); - final installedPackage = ref - .watch(packageManagerProvider) - .where((p) => p.appId == app.identifier) - .firstOrNull; + final operation = ref.watch(installOperationProvider(app.identifier)); + final installedPkg = ref.watch(installedPackageProvider(app.identifier)); - // Listen for download completion to show permission dialog and auto-install - ref.listen(downloadInfoProvider(app.identifier), (previous, next) { - // Installation just failed - show error - if (next != null && - next.isReadyToInstall && - next.errorDetails != null && - previous?.errorDetails != next.errorDetails) { - if (context.mounted) { - if (next.errorDetails == 'CERTIFICATE_MISMATCH') { - context.showError( - 'Installation failed', - description: - 'Certificate mismatch detected. The app signature does not match the expected developer.', - ); - } else { - context.showError( - 'Installation failed', - description: next.errorDetails, - ); - } - } - return; - } + // Derive what to display + final isInstalled = installedPkg != null; + final hasUpdate = app.hasUpdate; + final hasDowngrade = app.hasDowngrade; + final hasRelease = release != null; + final fileMetadata = app.latestFileMetadata; - // Download just completed and ready to install (no error) - show permission dialog if needed - if (next != null && - next.isReadyToInstall && - next.errorDetails == null && - previous?.isReadyToInstall != true) { - _showPermissionDialogAndInstall(context, ref); + // Listen for errors to show toasts + ref.listen(installOperationProvider(app.identifier), (prev, next) { + if (next is OperationFailed && prev is! OperationFailed) { + _showErrorToast(context, next); } }); - // Determine current state from inputs using the extracted function - final state = determineInstallButtonState( - app: app, - installedPackage: installedPackage, - downloadInfo: downloadInfo, - release: release, - formatTotalSizeMb: _formatTotalSizeMb, + final fontSize = compact ? 13.0 : 16.0; + + // Build button based on operation state + final button = _buildButton( + context, + ref, + operation: operation, + isInstalled: isInstalled, + hasUpdate: hasUpdate, + hasDowngrade: hasDowngrade, + hasRelease: hasRelease, + fileMetadata: fileMetadata, + fontSize: fontSize, ); - // Compact mode - just the button without positioning or extra actions if (compact) { - return _buildButtonForState(context, ref, state); + return button; } - // Regular mode - positioned at bottom with extra action buttons + // Full layout with action buttons return Positioned( bottom: 0, left: 0, @@ -95,17 +75,10 @@ class InstallButton extends ConsumerWidget { child: SafeArea( child: Row( children: [ - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: _buildButtonForState(context, ref, state), - ), - ), - // Show action buttons only for installed apps - if (state is InstalledUpToDate || - state is UpdateAvailable || - state is DowngradeBlocked) ...[ - if (state is UpdateAvailable) ...[ + Expanded(child: SizedBox(height: 48, child: button)), + // Show action buttons only for installed apps with no active operation + if (isInstalled && operation == null) ...[ + if (hasUpdate) ...[ const SizedBox(width: 8), _buildOpenIconButton(context, ref), ], @@ -119,229 +92,190 @@ class InstallButton extends ConsumerWidget { ); } - /// Builds the appropriate button widget based on the current state - /// Uses pattern matching on sealed class for exhaustive handling - Widget _buildButtonForState( + Widget _buildButton( BuildContext context, - WidgetRef ref, - InstallButtonState state, - ) { - final fontSize = compact ? 13.0 : 16.0; + WidgetRef ref, { + required InstallOperation? operation, + required bool isInstalled, + required bool hasUpdate, + required bool hasDowngrade, + required bool hasRelease, + required FileMetadata? fileMetadata, + required double fontSize, + }) { + // Handle operation states first + if (operation != null) { + return switch (operation) { + DownloadQueued() => _buildSimpleButton( + context, + 'Queued', + null, + fontSize: fontSize, + showSpinner: true, + ), - return switch (state) { - // Not installed, ready to install - ReadyToInstall(:final hasRelease) => _buildAsyncButton( - context, - ref, - text: 'Install', - onPressed: hasRelease ? () => _startDownload(ref) : null, - isPrimary: true, - fontSize: fontSize, - needsTrustCheck: true, - ), + Downloading(:final progress) => _buildProgressButton( + context, + ref, + progress: progress, + text: _formatProgress(progress), + fontSize: fontSize, + onTap: () => _pauseDownload(ref), + ), - // Installed and up to date - InstalledUpToDate() => _buildAsyncButton( + DownloadPaused(:final progress) => _buildProgressButton( + context, + ref, + progress: progress, + text: '${_formatProgress(progress)} (paused)', + fontSize: fontSize, + onTap: () => _resumeDownload(ref), + ), + + Verifying() => _buildSimpleButton( + context, + 'Verifying...', + null, + fontSize: fontSize, + showSpinner: true, + ), + + AwaitingPermission() => _buildSimpleButton( + context, + 'Grant Permission', + () => _requestPermission(ref), + fontSize: fontSize, + isWarning: true, + ), + + ReadyToInstall() => _buildAsyncButton( + context, + ref, + text: isInstalled ? 'Update' : 'Install', + onPressed: () => _triggerInstall(ref), + fontSize: fontSize, + ), + + Installing(:final isSilent) => _buildSimpleButton( + context, + isSilent + ? (isInstalled ? 'Updating...' : 'Installing...') + : (isInstalled ? 'Requesting update' : 'Requesting installation'), + null, // Not tappable - auto-transitions to retry after 10s if no response + fontSize: fontSize, + showSpinner: true, + ), + + AwaitingUserAction() => _buildSimpleButton( + context, + 'Tap to retry', + () => _retryInstall(ref), + fontSize: fontSize, + isWarning: true, + icon: Icons.refresh, + ), + + Uninstalling() => _buildSimpleButton( + context, + 'Uninstalling...', + null, + fontSize: fontSize, + showSpinner: true, + ), + + OperationFailed(:final type, :final needsForceUpdate) => + _buildErrorButton( + context, + ref, + type: type, + needsForceUpdate: needsForceUpdate, + fontSize: fontSize, + ), + }; + } + + // No operation - show based on installed state + if (isInstalled) { + if (hasDowngrade) { + return _buildSimpleButton( + context, + "Can't downgrade", + null, + fontSize: fontSize, + isDisabled: true, + ); + } + + if (hasUpdate) { + return _buildAsyncButton( + context, + ref, + text: 'Update', + onPressed: hasRelease && fileMetadata != null + ? () => _startDownload(context, ref, fileMetadata) + : null, + fontSize: fontSize, + ); + } + + // Up to date + return _buildAsyncButton( context, ref, text: 'Open', onPressed: () => _openApp(context, ref), - isPrimary: true, fontSize: fontSize, needsTrustCheck: false, - ), + ); + } - // Update available - UpdateAvailable(:final hasRelease) => _buildAsyncButton( - context, - ref, - text: 'Update', - onPressed: hasRelease ? () => _startDownload(ref) : null, - isPrimary: true, - fontSize: fontSize, - needsTrustCheck: false, - ), - - // Downgrade blocked - DowngradeBlocked() => _buildSimpleButton( - context, - "Can't downgrade", - null, - isPrimary: false, - showSpinner: false, - fontSize: fontSize, - isDowngrade: true, - ), - - // Download in progress - Downloading(:final progress, :final totalSizeMb) => _buildProgressButton( - context, - ref, - progress: progress, - text: _formatDownloadProgress(progress, totalSizeMb), - fontSize: fontSize, - onTap: () => _pauseDownload(context, ref), - ), - - // Download paused - DownloadPaused(:final progress, :final totalSizeMb) => - _buildProgressButton( - context, - ref, - progress: progress, - text: _formatDownloadProgress(progress, totalSizeMb, paused: true), - fontSize: fontSize, - onTap: () => _resumeDownload(context, ref), - ), - - // Download enqueued - DownloadEnqueued(:final isUpdate) => _buildSimpleButton( - context, - isUpdate ? 'Update' : 'Install', - () => _cancelAndRestart(ref), - isPrimary: true, - fontSize: fontSize, - ), - - // Downloaded, ready to install - DownloadedReadyToInstall(:final isUpdate) => AsyncButtonBuilder( - onPressed: () => _showPermissionDialogAndInstall(context, ref), - builder: (context, child, callback, buttonState) { - return _buildSimpleButton( - context, - buttonState.maybeWhen( - loading: () => 'Installing...', - orElse: () => isUpdate ? 'Update' : 'Install', - ), - buttonState.maybeWhen(loading: () => null, orElse: () => callback), - isPrimary: true, - showSpinner: buttonState.maybeWhen( - loading: () => true, - orElse: () => false, - ), - fontSize: fontSize, - ); - }, - child: Text( - isUpdate ? 'Update' : 'Install', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - onError: () { - if (context.mounted) { - context.showError( - 'Installation failed', - description: - 'The package could not be installed. Check storage space and try again.', - ); - } - }, - ), - - // Certificate mismatch - requires force update - ForceUpdateRequired() => _buildSimpleButton( - context, - 'Force update', - () => _showForceUpdateDialog(ref, context), - isPrimary: false, - fontSize: fontSize, - isError: true, - ), - - // Installing - Installing(:final isSilent) => _buildSimpleButton( - context, - isSilent ? 'Installing...' : 'Requesting installation', - null, - isPrimary: true, - showSpinner: true, - fontSize: fontSize, - ), - - // Failed - Failed(:final canRetryReckless, :final downloadInfo) => - _buildSimpleButton( - context, - 'Error (tap for details)', - () => _handleErrorTap(ref, context, downloadInfo, canRetryReckless), - isPrimary: false, - isError: true, - fontSize: fontSize, - ), - }; + // Not installed + return _buildAsyncButton( + context, + ref, + text: 'Install', + onPressed: hasRelease && fileMetadata != null + ? () => _startDownload(context, ref, fileMetadata) + : null, + fontSize: fontSize, + needsTrustCheck: true, + ); } - /// Helper to format download progress text - String _formatDownloadProgress( - double progress, - String? totalSizeMb, { - bool paused = false, - }) { + String _formatProgress(double progress) { final percent = (progress * 100).round(); - final pausedSuffix = paused ? ' (paused)' : ''; - return totalSizeMb != null - ? '$percent% of $totalSizeMb$pausedSuffix' - : '$percent%$pausedSuffix'; + final sizeMb = _formatTotalSizeMb(); + return sizeMb != null ? '$percent% of $sizeMb' : '$percent%'; } - /// Builds async button with trust check for fresh installs + String? _formatTotalSizeMb() { + final sizeBytes = app.latestFileMetadata?.size; + if (sizeBytes == null || sizeBytes <= 0) return null; + final mb = sizeBytes / (1024 * 1024); + return '${mb.toStringAsFixed(1)} MB'; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BUTTON BUILDERS + // ═══════════════════════════════════════════════════════════════════════════ + Widget _buildAsyncButton( BuildContext context, WidgetRef ref, { required String text, required Future Function()? onPressed, - required bool isPrimary, required double fontSize, - required bool needsTrustCheck, + bool needsTrustCheck = false, }) { - // Store error message for display - String? lastError; - return AsyncButtonBuilder( onPressed: onPressed == null ? null : () async { - // Trust check only for fresh installs if (needsTrustCheck) { - final signerPubkey = app.author.value?.pubkey; - bool shouldShowDialog = true; - if (signerPubkey != null) { - try { - final isTrusted = await ref - .read(trustServiceProvider) - .isSignerTrusted(signerPubkey); - shouldShowDialog = !isTrusted; - } catch (_) { - shouldShowDialog = true; - } - } - - if (shouldShowDialog) { - if (!context.mounted) return; - final result = - await showBaseDialog<({bool trustPermanently})>( - context: context, - dialog: InstallAlertDialog(app: app), - ); - if (result == null) return; - if (result.trustPermanently && signerPubkey != null) { - try { - await ref - .read(trustServiceProvider) - .addTrustedSigner(signerPubkey); - } catch (_) { - // ignore persistence errors - } - } - } - } - - try { - await onPressed(); - } catch (e) { - // Store the error message for display - lastError = e.toString(); - rethrow; + final proceed = await _checkTrust(context, ref); + if (!proceed) return; } + await onPressed(); }, builder: (context, child, callback, buttonState) { return _buildSimpleButton( @@ -351,27 +285,178 @@ class InstallButton extends ConsumerWidget { orElse: () => text, ), buttonState.maybeWhen(loading: () => null, orElse: () => callback), - isPrimary: isPrimary, - showSpinner: false, fontSize: fontSize, ); }, - child: Text( - text, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + child: Text(text), onError: () { if (context.mounted) { - // Show the actual error message if available - final message = lastError != null - ? lastError!.replaceFirst('Exception: ', '') - : 'Operation failed. Please try again.'; - context.showError(message); + context.showError('Operation failed. Please try again.'); } }, ); } + Widget _buildProgressButton( + BuildContext context, + WidgetRef ref, { + required double progress, + required String text, + required double fontSize, + VoidCallback? onTap, + }) { + const actionColor = AppColors.darkActionPrimary; + final darkerAction = Color.alphaBlend( + Colors.black.withValues(alpha: 0.22), + actionColor, + ); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + constraints: const BoxConstraints(minHeight: 38), + decoration: BoxDecoration( + color: actionColor, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + // Progress fill + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + heightFactor: 1.0, + child: Container( + decoration: BoxDecoration( + color: darkerAction, + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ), + // Text + Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 10, + horizontal: 16, + ), + child: Text( + text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSimpleButton( + BuildContext context, + String text, + VoidCallback? onPressed, { + double fontSize = 16.0, + bool showSpinner = false, + bool isError = false, + bool isWarning = false, + bool isDisabled = false, + IconData? icon, + }) { + final theme = Theme.of(context); + + Widget child = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[Icon(icon, size: 18), const SizedBox(width: 6)], + Flexible( + child: Text( + text, + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + if (showSpinner) ...[ + const SizedBox(width: 8), + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ], + ); + + Color backgroundColor; + Color foregroundColor = Colors.white; + + if (isError) { + backgroundColor = theme.colorScheme.error; + } else if (isWarning) { + backgroundColor = Colors.amber.shade700; + } else if (isDisabled) { + backgroundColor = theme.colorScheme.outline; + foregroundColor = theme.colorScheme.onSurface.withValues(alpha: 0.5); + } else { + backgroundColor = AppColors.darkActionPrimary; + } + + return FilledButton( + onPressed: isDisabled ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: backgroundColor, + disabledBackgroundColor: backgroundColor, + disabledForegroundColor: foregroundColor, + foregroundColor: foregroundColor, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: child, + ); + } + + Widget _buildErrorButton( + BuildContext context, + WidgetRef ref, { + required FailureType type, + required bool needsForceUpdate, + required double fontSize, + }) { + if (needsForceUpdate) { + return _buildSimpleButton( + context, + 'Force update', + () => _showForceUpdateDialog(ref, context), + fontSize: fontSize, + isError: true, + ); + } + + return _buildSimpleButton( + context, + 'Error (tap for details)', + () => _handleErrorTap(ref, context), + fontSize: fontSize, + isError: true, + ); + } + Widget _buildUninstallIconButton(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); return IconButton.filled( @@ -387,8 +472,6 @@ class InstallButton extends ConsumerWidget { ); } - // Removed: update logic moved to AppExt - Widget _buildOpenIconButton(BuildContext context, WidgetRef ref) { const actionColor = AppColors.darkActionPrimary; return IconButton.filled( @@ -404,301 +487,111 @@ class InstallButton extends ConsumerWidget { ); } - Widget _buildProgressButton( - BuildContext context, - WidgetRef ref, { - required double progress, - required String text, - required double fontSize, - VoidCallback? onTap, - }) { - const actionColor = AppColors.darkActionPrimary; - final darkerAction = Color.alphaBlend( - Colors.black.withValues(alpha: 0.22), - actionColor, - ); + // ═══════════════════════════════════════════════════════════════════════════ + // ACTIONS + // ═══════════════════════════════════════════════════════════════════════════ - return FilledButton( - onPressed: onTap ?? () => _cancelDownload(context, ref), - style: FilledButton.styleFrom( - backgroundColor: Colors.transparent, - disabledBackgroundColor: Colors.transparent, - disabledForegroundColor: Colors.white, - foregroundColor: Colors.white, - elevation: 0, - shadowColor: Colors.transparent, - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Stack( - children: [ - Positioned.fill(child: Container(color: actionColor)), - Positioned.fill( - child: Align( - alignment: Alignment.centerLeft, - child: FractionallySizedBox( - widthFactor: progress, - heightFactor: 1.0, - child: Container(color: darkerAction), - ), - ), - ), - Center( - child: Text( - text, - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ); - } + Future _checkTrust(BuildContext context, WidgetRef ref) async { + final signerPubkey = app.author.value?.pubkey; + bool shouldShowDialog = true; - Widget _buildSimpleButton( - BuildContext context, - String text, - VoidCallback? onPressed, { - bool isPrimary = true, - bool isError = false, - bool showSpinner = false, - double fontSize = 16.0, - bool isDowngrade = false, - }) { - final theme = Theme.of(context); - - Widget child = Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Flexible( - child: Text( - text, - style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - if (showSpinner) ...[ - const SizedBox(width: 8), - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ), - ], - ], - ); - - if (isError) { - return FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: theme.colorScheme.error, - disabledBackgroundColor: theme.colorScheme.error, - disabledForegroundColor: Colors.white, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: child, - ); - } else if (isDowngrade) { - // Greyed out disabled button for downgrades (opaque) - final greyColor = theme.colorScheme.outline; - return FilledButton( - onPressed: null, // disabled - style: FilledButton.styleFrom( - backgroundColor: greyColor, - disabledBackgroundColor: greyColor, - disabledForegroundColor: theme.colorScheme.onSurface.withValues( - alpha: 0.5, - ), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: child, - ); - } else { - const actionColor = AppColors.darkActionPrimary; - final backgroundColor = isPrimary - ? actionColor - : actionColor.withValues(alpha: 0.8); - - return FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: backgroundColor, - disabledBackgroundColor: backgroundColor, - disabledForegroundColor: Colors.white, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: child, - ); + if (signerPubkey != null) { + try { + final isTrusted = await ref + .read(trustServiceProvider) + .isSignerTrusted(signerPubkey); + shouldShowDialog = !isTrusted; + } catch (_) { + shouldShowDialog = true; + } } + + if (shouldShowDialog) { + if (!context.mounted) return false; + final result = await showBaseDialog<({bool trustPermanently})>( + context: context, + dialog: InstallAlertDialog(app: app), + ); + if (result == null) return false; + if (result.trustPermanently && signerPubkey != null) { + try { + await ref.read(trustServiceProvider).addTrustedSigner(signerPubkey); + } catch (_) {} + } + } + return true; } - Future _startDownload(WidgetRef ref) async { - if (release == null) return; - - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.downloadApp(app, release!); - } - - /// Shows permission dialog (if needed) and triggers installation - Future _showPermissionDialogAndInstall( + Future _startDownload( BuildContext context, WidgetRef ref, + FileMetadata fileMetadata, ) async { - if (!context.mounted) return; - - try { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.installFromDownloaded(app.identifier); - } catch (e) { - if (context.mounted) { - context.showError( - 'Installation failed', - description: - 'The package could not be installed. Check storage space and try again.', - ); - } - } + final pm = ref.read(packageManagerProvider.notifier); + await pm.startDownload(app.identifier, fileMetadata, displayName: app.name); } - Future _resumeDownload(BuildContext context, WidgetRef ref) async { - try { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.resumeDownload(app.identifier); - } catch (e) { - if (context.mounted) { - context.showError('Failed to resume download', description: '$e'); - } - } + void _pauseDownload(WidgetRef ref) { + final pm = ref.read(packageManagerProvider.notifier); + pm.pauseDownload(app.identifier); } - Future _pauseDownload(BuildContext context, WidgetRef ref) async { - try { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.pauseDownload(app.identifier); - } catch (e) { - if (context.mounted) { - context.showError('Failed to pause download', description: '$e'); - } - } + void _resumeDownload(WidgetRef ref) { + final pm = ref.read(packageManagerProvider.notifier); + pm.resumeDownload(app.identifier); } - Future _cancelDownload(BuildContext context, WidgetRef ref) async { - try { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.cancelDownload(app.identifier); - } catch (e) { - if (context.mounted) { - context.showError('Failed to cancel download', description: '$e'); - } + Future _triggerInstall(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + await pm.triggerInstall(app.identifier); + } + + Future _retryInstall(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + await pm.retryInstall(app.identifier); + } + + Future _requestPermission(WidgetRef ref) async { + final pm = ref.read(packageManagerProvider.notifier); + + // Check if permission is already granted (user may have granted it in settings) + if (await pm.hasPermission()) { + await pm.onPermissionGranted(app.identifier); + return; } + + // Request permission (opens settings) + await pm.requestPermission(); + + // Check again after returning from settings + if (await pm.hasPermission()) { + await pm.onPermissionGranted(app.identifier); + } + // If still no permission, user will need to tap button again } Future _openApp(BuildContext context, WidgetRef ref) async { try { - final packageManager = ref.read(packageManagerProvider.notifier); - context.showInfo('Launching ${app.name ?? app.identifier}...'); - await packageManager.launchApp(app.identifier); + final pm = ref.read(packageManagerProvider.notifier); + await pm.launchApp(app.identifier); } catch (e) { if (!context.mounted) return; context.showError( 'Failed to launch ${app.name ?? app.identifier}', - description: - 'The app may have been uninstalled or moved. Try reinstalling.\n\n$e', + description: 'The app may have been uninstalled or moved.\n\n$e', ); } } - Future _cancelAndRestart(WidgetRef ref) async { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.cancelDownload(app.identifier); - } - - String? _formatTotalSizeMb(Release? release) { - try { - final sizeBytes = app.latestFileMetadata?.size; - if (sizeBytes == null || sizeBytes <= 0) return null; - final mb = sizeBytes / (1024 * 1024); - return '${mb.toStringAsFixed(1)} MB'; - } catch (_) { - return null; - } - } - - void _handleErrorTap( - WidgetRef ref, - BuildContext context, - DownloadInfo downloadInfo, - bool canRetryReckless, - ) { - final errorMessage = - downloadInfo.errorDetails ?? 'Download failed. Please try again.'; - - context.showError( - errorMessage, - actions: canRetryReckless - ? [ - ( - '⚠️ Proceed anyway (RECKLESS)', - () async { - // Retry installation with skipVerification=true - // Update download state to show installing - final downloadService = ref.read( - downloadServiceProvider.notifier, - ); - // Explicitly allow install for this override flow and install via the queue. - downloadService.markReadyToInstall( - app.identifier, - skipVerificationOnInstall: true, - ); - await downloadService.installFromDownloaded(app.identifier); - }, - ), - ] - : [], - ); - - // Only cancel if user doesn't have the option to proceed - // For hash errors, keep the download so user can proceed - if (!canRetryReckless) { - final downloadService = ref.read(downloadServiceProvider.notifier); - downloadService.cancelDownload(app.identifier); - } - } - Future _uninstallApp(WidgetRef ref, BuildContext context) async { try { - final packageManager = ref.read(packageManagerProvider.notifier); - await packageManager.uninstall(app.identifier); - // Only reaches here after successful uninstall + final pm = ref.read(packageManagerProvider.notifier); + await pm.uninstall(app.identifier); if (context.mounted) { context.showInfo('${app.name ?? app.identifier} has been uninstalled'); } } catch (e) { if (context.mounted) { - // Don't show error for user cancellation final message = e.toString(); if (!message.contains('cancelled')) { context.showError('Uninstall failed', description: '$e'); @@ -707,21 +600,39 @@ class InstallButton extends ConsumerWidget { } } + void _handleErrorTap(WidgetRef ref, BuildContext context) { + final operation = ref.read(installOperationProvider(app.identifier)); + if (operation is! OperationFailed) return; + + context.showError(operation.message, actions: const []); + + // Always clear error after showing it (reckless mode removed). + final pm = ref.read(packageManagerProvider.notifier); + pm.dismissError(app.identifier); + } + + void _showErrorToast(BuildContext context, OperationFailed operation) { + if (operation.type == FailureType.certMismatch) { + context.showError( + 'Certificate mismatch', + description: 'The app signature does not match. Force update required.', + ); + } + // Other errors are shown when user taps the error button + } + Future _showForceUpdateDialog( WidgetRef ref, BuildContext context, ) async { - // Get version and certificate info - final installedPackage = ref - .read(packageManagerProvider) - .where((p) => p.appId == app.identifier) - .firstOrNull; - final updateVersion = app.latestFileMetadata?.version ?? 'Unknown'; - final currentVersion = installedPackage?.version ?? 'Unknown'; - final currentCertHash = installedPackage?.signatureHash ?? 'Unknown'; - final updateCertHash = app.latestFileMetadata?.certificateHash ?? 'Unknown'; + final installedPkg = ref.read(installedPackageProvider(app.identifier)); + final operation = ref.read(installOperationProvider(app.identifier)); + if (operation is! OperationFailed) return; - // Get author profile + final updateVersion = operation.target.version; + final currentVersion = installedPkg?.version ?? 'Unknown'; + final currentCertHash = installedPkg?.signatureHash ?? 'Unknown'; + final updateCertHash = operation.target.apkSignatureHash ?? 'Unknown'; final author = app.author.value; final shouldProceed = await showBaseDialog( @@ -735,22 +646,21 @@ class InstallButton extends ConsumerWidget { title: const BaseDialogTitle('Certificate Mismatch'), content: BaseDialogContent( children: [ - Row( - children: [ - Expanded( - child: AuthorContainer( - profile: author!, - beforeText: 'This update was published by', - afterText: ' but signed with a different certificate.', - oneLine: false, - size: 14, + if (author != null) + Row( + children: [ + Expanded( + child: AuthorContainer( + profile: author, + beforeText: 'This update was published by', + afterText: ' but signed with a different certificate.', + oneLine: false, + size: 14, + ), ), - ), - ], - ), + ], + ), const SizedBox(height: 12), - - // Compact version and certificate comparison _buildCompactHashRow( dialogContext, 'Current ($currentVersion)', @@ -765,13 +675,15 @@ class InstallButton extends ConsumerWidget { 'New version certificate', ), const SizedBox(height: 12), - Text( - 'Android security prevents updating apps signed by different certificates. Contact the publisher for details.', + const Text( + 'Android security prevents updating apps signed by different certificates. ' + 'Contact the publisher for details.', style: TextStyle(fontSize: 14), ), const SizedBox(height: 8), - Text( - 'To proceed anyway, uninstall the current version and install the new one. ALL APP DATA WILL BE LOST.', + const Text( + 'To proceed anyway, uninstall the current version and install the new one. ' + 'ALL APP DATA WILL BE LOST.', style: TextStyle(fontSize: 14), ), ], @@ -796,21 +708,8 @@ class InstallButton extends ConsumerWidget { if (shouldProceed == true && context.mounted) { try { - final downloadService = ref.read(downloadServiceProvider.notifier); - final packageManager = ref.read(packageManagerProvider.notifier); - - final downloadInfo = downloadService.getDownloadInfo(app.identifier); - if (downloadInfo == null) { - throw Exception('Download not found'); - } - - downloadService.clearError(app.identifier); - - await packageManager.uninstall(app.identifier); - - // Queue install through DownloadService (single source of truth for installs). - downloadService.markReadyToInstall(app.identifier); - await downloadService.installFromDownloaded(app.identifier); + final pm = ref.read(packageManagerProvider.notifier); + await pm.forceUpdate(app.identifier); } catch (e) { final message = e.toString(); if (context.mounted && !message.contains('cancelled')) { @@ -820,6 +719,12 @@ class InstallButton extends ConsumerWidget { } } + String _abbr(String v) { + final t = v.trim(); + if (t.length <= 12) return t; + return '${t.substring(0, 6)}...${t.substring(t.length - 6)}'; + } + Widget _buildCompactHashRow( BuildContext context, String versionLabel, @@ -829,17 +734,17 @@ class InstallButton extends ConsumerWidget { return Row( children: [ Expanded( - child: Text.rich( - TextSpan( + child: RichText( + text: TextSpan( style: Theme.of(context).textTheme.bodyMedium, children: [ TextSpan( text: '$versionLabel → ', - style: TextStyle(fontWeight: FontWeight.w600), + style: const TextStyle(fontWeight: FontWeight.w600), ), TextSpan( - text: hash.abbreviate(), - style: TextStyle(fontFamily: 'monospace'), + text: _abbr(hash), + style: const TextStyle(fontFamily: 'monospace'), ), ], ), diff --git a/lib/widgets/install_button_state.dart b/lib/widgets/install_button_state.dart deleted file mode 100644 index 6d77e84..0000000 --- a/lib/widgets/install_button_state.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:background_downloader/background_downloader.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/services/download/download_service.dart'; -import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/utils/extensions.dart'; - -/// Represents all possible button states in the install flow. -/// This state machine ensures UI consistency and prevents invalid states. -sealed class InstallButtonState { - const InstallButtonState(); -} - -/// App is not installed, ready to install -class ReadyToInstall extends InstallButtonState { - final bool hasRelease; - const ReadyToInstall({required this.hasRelease}); -} - -/// App is installed and up to date, can be opened -class InstalledUpToDate extends InstallButtonState { - const InstalledUpToDate(); -} - -/// App is installed, update available -class UpdateAvailable extends InstallButtonState { - final bool hasRelease; - const UpdateAvailable({required this.hasRelease}); -} - -/// App is installed, but relay has older version (downgrade not allowed) -class DowngradeBlocked extends InstallButtonState { - const DowngradeBlocked(); -} - -/// Download is in progress -class Downloading extends InstallButtonState { - final double progress; - final String? totalSizeMb; - const Downloading({required this.progress, this.totalSizeMb}); -} - -/// Download is paused -class DownloadPaused extends InstallButtonState { - final double progress; - final String? totalSizeMb; - const DownloadPaused({required this.progress, this.totalSizeMb}); -} - -/// Download is enqueued or waiting to retry -class DownloadEnqueued extends InstallButtonState { - final bool isUpdate; - const DownloadEnqueued({required this.isUpdate}); -} - -/// Download completed, ready to install from file -class DownloadedReadyToInstall extends InstallButtonState { - final bool isUpdate; - const DownloadedReadyToInstall({required this.isUpdate}); -} - -/// Certificate mismatch - force update required (uninstall + install) -class ForceUpdateRequired extends InstallButtonState { - const ForceUpdateRequired(); -} - -/// Installation is in progress -class Installing extends InstallButtonState { - final bool isSilent; - const Installing({this.isSilent = false}); -} - -/// Download or installation failed -class Failed extends InstallButtonState { - final String errorMessage; - final bool canRetryReckless; - final DownloadInfo downloadInfo; - const Failed({ - required this.errorMessage, - required this.canRetryReckless, - required this.downloadInfo, - }); -} - -/// Determines the current button state from app, download info, and release data. -/// This is the single source of truth for button state logic. -InstallButtonState determineInstallButtonState({ - required App app, - required PackageInfo? installedPackage, - required DownloadInfo? downloadInfo, - required Release? release, - required String? Function(Release?) formatTotalSizeMb, -}) { - final isInstalled = installedPackage != null; - final hasUpdate = app.hasUpdate; - final hasDowngrade = app.hasDowngrade; - final hasRelease = release != null; - final canInstallSilently = installedPackage?.canInstallSilently ?? false; - - // PRIORITY 1: Check active download states first - if (downloadInfo != null) { - // Ready to install from downloaded file - if (downloadInfo.isReadyToInstall) { - // Check for certificate mismatch - if (downloadInfo.errorDetails == 'CERTIFICATE_MISMATCH') { - return const ForceUpdateRequired(); - } - return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate); - } - - // Installation in progress - if (downloadInfo.isInstalling) { - return Installing(isSilent: canInstallSilently); - } - - // Check task status - switch (downloadInfo.status) { - case TaskStatus.running: - return Downloading( - progress: downloadInfo.progress, - totalSizeMb: formatTotalSizeMb(release), - ); - - case TaskStatus.paused: - return DownloadPaused( - progress: downloadInfo.progress, - totalSizeMb: formatTotalSizeMb(release), - ); - - case TaskStatus.enqueued: - case TaskStatus.waitingToRetry: - return DownloadEnqueued(isUpdate: isInstalled && hasUpdate); - - case TaskStatus.complete: - return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate); - - case TaskStatus.failed: - final errorMessage = - downloadInfo.errorDetails ?? 'Download failed. Please try again.'; - final isHashError = - errorMessage.contains('Hash verification failed') && - !errorMessage.contains('Invalid APK file'); - return Failed( - errorMessage: errorMessage, - canRetryReckless: isHashError, - downloadInfo: downloadInfo, - ); - - case TaskStatus.canceled: - case TaskStatus.notFound: - break; - } - } - - // PRIORITY 2: Check installation state (no active download) - if (isInstalled) { - // Downgrade case - if (hasDowngrade) { - return const DowngradeBlocked(); - } - - // Update available - if (hasUpdate) { - return UpdateAvailable(hasRelease: hasRelease); - } - - // Up to date - return const InstalledUpToDate(); - } - - // PRIORITY 3: Not installed, no active download - return ReadyToInstall(hasRelease: hasRelease); -} diff --git a/lib/widgets/sign_in_button.dart b/lib/widgets/sign_in_button.dart index 896ba65..d1a25e4 100644 --- a/lib/widgets/sign_in_button.dart +++ b/lib/widgets/sign_in_button.dart @@ -22,10 +22,8 @@ class SignInButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final packageManager = ref.watch(packageManagerProvider); - final isAmberInstalled = packageManager.any( - (p) => p.appId == kAmberPackageId, - ); + final pmState = ref.watch(packageManagerProvider); + final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId); return AsyncButtonBuilder( onPressed: () async { @@ -33,7 +31,10 @@ class SignInButton extends ConsumerWidget { context.showInfo( 'Install Amber to sign in with your Nostr identity', actions: [ - ('Open Amber', () async => context.push('/search/app/$kAmberNaddr')), + ( + 'Open Amber', + () async => context.push('/search/app/$kAmberNaddr'), + ), ], ); } else { diff --git a/lib/widgets/version_pill_widget.dart b/lib/widgets/version_pill_widget.dart index b9a13f3..c4fd368 100644 --- a/lib/widgets/version_pill_widget.dart +++ b/lib/widgets/version_pill_widget.dart @@ -23,7 +23,9 @@ class VersionPillWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Watch package manager state for reactivity - final installedPackages = ref.watch(packageManagerProvider); + ref.watch( + packageManagerProvider.select((s) => s.installed[app.identifier]), + ); // Resolve installed and available versions using AppExt final installedVersion = app.installedPackage?.version; @@ -38,8 +40,6 @@ class VersionPillWidget extends HookConsumerWidget { availableVersion != null) { return _buildDualVersionPills( context, - ref, - installedPackages, installedVersion, availableVersion, isDowngrade: downgradeAvailable, @@ -55,8 +55,6 @@ class VersionPillWidget extends HookConsumerWidget { return _buildVersionPill( context, - ref, - installedPackages, version, AppColors.darkPillBackground, Colors.white, @@ -65,8 +63,6 @@ class VersionPillWidget extends HookConsumerWidget { Widget _buildDualVersionPills( BuildContext context, - WidgetRef ref, - List installedPackages, String installedVersion, String availableVersion, { bool isDowngrade = false, @@ -74,9 +70,10 @@ class VersionPillWidget extends HookConsumerWidget { // Get version codes final installedVersionCode = app.installedPackage?.versionCode; final availableVersionCode = app.latestFileMetadata?.versionCode; - + // When version strings are equal but version codes differ, show version codes in parentheses - final showVersionCodes = installedVersion == availableVersion && + final showVersionCodes = + installedVersion == availableVersion && installedVersionCode != null && availableVersionCode != null && installedVersionCode != availableVersionCode; @@ -88,8 +85,6 @@ class VersionPillWidget extends HookConsumerWidget { Flexible( child: _buildVersionPill( context, - ref, - installedPackages, installedVersion, Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), Theme.of(context).colorScheme.onSurface, @@ -98,7 +93,7 @@ class VersionPillWidget extends HookConsumerWidget { ), ), - // Arrow icon (always arrow, forbidden icon is in the pill itself) + // Arrow icon Icon( Icons.arrow_right, size: 16, @@ -109,8 +104,6 @@ class VersionPillWidget extends HookConsumerWidget { Flexible( child: _buildVersionPill( context, - ref, - installedPackages, availableVersion, isDowngrade ? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3) @@ -128,8 +121,6 @@ class VersionPillWidget extends HookConsumerWidget { Widget _buildVersionPill( BuildContext context, - WidgetRef ref, - List installedPackages, String version, Color backgroundColor, Color textColor, { @@ -168,16 +159,12 @@ class VersionPillWidget extends HookConsumerWidget { } } - // Use grey colors for "do not disturb" states (downgrade, certificate mismatch) - final finalBackgroundColor = backgroundColor; - final finalTextColor = textColor; - final double verticalPadding = isInstalledVersion ? 5.0 * 1.05 : 5.0; return Container( padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding), decoration: BoxDecoration( - color: finalBackgroundColor, + color: backgroundColor, borderRadius: BorderRadius.circular(8), ), child: Row( @@ -190,7 +177,7 @@ class VersionPillWidget extends HookConsumerWidget { overflow: TextOverflow.ellipsis, style: context.textTheme.labelSmall?.copyWith( fontWeight: FontWeight.bold, - color: finalTextColor, + color: textColor, ), ), ), @@ -207,13 +194,9 @@ class VersionPillWidget extends HookConsumerWidget { } String _displayVersion(String version, {int? versionCode}) { - // Show full version - let Flexible + TextOverflow.ellipsis handle truncation only when needed - // If versionCode is provided, append it in parentheses if (versionCode != null) { return '$version ($versionCode)'; } return version; } - - // No width enforcement; both pills share the same text style }