mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48: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 awaitLatches = ConcurrentHashMap<String, CountDownLatch>()
|
||||||
private val awaitResults = ConcurrentHashMap<String, Map<String, Any?>>()
|
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
|
* Weak reference to the current Activity. Used by launchConfirmDialog to start
|
||||||
* the PackageInstaller confirmation dialog in the same task as the app, which
|
* the PackageInstaller confirmation dialog in the same task as the app, which
|
||||||
@@ -331,6 +343,35 @@ class AndroidPackageManagerPlugin :
|
|||||||
awaitResults[packageName] = result
|
awaitResults[packageName] = result
|
||||||
latch.countDown()
|
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)
|
methodChannel.setMethodCallHandler(null)
|
||||||
eventChannel.setStreamHandler(null)
|
eventChannel.setStreamHandler(null)
|
||||||
eventSink = null
|
eventSink = null
|
||||||
|
eventListenerPlugins.remove(this)
|
||||||
instance = null
|
instance = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +562,11 @@ class AndroidPackageManagerPlugin :
|
|||||||
|
|
||||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
eventSink = events
|
eventSink = events
|
||||||
|
if (events != null) {
|
||||||
|
eventListenerPlugins.add(this)
|
||||||
|
} else {
|
||||||
|
eventListenerPlugins.remove(this)
|
||||||
|
}
|
||||||
// Flush buffered events (if any) so Dart can reconcile state.
|
// Flush buffered events (if any) so Dart can reconcile state.
|
||||||
val sink = eventSink ?: return
|
val sink = eventSink ?: return
|
||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
@@ -538,6 +585,7 @@ class AndroidPackageManagerPlugin :
|
|||||||
|
|
||||||
override fun onCancel(arguments: Any?) {
|
override fun onCancel(arguments: Any?) {
|
||||||
eventSink = null
|
eventSink = null
|
||||||
|
eventListenerPlugins.remove(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitInstallStatus(
|
private fun emitInstallStatus(
|
||||||
@@ -774,6 +822,12 @@ class AndroidPackageManagerPlugin :
|
|||||||
result
|
result
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
"notifyBackgroundUpdatesCompleted" -> {
|
||||||
|
val updatedAppIds =
|
||||||
|
call.argument<List<String>>("updatedAppIds") ?: emptyList()
|
||||||
|
notifyBackgroundUpdatesCompleted(updatedAppIds)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
else -> result.notImplemented()
|
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.
|
/// Launch the Android install confirmation dialog for a prepared manual update.
|
||||||
static Future<bool> launchPreparedInstall({
|
static Future<bool> launchPreparedInstall({
|
||||||
required String appId,
|
required String appId,
|
||||||
|
|||||||
@@ -356,6 +356,9 @@ Future<bool> _checkForUpdatesInBackground(
|
|||||||
installed: pmState.installed,
|
installed: pmState.installed,
|
||||||
displayNames: displayNames,
|
displayNames: displayNames,
|
||||||
);
|
);
|
||||||
|
await BackgroundNativeInstaller.notifyBackgroundUpdatesCompleted(
|
||||||
|
result.updatedAppIds,
|
||||||
|
);
|
||||||
if (result.hasWork) {
|
if (result.hasWork) {
|
||||||
await _showAutoUpdateResultNotification(result, updatableApps);
|
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/log_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
|
import 'package:zapstore/services/updates_service.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
|
||||||
/// Install status values from native side.
|
/// Install status values from native side.
|
||||||
@@ -78,6 +79,7 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
int _syncGeneration = 0;
|
int _syncGeneration = 0;
|
||||||
Future<void>? _syncInFlight;
|
Future<void>? _syncInFlight;
|
||||||
StreamSubscription<dynamic>? _eventSubscription;
|
StreamSubscription<dynamic>? _eventSubscription;
|
||||||
|
Future<void>? _backgroundRefreshInFlight;
|
||||||
|
|
||||||
/// Tracks appIds where we've already attempted to abort orphaned sessions.
|
/// Tracks appIds where we've already attempted to abort orphaned sessions.
|
||||||
/// Prevents spamming abort calls when native keeps sending events.
|
/// Prevents spamming abort calls when native keeps sending events.
|
||||||
@@ -148,6 +150,11 @@ final class AndroidPackageManager extends PackageManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event['type'] == 'backgroundUpdatesCompleted') {
|
||||||
|
_refreshAfterBackgroundUpdates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final appId = event['appId'] as String?;
|
final appId = event['appId'] as String?;
|
||||||
final statusRaw = event['status'] as String?;
|
final statusRaw = event['status'] as String?;
|
||||||
final message = event['message'] 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
|
@override
|
||||||
void setOperation(String appId, InstallOperation op) {
|
void setOperation(String appId, InstallOperation op) {
|
||||||
// Clear from aborted orphans when a new operation is set,
|
// 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] 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] Queue the first auto-update run immediately with an unmetered-network constraint
|
||||||
- [x] Emulator UAT: schedule constraints, staging, notification, and install prompt
|
- [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
|
- [ ] Manual UAT on device
|
||||||
|
|
||||||
## Decisions
|
## 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
|
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
|
while offline; the existing periodic auto-update task remains approximately
|
||||||
every 24 hours.
|
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
|
## Test Coverage
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,54 @@ void main() {
|
|||||||
|
|
||||||
expect(scanCalls, 1);
|
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