mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Refresh foreground after auto-install
This commit is contained in:
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user