Refresh foreground after auto-install

This commit is contained in:
franzap
2026-07-14 11:00:45 -03:00
parent ab1690f89f
commit 205b23eca9
6 changed files with 169 additions and 0 deletions
@@ -160,6 +160,18 @@ class AndroidPackageManagerPlugin :
private val awaitLatches = ConcurrentHashMap<String, CountDownLatch>()
private val awaitResults = ConcurrentHashMap<String, Map<String, Any?>>()
/**
* 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<AndroidPackageManagerPlugin>()
/**
* 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<String>) {
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<List<String>>("updatedAppIds") ?: emptyList()
notifyBackgroundUpdatesCompleted(updatedAppIds)
result.success(null)
}
else -> result.notImplemented()
}
}
@@ -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<void> notifyBackgroundUpdatesCompleted(
List<String> updatedAppIds,
) async {
if (updatedAppIds.isEmpty) return;
try {
await _channel.invokeMethod<void>('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<bool> launchPreparedInstall({
required String appId,
@@ -356,6 +356,9 @@ Future<bool> _checkForUpdatesInBackground(
installed: pmState.installed,
displayNames: displayNames,
);
await BackgroundNativeInstaller.notifyBackgroundUpdatesCompleted(
result.updatedAppIds,
);
if (result.hasWork) {
await _showAutoUpdateResultNotification(result, updatableApps);
}
@@ -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<void>? _syncInFlight;
StreamSubscription<dynamic>? _eventSubscription;
Future<void>? _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<void> refresh;
refresh = _refreshAfterBackgroundUpdatesImpl().whenComplete(() {
if (identical(_backgroundRefreshInFlight, refresh)) {
_backgroundRefreshInFlight = null;
}
});
_backgroundRefreshInFlight = refresh;
}
Future<void> _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,
@@ -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
@@ -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<void>();
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': <String>[],
'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<void>.delayed(Duration.zero);
expect(
container
.read(packageManagerProvider)
.installed['place.poster.app']
?.versionCode,
2,
);
},
);
}