From 205b23eca96dc1e9e1da16e6bee7afc105667bd4 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 14 Jul 2026 11:00:45 -0300 Subject: [PATCH] Refresh foreground after auto-install --- .../plugins/AndroidPackageManagerPlugin.kt | 54 +++++++++++++++++++ lib/services/background_native_installer.dart | 25 +++++++++ lib/services/background_update_service.dart | 3 ++ .../android_package_manager.dart | 32 +++++++++++ spec/work/WORK-013-background-auto-updates.md | 5 ++ .../android_package_manager_test.dart | 50 +++++++++++++++++ 6 files changed, 169 insertions(+) diff --git a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt index f6ff0c6..aa0c8b6 100644 --- a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt @@ -160,6 +160,18 @@ class AndroidPackageManagerPlugin : private val awaitLatches = ConcurrentHashMap() private val awaitResults = ConcurrentHashMap>() + /** + * Plugins whose Dart engines are listening for UI install events. + * + * WorkManager starts a separate headless Flutter engine. Its plugin + * instance has no EventChannel listener, while the foreground engine + * can remain attached in the same process. Keep listeners per plugin + * instead of relying on [instance], which points at the most recently + * attached engine. + */ + private val eventListenerPlugins = + ConcurrentHashMap.newKeySet() + /** * Weak reference to the current Activity. Used by launchConfirmDialog to start * the PackageInstaller confirmation dialog in the same task as the app, which @@ -331,6 +343,35 @@ class AndroidPackageManagerPlugin : awaitResults[packageName] = result latch.countDown() } + + /** + * Notify foreground Flutter engines after a headless auto-update + * worker has successfully installed one or more packages. + * + * This intentionally emits a distinct completion event rather than + * replaying install progress. The foreground package manager does not + * own the background install operation, so replayed status events + * would be treated as orphaned sessions. + */ + fun notifyBackgroundUpdatesCompleted(updatedAppIds: List) { + if (updatedAppIds.isEmpty()) return + + val event = + mapOf( + "type" to "backgroundUpdatesCompleted", + "updatedAppIds" to updatedAppIds + ) + for (plugin in eventListenerPlugins) { + plugin.mainHandler.post { + val sink = plugin.eventSink ?: return@post + try { + sink.success(event) + } catch (e: Exception) { + Log.w(TAG, "Failed to emit background update completion", e) + } + } + } + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -369,6 +410,7 @@ class AndroidPackageManagerPlugin : methodChannel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) eventSink = null + eventListenerPlugins.remove(this) instance = null } @@ -520,6 +562,11 @@ class AndroidPackageManagerPlugin : override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink = events + if (events != null) { + eventListenerPlugins.add(this) + } else { + eventListenerPlugins.remove(this) + } // Flush buffered events (if any) so Dart can reconcile state. val sink = eventSink ?: return mainHandler.post { @@ -538,6 +585,7 @@ class AndroidPackageManagerPlugin : override fun onCancel(arguments: Any?) { eventSink = null + eventListenerPlugins.remove(this) } private fun emitInstallStatus( @@ -774,6 +822,12 @@ class AndroidPackageManagerPlugin : result ) } + "notifyBackgroundUpdatesCompleted" -> { + val updatedAppIds = + call.argument>("updatedAppIds") ?: emptyList() + notifyBackgroundUpdatesCompleted(updatedAppIds) + result.success(null) + } else -> result.notImplemented() } } diff --git a/lib/services/background_native_installer.dart b/lib/services/background_native_installer.dart index 252ba35..e1f959f 100644 --- a/lib/services/background_native_installer.dart +++ b/lib/services/background_native_installer.dart @@ -72,6 +72,31 @@ class BackgroundNativeInstaller { } } + /// Notify an attached foreground engine that silent background updates finished. + /// + /// The foreground package manager will rescan installed packages and refresh + /// its local update categories. This does not forward per-install status + /// events, which belong only to the headless worker. + static Future notifyBackgroundUpdatesCompleted( + List updatedAppIds, + ) async { + if (updatedAppIds.isEmpty) return; + + try { + await _channel.invokeMethod('notifyBackgroundUpdatesCompleted', { + 'updatedAppIds': updatedAppIds, + }); + } catch (e, st) { + LogService.I.warn( + 'could not notify foreground background updates completed', + tag: 'background_updates', + fields: {'updatedAppIds': updatedAppIds.join(',')}, + err: e, + stack: st, + ); + } + } + /// Launch the Android install confirmation dialog for a prepared manual update. static Future launchPreparedInstall({ required String appId, diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 797d9cb..150c17b 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -356,6 +356,9 @@ Future _checkForUpdatesInBackground( installed: pmState.installed, displayNames: displayNames, ); + await BackgroundNativeInstaller.notifyBackgroundUpdatesCompleted( + result.updatedAppIds, + ); if (result.hasWork) { await _showAutoUpdateResultNotification(result, updatableApps); } diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index c51de7f..3754122 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -8,6 +8,7 @@ import 'package:zapstore/services/c1_proof_verification.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/utils/extensions.dart'; /// Install status values from native side. @@ -78,6 +79,7 @@ final class AndroidPackageManager extends PackageManager { int _syncGeneration = 0; Future? _syncInFlight; StreamSubscription? _eventSubscription; + Future? _backgroundRefreshInFlight; /// Tracks appIds where we've already attempted to abort orphaned sessions. /// Prevents spamming abort calls when native keeps sending events. @@ -148,6 +150,11 @@ final class AndroidPackageManager extends PackageManager { return; } + if (event['type'] == 'backgroundUpdatesCompleted') { + _refreshAfterBackgroundUpdates(); + return; + } + final appId = event['appId'] as String?; final statusRaw = event['status'] as String?; final message = event['message'] as String?; @@ -370,6 +377,31 @@ final class AndroidPackageManager extends PackageManager { } } + @visibleForTesting + void handlePlatformEventForTesting(dynamic event) => + _handleInstallEvent(event); + + /// Reconcile package state after a headless WorkManager engine installs an + /// update. The foreground engine did not own that operation, so it receives + /// only a completion signal and rebuilds from local sources. + void _refreshAfterBackgroundUpdates() { + if (_backgroundRefreshInFlight != null) return; + + late final Future refresh; + refresh = _refreshAfterBackgroundUpdatesImpl().whenComplete(() { + if (identical(_backgroundRefreshInFlight, refresh)) { + _backgroundRefreshInFlight = null; + } + }); + _backgroundRefreshInFlight = refresh; + } + + Future _refreshAfterBackgroundUpdatesImpl() async { + await syncInstalledPackages(); + if (!mounted) return; + await ref.read(updatePollerProvider.notifier).refreshFromLocal(); + } + @override void setOperation(String appId, InstallOperation op) { // Clear from aborted orphans when a new operation is set, diff --git a/spec/work/WORK-013-background-auto-updates.md b/spec/work/WORK-013-background-auto-updates.md index 3af4e16..e069abb 100644 --- a/spec/work/WORK-013-background-auto-updates.md +++ b/spec/work/WORK-013-background-auto-updates.md @@ -22,6 +22,7 @@ apply updates, with a result notification instead of "updates available". - [x] Confirm opt-in before enabling and explain first-run timing and cadence - [x] Queue the first auto-update run immediately with an unmetered-network constraint - [x] Emulator UAT: schedule constraints, staging, notification, and install prompt +- [x] Refresh foreground package and Updates-screen state after a headless silent update - [ ] Manual UAT on device ## Decisions @@ -42,6 +43,10 @@ apply updates, with a result notification instead of "updates available". unmetered network. It runs as soon as Wi-Fi is available and does not poll while offline; the existing periodic auto-update task remains approximately every 24 hours. +- A headless worker emits a completion-only native event to foreground Flutter + engines after silent updates succeed. Foreground state then rescans installed + packages and refreshes local catalog categories; install-progress events are + never replayed into the foreground state machine. ## Test Coverage diff --git a/test/services/package_manager/android_package_manager_test.dart b/test/services/package_manager/android_package_manager_test.dart index a0ace2a..8bb516b 100644 --- a/test/services/package_manager/android_package_manager_test.dart +++ b/test/services/package_manager/android_package_manager_test.dart @@ -51,4 +51,54 @@ void main() { expect(scanCalls, 1); }); + + test( + 'refreshes installed packages after background update completion', + () async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final installedPackagesSynced = Completer(); + + messenger.setMockMethodCallHandler(eventChannel, (_) async => null); + messenger.setMockMethodCallHandler(methodChannel, (call) async { + expect(call.method, 'getInstalledApps'); + installedPackagesSynced.complete(); + return [ + { + 'packageName': 'place.poster.app', + 'name': 'Poster', + 'versionName': '2.0.0', + 'versionCode': 2, + 'signatureHashes': [], + 'canInstallSilently': true, + }, + ]; + }); + + final container = ProviderContainer( + overrides: [ + packageManagerProvider.overrideWith(AndroidPackageManager.new), + ], + ); + addTearDown(container.dispose); + + final packageManager = + container.read(packageManagerProvider.notifier) + as AndroidPackageManager; + packageManager.handlePlatformEventForTesting({ + 'type': 'backgroundUpdatesCompleted', + 'updatedAppIds': ['place.poster.app'], + }); + await installedPackagesSynced.future; + await Future.delayed(Duration.zero); + + expect( + container + .read(packageManagerProvider) + .installed['place.poster.app'] + ?.versionCode, + 2, + ); + }, + ); }