Improve resume and navigation responsiveness by fixing log flush races, incremental catalog resolution, and coalesced Android package scans

This commit is contained in:
franzap
2026-06-29 14:30:59 -03:00
parent 0f0c4c0ffc
commit 397492b509
8 changed files with 255 additions and 41 deletions
@@ -32,6 +32,8 @@ import java.security.MessageDigest
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private const val TAG = "AndroidPackageManager" private const val TAG = "AndroidPackageManager"
@@ -110,6 +112,8 @@ class AndroidPackageManagerPlugin :
private lateinit var eventChannel: EventChannel private lateinit var eventChannel: EventChannel
private lateinit var context: Context private lateinit var context: Context
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var packageQueryExecutor: ExecutorService? = null
@Volatile private var isEngineAttached = false
/** Per-app watchdog generation counters (cancels old scheduled callbacks) */ /** Per-app watchdog generation counters (cancels old scheduled callbacks) */
private val watchdogGen = mutableMapOf<String, Int>() private val watchdogGen = mutableMapOf<String, Int>()
@@ -337,6 +341,8 @@ class AndroidPackageManagerPlugin :
context = binding.applicationContext context = binding.applicationContext
appContext = context appContext = context
instance = this instance = this
isEngineAttached = true
packageQueryExecutor = Executors.newSingleThreadExecutor()
methodChannel = MethodChannel(binding.binaryMessenger, "android_package_manager") methodChannel = MethodChannel(binding.binaryMessenger, "android_package_manager")
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
@@ -355,6 +361,9 @@ class AndroidPackageManagerPlugin :
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
isEngineAttached = false
packageQueryExecutor?.shutdownNow()
packageQueryExecutor = null
mainHandler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } mainHandler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) }
unregisterSessionCallback() unregisterSessionCallback()
methodChannel.setMethodCallHandler(null) methodChannel.setMethodCallHandler(null)
@@ -680,7 +689,31 @@ class AndroidPackageManagerPlugin :
"requestInstallPermission" -> requestInstallPermission(result) "requestInstallPermission" -> requestInstallPermission(result)
"getInstalledApps" -> { "getInstalledApps" -> {
val includeSystem = call.argument<Boolean>("includeSystemApps") ?: false val includeSystem = call.argument<Boolean>("includeSystemApps") ?: false
result.success(getInstalledApps(includeSystem)) val executor = packageQueryExecutor
if (executor == null) {
result.error("PLUGIN_DETACHED", "Package manager is unavailable", null)
return
}
executor.execute {
try {
val installedApps = getInstalledApps(includeSystem)
mainHandler.post {
if (isEngineAttached) {
result.success(installedApps)
}
}
} catch (error: Throwable) {
mainHandler.post {
if (isEngineAttached) {
result.error(
"PACKAGE_QUERY_FAILED",
error.message ?: "Could not query installed apps",
null
)
}
}
}
}
} }
"uninstall" -> { "uninstall" -> {
val packageName = call.argument<String>("packageName") val packageName = call.argument<String>("packageName")
+1 -10
View File
@@ -200,20 +200,11 @@ final routerProvider = Provider<GoRouter>((ref) {
previousPath = currentPath; previousPath = currentPath;
Future.microtask(() { Future.microtask(() {
// Sync installed packages on every navigation to catch sideloads,
// external installs/uninstalls, and self-updating apps.
// This is a local-only platform channel call (~100-500ms, no network).
unawaited(
ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
);
// Re-derive catalog from local DB when arriving at updates tab so // Re-derive catalog from local DB when arriving at updates tab so
// data written by other screens or the background service is visible // data written by other screens or the background service is visible
// without waiting for the next poll cycle. // without waiting for the next poll cycle.
if (isUpdatesRoute && !wasUpdatesRoute) { if (isUpdatesRoute && !wasUpdatesRoute) {
unawaited( unawaited(ref.read(updatePollerProvider.notifier).refreshFromLocal());
ref.read(updatePollerProvider.notifier).refreshFromLocal(),
);
} }
// Clear completed operations when navigating away from updates // Clear completed operations when navigating away from updates
+34 -14
View File
@@ -375,11 +375,9 @@ class LogService {
await f; await f;
continue; continue;
} }
if (_pending.isEmpty) return; if (_pending.isEmpty || _diskDisabled) return;
// No flush in flight but entries pending — kick one off and // No flush in flight but entries pending — kick one off.
// record it as the active future so concurrent callers join. await _startFlush();
_flushFuture = _runFlush();
await _flushFuture;
} }
} }
@@ -449,21 +447,47 @@ class LogService {
_flushScheduled = false; _flushScheduled = false;
// Another caller (e.g. `flush()`) may have started the flush // Another caller (e.g. `flush()`) may have started the flush
// already; if so, do nothing — entries added after that will // already; if so, do nothing — entries added after that will
// re-schedule in the `_runFlush` finally block. // re-schedule when the in-flight flush completes.
if (_flushFuture != null) return; if (_flushFuture != null) return;
_flushFuture = _runFlush(); _startFlush();
}); });
} }
/// Sole owner of [_flushFuture]: assigned exactly once here and cleared
/// exactly once on completion. `_runFlush` must never touch it — it used
/// to null it synchronously on an early return, after which the caller's
/// `_flushFuture = _runFlush()` assignment resurrected the already
/// completed future. That stale non-null future made `flush()` spin its
/// `await`-loop on the microtask queue forever, permanently starving the
/// UI event loop (app frozen on resume, kill required).
///
/// `whenComplete` on an async function's future always fires in a later
/// microtask, so the clear below is guaranteed to run after the
/// assignment — even when `_runFlush` returns without suspending.
Future<void> _startFlush() {
late final Future<void> future;
future = _runFlush().whenComplete(() {
if (identical(_flushFuture, future)) {
_flushFuture = null;
}
// If new entries arrived while we were flushing, schedule again.
if (_pending.isNotEmpty && !_diskDisabled) _scheduleFlush();
});
_flushFuture = future;
return future;
}
Future<void> _runFlush() async { Future<void> _runFlush() async {
if (_pending.isEmpty || _diskDisabled) { if (_diskDisabled) {
_flushFuture = null; // Entries can never reach disk this session; drop them so callers
// of flush() terminate. The ring buffer still holds them.
_pending.clear();
return; return;
} }
if (_pending.isEmpty) return;
final file = _activeFile; final file = _activeFile;
if (file == null) { if (file == null) {
_pending.clear(); _pending.clear();
_flushFuture = null;
return; return;
} }
final batch = List<LogEntry>.from(_pending); final batch = List<LogEntry>.from(_pending);
@@ -475,10 +499,6 @@ class LogService {
_onDiskWriteFailure(e); _onDiskWriteFailure(e);
} catch (_) { } catch (_) {
// Swallow — logging must never crash the app. // Swallow — logging must never crash the app.
} finally {
_flushFuture = null;
// If new entries arrived while we were flushing, schedule again.
if (_pending.isNotEmpty) _scheduleFlush();
} }
} }
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/services/c1_proof_verification.dart'; import 'package:zapstore/services/c1_proof_verification.dart';
@@ -75,6 +76,7 @@ final class AndroidPackageManager extends PackageManager {
static const _eventChannel = EventChannel('android_package_manager/events'); static const _eventChannel = EventChannel('android_package_manager/events');
bool _supportsSilentInstall = false; bool _supportsSilentInstall = false;
int _syncGeneration = 0; int _syncGeneration = 0;
Future<void>? _syncInFlight;
StreamSubscription<dynamic>? _eventSubscription; StreamSubscription<dynamic>? _eventSubscription;
/// Tracks appIds where we've already attempted to abort orphaned sessions. /// Tracks appIds where we've already attempted to abort orphaned sessions.
@@ -713,7 +715,21 @@ final class AndroidPackageManager extends PackageManager {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@override @override
Future<void> syncInstalledPackages() async { Future<void> syncInstalledPackages() {
final inFlight = _syncInFlight;
if (inFlight != null) return inFlight;
late final Future<void> operation;
operation = _performInstalledPackagesSync().whenComplete(() {
if (identical(_syncInFlight, operation)) {
_syncInFlight = null;
}
});
_syncInFlight = operation;
return operation;
}
Future<void> _performInstalledPackagesSync() async {
final syncGen = ++_syncGeneration; final syncGen = ++_syncGeneration;
state = state.copyWith(isScanning: true); state = state.copyWith(isScanning: true);
try { try {
@@ -729,7 +745,7 @@ final class AndroidPackageManager extends PackageManager {
) ?? ) ??
[]; [];
if (syncGen != _syncGeneration) return; if (!mounted || syncGen != _syncGeneration) return;
final packages = <String, PackageInfo>{}; final packages = <String, PackageInfo>{};
var anyCanInstallSilently = false; var anyCanInstallSilently = false;
@@ -762,7 +778,7 @@ final class AndroidPackageManager extends PackageManager {
} }
} }
if (syncGen != _syncGeneration) return; if (!mounted || syncGen != _syncGeneration) return;
// Derive general silent install capability from per-app data // Derive general silent install capability from per-app data
_supportsSilentInstall = anyCanInstallSilently; _supportsSilentInstall = anyCanInstallSilently;
@@ -770,8 +786,10 @@ final class AndroidPackageManager extends PackageManager {
// Trust the native source as the single source of truth. // Trust the native source as the single source of truth.
// Android's SUCCESS broadcast only fires after the package is committed, // Android's SUCCESS broadcast only fires after the package is committed,
// so there's no race condition with queryable state. // so there's no race condition with queryable state.
state = state.copyWith(installed: packages); if (!mapEquals(state.installed, packages)) {
await InstalledPackagesSnapshot.save(state.installed); state = state.copyWith(installed: packages);
await InstalledPackagesSnapshot.save(packages);
}
// Clear operations for apps where the installed version matches the target version // Clear operations for apps where the installed version matches the target version
// This catches installs that succeeded but we missed the event // This catches installs that succeeded but we missed the event
@@ -827,7 +845,9 @@ final class AndroidPackageManager extends PackageManager {
} catch (_) { } catch (_) {
// Don't clobber state on transient errors // Don't clobber state on transient errors
} finally { } finally {
state = state.copyWith(isScanning: false); if (mounted) {
state = state.copyWith(isScanning: false);
}
} }
} }
} }
+5 -3
View File
@@ -152,11 +152,11 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
// Fire-and-forget: a remote check blocks for tens of seconds offline, // Fire-and-forget: a remote check blocks for tens of seconds offline,
// but must not delay the local-first UI render that [refreshFromLocal] // but must not delay the local-first UI render that [refreshFromLocal]
// already produced. // already produced.
unawaited(checkNow()); unawaited(checkNow(refreshInstalledPackages: false));
} }
/// Trigger an update check. Called by timer and pull-to-refresh. /// Trigger an update check. Called by timer and pull-to-refresh.
Future<void> checkNow() async { Future<void> checkNow({bool refreshInstalledPackages = true}) async {
if (state.isChecking) return; if (state.isChecking) return;
if (state.lastCheckTime != null) { if (state.lastCheckTime != null) {
@@ -167,7 +167,9 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
state = state.copyWith(isChecking: true); state = state.copyWith(isChecking: true);
try { try {
await ref.read(packageManagerProvider.notifier).syncInstalledPackages(); if (refreshInstalledPackages) {
await ref.read(packageManagerProvider.notifier).syncInstalledPackages();
}
await _fetchCatalog(); await _fetchCatalog();
state = state.copyWith( state = state.copyWith(
isChecking: false, isChecking: false,
+57 -7
View File
@@ -134,7 +134,11 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
final keepAssets = [...assets, ...filteredOlder]; final keepAssets = [...assets, ...filteredOlder];
final appsByAssetId = _resolveAppsFromLocal(storage, keepAssets); final appsByAssetId = _resolveAppsFromLocal(
storage,
keepAssets,
existing: state.appsByAssetId,
);
state = state.copyWith( state = state.copyWith(
firstPage: assets, firstPage: assets,
@@ -224,9 +228,17 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
state = state.copyWith( state = state.copyWith(
olderPages: [...state.olderPages, ...unique], olderPages: [...state.olderPages, ...unique],
appsByAssetId: _resolveAppsFromLocal(storage, keepAssets), appsByAssetId: _resolveAppsFromLocal(
storage,
keepAssets,
existing: state.appsByAssetId,
),
isLoadingMore: false, isLoadingMore: false,
hasMore: assets.length >= _kPageSize, // A page that yields no NEW assets is end-of-list. Mirrors the
// shared PagedSubscriptionNotifier contract — without the
// `unique.isNotEmpty` half, a page of all-duplicates keeps
// `hasMore` true and spins the viewport auto-fill loop.
hasMore: unique.isNotEmpty && assets.length >= _kPageSize,
); );
} catch (_) { } catch (_) {
state = state.copyWith(isLoadingMore: false); state = state.copyWith(isLoadingMore: false);
@@ -235,13 +247,30 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
/// Synchronously map asset IDs -> parent App using local storage only. /// Synchronously map asset IDs -> parent App using local storage only.
/// Never awaits network; returns an empty entry for ids not yet in cache. /// Never awaits network; returns an empty entry for ids not yet in cache.
///
/// INCREMENTAL: reuses already-resolved entries from [existing] and only
/// runs a `querySync` for assets not yet resolved. This is critical for
/// UI responsiveness — this method runs on the UI isolate on EVERY
/// subscription emission, and emissions arrive in bursts on resume
/// (relay reconnect re-streams the catalog). Re-resolving the entire
/// accumulated asset list on every emission is O(loaded assets) of
/// synchronous DB work per emission, which pegs the isolate and freezes
/// the app. Resolving only new assets keeps steady-state emissions ~O(0).
Map<String, App> _resolveAppsFromLocal( Map<String, App> _resolveAppsFromLocal(
StorageNotifier storage, StorageNotifier storage,
List<SoftwareAsset> assets, List<SoftwareAsset> assets, {
) { Map<String, App> existing = const {},
final result = <String, App>{}; }) {
final assetIds = assets.map((asset) => asset.id).toSet();
// Carry over previously resolved entries whose asset is still present;
// drop entries for assets no longer in the list.
final result = <String, App>{
for (final entry in existing.entries)
if (assetIds.contains(entry.key)) entry.key: entry.value,
};
for (final asset in assets) { for (final asset in assets) {
if (asset.appIdentifier.isEmpty) continue; if (asset.appIdentifier.isEmpty) continue;
if (result.containsKey(asset.id)) continue; // already resolved
final matches = storage.querySync( final matches = storage.querySync(
RequestFilter<App>( RequestFilter<App>(
authors: {asset.event.pubkey}, authors: {asset.event.pubkey},
@@ -339,6 +368,13 @@ class LatestReleasesContainer extends HookConsumerWidget {
final combinedApps = [...pinnedApps, ...dedupedApps]; final combinedApps = [...pinnedApps, ...dedupedApps];
// Tracks the visible (deduped) app count the last time we auto-filled an
// underfilled viewport. Guards against an unbounded loop: when many
// SoftwareAssets collapse to few app cards, `maxScrollExtent` stays <= 0
// and the post-frame check would otherwise call `loadMore` every frame,
// walking the entire local asset history synchronously on the UI isolate.
final lastAutoFillLength = useRef<int?>(null);
useEffect(() { useEffect(() {
if (state == null) return null; if (state == null) return null;
void onScroll() { void onScroll() {
@@ -356,9 +392,23 @@ class LatestReleasesContainer extends HookConsumerWidget {
final position = scrollController.position; final position = scrollController.position;
final s = ref.read(latestReleasesProvider); final s = ref.read(latestReleasesProvider);
if (s.isLoadingMore || !s.hasMore) return; if (s.isLoadingMore || !s.hasMore) return;
if (position.maxScrollExtent <= 0 ||
// User scrolled near the bottom of a scrollable list — normal
// infinite scroll. Only possible once content overflows the viewport.
if (position.maxScrollExtent > 0 &&
position.pixels >= position.maxScrollExtent - 300) { position.pixels >= position.maxScrollExtent - 300) {
ref.read(latestReleasesProvider.notifier).loadMore(); ref.read(latestReleasesProvider.notifier).loadMore();
return;
}
// Viewport not filled yet — auto-fill so infinite scroll can engage.
// Only attempt again once the visible app count has actually grown;
// otherwise a page of assets that maps only to already-shown apps
// would keep this firing forever.
if (position.maxScrollExtent <= 0) {
if (lastAutoFillLength.value == combinedApps.length) return;
lastAutoFillLength.value = combinedApps.length;
ref.read(latestReleasesProvider.notifier).loadMore();
} }
} }
+44
View File
@@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -18,6 +19,18 @@ Future<({LogService log, Directory dir})> _newService(
File _activeFile(Directory dir) => File(p.join(dir.path, 'zapstore.log')); File _activeFile(Directory dir) => File(p.join(dir.path, 'zapstore.log'));
/// Isolate entry for the flushSync-race regression test. Reports 'ok'
/// once flush() returns; if flush() spins forever the parent times out.
Future<void> _flushAfterFlushSyncRace(SendPort report) async {
final dir = await Directory.systemTemp.createTemp('log_service_race_');
final svc = LogService.forTesting(directory: dir, isolate: 'race');
svc.info('entry'); // schedules the flush microtask
svc.flushSync(); // drains _pending before the microtask runs
await Future<void>.delayed(Duration.zero); // let the microtask fire
await svc.flush(); // must terminate
report.send('ok');
}
void main() { void main() {
group('LogService basics', () { group('LogService basics', () {
test('records to ring buffer and disk', () async { test('records to ring buffer and disk', () async {
@@ -288,6 +301,37 @@ void main() {
}); });
group('Concurrency', () { group('Concurrency', () {
// Regression: log() schedules a flush microtask; if flushSync() (the
// uncaught-error path) drains _pending before that microtask runs,
// the microtask used to leave a stale, already-completed
// _flushFuture behind. The next flush() — called on every app
// pause — then spun `await`ing that completed future forever,
// starving the UI event loop with microtasks (app frozen on
// resume, kill required).
//
// A regression cannot be caught by a plain `await flush()` here:
// the spin never yields to the event loop, so even a timeout timer
// would never fire in this isolate. Run the scenario in a separate
// isolate and time out from the outside.
test('flush completes after flushSync races a scheduled flush',
() async {
final done = ReceivePort();
final isolate = await Isolate.spawn(
_flushAfterFlushSyncRace,
done.sendPort,
);
try {
final result = await done.first.timeout(
const Duration(seconds: 10),
onTimeout: () => 'flush() never completed — microtask spin',
);
expect(result, 'ok');
} finally {
isolate.kill(priority: Isolate.immediate);
done.close();
}
});
test('many overlapping flushes never corrupt a line', () async { test('many overlapping flushes never corrupt a line', () async {
final (:log, :dir) = await _newService(); final (:log, :dir) = await _newService();
@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/services/package_manager/android_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const methodChannel = MethodChannel('android_package_manager');
const eventChannel = MethodChannel('android_package_manager/events');
tearDown(() async {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockMethodCallHandler(eventChannel, null);
});
test('coalesces overlapping installed-package scans', () async {
final scanResult = Completer<List<Object?>>();
var scanCalls = 0;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(eventChannel, (_) async => null);
messenger.setMockMethodCallHandler(methodChannel, (call) async {
expect(call.method, 'getInstalledApps');
scanCalls++;
return scanResult.future;
});
final container = ProviderContainer(
overrides: [
packageManagerProvider.overrideWith(AndroidPackageManager.new),
],
);
addTearDown(container.dispose);
final packageManager = container.read(packageManagerProvider.notifier);
final first = packageManager.syncInstalledPackages();
final second = packageManager.syncInstalledPackages();
expect(identical(first, second), isTrue);
expect(scanCalls, 1);
scanResult.complete(const []);
await Future.wait([first, second]);
expect(scanCalls, 1);
});
}