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 168d518..5ef66c8 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 @@ -50,6 +50,7 @@ object InstallStatus { const val STARTED = "started" const val VERIFYING = "verifying" const val PENDING_USER_ACTION = "pendingUserAction" + const val INSTALLING = "installing" // User accepted, system is now installing const val ALREADY_IN_PROGRESS = "alreadyInProgress" const val SUCCESS = "success" const val FAILED = "failed" @@ -99,6 +100,15 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, /** Track verification threads so we can "ping" and avoid false timeouts */ private val verificationThreads = mutableMapOf() + + /** Track sessions that are pending user action (so we can detect when user accepts) */ + private val sessionsPendingUserAction = mutableSetOf() + + /** Track sessions that have emitted INSTALLING status (to avoid duplicates) */ + private val sessionsInstalling = mutableSetOf() + + /** Session callback to detect progress after user confirms install */ + private var sessionCallback: PackageInstaller.SessionCallback? = null private var eventSink: EventChannel.EventSink? = null @@ -140,6 +150,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { pendingUserActionIntents[pkg] = confirmIntent + // Track this session as pending user action so SessionCallback can detect acceptance + instance?.sessionsPendingUserAction?.add(sessionId) + // Auto-launch dialog if app is in foreground if (isAppInForeground) { launchConfirmDialog(pkg, confirmIntent) @@ -150,6 +163,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) { sessionToPackage.remove(sessionId) pendingUserActionIntents.remove(pkg) + instance?.sessionsPendingUserAction?.remove(sessionId) + instance?.sessionsInstalling?.remove(sessionId) if (status != InstallStatus.SUCCESS) { abandonSession(sessionId) @@ -182,12 +197,27 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private fun launchConfirmDialog(packageName: String, intent: Intent) { val ctx = appContext ?: return - try { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - ctx.startActivity(intent) - Log.d(TAG, "Launched confirmation dialog for $packageName") - } catch (e: Exception) { - Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) + val inst = instance + + // Post with delay to ensure the system is ready to show the dialog. + // Without this, the dialog may not appear when the broadcast arrives + // immediately after session.commit() while the app is still processing. + val launcher: () -> Unit = { + try { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ctx.startActivity(intent) + Log.d(TAG, "Launched confirmation dialog for $packageName") + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e) + } + Unit + } + + if (inst != null) { + inst.mainHandler.postDelayed(launcher, 100) + } else { + // Fallback if instance not available + Handler(Looper.getMainLooper()).postDelayed(launcher, 100) } } @@ -223,6 +253,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, ProcessLifecycleOwner.get().lifecycle.addObserver(this) } + // Register session callback to detect when user accepts install dialog + registerSessionCallback() + cleanupStaleSessions() Log.d(TAG, "AndroidPackageManagerPlugin initialized") } @@ -231,6 +264,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, mainHandler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } + unregisterSessionCallback() methodChannel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) eventSink = null @@ -262,6 +296,57 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, Log.d(TAG, "App backgrounded") } + // ═══════════════════════════════════════════════════════════════════════════════ + // SESSION CALLBACK (Detect when user accepts install dialog) + // ═══════════════════════════════════════════════════════════════════════════════ + + private fun registerSessionCallback() { + sessionCallback = object : PackageInstaller.SessionCallback() { + override fun onCreated(sessionId: Int) {} + override fun onBadgingChanged(sessionId: Int) {} + override fun onActiveChanged(sessionId: Int, active: Boolean) {} + + override fun onProgressChanged(sessionId: Int, progress: Float) { + // When we see progress on a session that was pending user action, + // it means the user accepted and the system is now installing. + val pkg = sessionToPackage[sessionId] ?: return + + // Only emit INSTALLING once per session + if (sessionId in sessionsPendingUserAction && sessionId !in sessionsInstalling) { + sessionsInstalling.add(sessionId) + sessionsPendingUserAction.remove(sessionId) + Log.d(TAG, "User accepted install for $pkg (progress=$progress)") + onInstallResult( + sessionId = sessionId, + status = InstallStatus.INSTALLING, + packageName = pkg, + message = "Installing..." + ) + } + } + + override fun onFinished(sessionId: Int, success: Boolean) { + // Clean up tracking + sessionsPendingUserAction.remove(sessionId) + sessionsInstalling.remove(sessionId) + } + } + + context.packageManager.packageInstaller.registerSessionCallback(sessionCallback!!, mainHandler) + Log.d(TAG, "Registered session callback") + } + + private fun unregisterSessionCallback() { + sessionCallback?.let { + try { + context.packageManager.packageInstaller.unregisterSessionCallback(it) + } catch (e: Exception) { + Log.w(TAG, "Failed to unregister session callback", e) + } + } + sessionCallback = null + } + // ═══════════════════════════════════════════════════════════════════════════════ // EVENT CHANNEL STREAM HANDLER // ═══════════════════════════════════════════════════════════════════════════════ @@ -394,7 +479,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, // Start watchdog for verify stage scheduleWatchdog(appId, VERIFY_WATCHDOG_MS) } - InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> { + InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, InstallStatus.INSTALLING -> { // Start/refresh watchdog for install stage scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) } diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index f82f2a6..5eea8e7 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -147,16 +147,7 @@ class _UpdatesContent extends HookConsumerWidget { .map((entry) => entry.key) .toSet(); - if (activeAppIds.isEmpty) { - return _UpdatesListBody( - automaticUpdates: automaticUpdates, - manualUpdates: manualUpdates, - installingApps: const [], - upToDateApps: upToDateApps, - uncatalogedApps: uncatalogedApps, - ); - } - + // Always use the same widget type to preserve scroll position return _UpdatesListBodyWithInstallingAppIds( installingAppIds: activeAppIds, automaticUpdates: automaticUpdates, @@ -184,27 +175,32 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final installingAppsState = ref.watch( - query( - tags: {'#d': installingAppIds}, - and: (app) => {app.latestRelease.query()}, - source: const LocalAndRemoteSource(relays: 'AppCatalog'), - subscriptionPrefix: 'installing-apps', - ), - ); - final updateAppIds = { ...automaticUpdates.map((a) => a.identifier), ...manualUpdates.map((a) => a.identifier), }; - final installingApps = installingAppsState.models - .where( - (app) => - installingAppIds.contains(app.identifier) && - !updateAppIds.contains(app.identifier), - ) - .toList(); + // Only query for installing apps if there are active operations + final List installingApps; + if (installingAppIds.isEmpty) { + installingApps = const []; + } else { + final installingAppsState = ref.watch( + query( + tags: {'#d': installingAppIds}, + and: (app) => {app.latestRelease.query()}, + source: const LocalAndRemoteSource(relays: 'AppCatalog'), + subscriptionPrefix: 'installing-apps', + ), + ); + installingApps = installingAppsState.models + .where( + (app) => + installingAppIds.contains(app.identifier) && + !updateAppIds.contains(app.identifier), + ) + .toList(); + } return _UpdatesListBody( automaticUpdates: automaticUpdates, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 17315f3..0e4b350 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -14,6 +14,7 @@ enum InstallStatus { started, verifying, pendingUserAction, + installing, // User accepted, system is now installing alreadyInProgress, success, failed, @@ -26,6 +27,7 @@ extension InstallStatusX on InstallStatus { 'started' => InstallStatus.started, 'verifying' => InstallStatus.verifying, 'pendingUserAction' => InstallStatus.pendingUserAction, + 'installing' => InstallStatus.installing, 'alreadyInProgress' => InstallStatus.alreadyInProgress, 'success' => InstallStatus.success, 'failed' => InstallStatus.failed, @@ -170,6 +172,17 @@ final class AndroidPackageManager extends PackageManager { } break; + case InstallStatus.installing: + // User accepted the install dialog, system is now installing. + // Transition to Installing with isSilent=true to show "Installing..." + if (filePath != null) { + setOperation( + appId, + Installing(target: target, filePath: filePath, isSilent: true), + ); + } + break; + case InstallStatus.alreadyInProgress: // Real pending dialog exists - Kotlin will also send pendingUserAction event // which transitions to Installing. Nothing to do here. diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 171737a..9b32f8f 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -731,25 +731,46 @@ abstract class PackageManager extends StateNotifier { } void _processQueuedDownload() { - var activeDownloads = countOperations(); + final activeDownloads = countOperations(); if (activeDownloads >= maxConcurrentDownloads) return; - // Start downloads until we fill all available slots + // Collect queued items first to avoid iterating while modifying state + final queuedItems = {}; for (final entry in state.operations.entries) { - if (activeDownloads >= maxConcurrentDownloads) break; - if (entry.value is DownloadQueued) { - final queued = entry.value as DownloadQueued; - final downloadUrl = queued.target.urls.firstOrNull; - if (downloadUrl != null) { + queuedItems[entry.key] = entry.value as DownloadQueued; + } + } + + if (queuedItems.isEmpty) return; + + // Start downloads until we fill all available slots + var started = 0; + for (final entry in queuedItems.entries) { + if (activeDownloads + started >= maxConcurrentDownloads) break; + + final queued = entry.value; + final downloadUrl = queued.target.urls.firstOrNull; + if (downloadUrl != null) { + unawaited( _startDownloadTask( entry.key, queued.target, downloadUrl, displayName: queued.displayName, - ); - activeDownloads++; - } + ), + ); + started++; + } else { + // No URL - fail the operation so it doesn't stay queued forever + setOperation( + entry.key, + OperationFailed( + target: queued.target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); } } } diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index b3782b6..511d191 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -146,12 +146,12 @@ class InstallButton extends ConsumerWidget { isWarning: true, ), - ReadyToInstall() => _buildAsyncButton( + ReadyToInstall() => _buildSimpleButton( context, - ref, - text: isInstalled ? 'Update' : 'Install', - onPressed: () => _triggerInstall(ref), + 'Waiting...', + null, // Not tappable - system advances automatically fontSize: fontSize, + showSpinner: true, ), Installing(:final isSilent) => _buildSimpleButton( @@ -539,11 +539,6 @@ class InstallButton extends ConsumerWidget { pm.resumeDownload(app.identifier); } - Future _triggerInstall(WidgetRef ref) async { - final pm = ref.read(packageManagerProvider.notifier); - await pm.triggerInstall(app.identifier); - } - Future _retryInstall(WidgetRef ref) async { final pm = ref.read(packageManagerProvider.notifier); await pm.retryInstall(app.identifier);