mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improve package manager handling of edge cases on Dart side
This commit is contained in:
@@ -74,6 +74,10 @@ final class AndroidPackageManager extends PackageManager {
|
||||
int _syncGeneration = 0;
|
||||
StreamSubscription<dynamic>? _eventSubscription;
|
||||
|
||||
/// Tracks appIds where we've already attempted to abort orphaned sessions.
|
||||
/// Prevents spamming abort calls when native keeps sending events.
|
||||
final Set<String> _abortedOrphans = {};
|
||||
|
||||
@override
|
||||
String get platform => 'android-arm64-v8a';
|
||||
|
||||
@@ -87,13 +91,40 @@ final class AndroidPackageManager extends PackageManager {
|
||||
// EVENT STREAM HANDLING
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Whether we're currently attempting to reconnect the event stream
|
||||
bool _isReconnecting = false;
|
||||
|
||||
void _setupEventStream() {
|
||||
_eventSubscription?.cancel();
|
||||
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
|
||||
_handleInstallEvent,
|
||||
onError: (_) {}, // Events will resume when stream reconnects
|
||||
onError: (e) {
|
||||
debugPrint('[PackageManager] EventChannel error: $e');
|
||||
_attemptEventStreamReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
debugPrint('[PackageManager] EventChannel closed unexpectedly');
|
||||
_attemptEventStreamReconnect();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Attempt to reconnect the event stream after a failure.
|
||||
/// Uses exponential backoff to avoid hammering the system.
|
||||
void _attemptEventStreamReconnect() {
|
||||
if (_isReconnecting) return;
|
||||
_isReconnecting = true;
|
||||
|
||||
// Reconnect after a short delay
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
_isReconnecting = false;
|
||||
if (mounted) {
|
||||
debugPrint('[PackageManager] Attempting EventChannel reconnect');
|
||||
_setupEventStream();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle install status events from native side.
|
||||
/// Events arrive sequentially (Dart is single-threaded), no lock needed.
|
||||
void _handleInstallEvent(dynamic event) {
|
||||
@@ -110,10 +141,6 @@ final class AndroidPackageManager extends PackageManager {
|
||||
final progress = event['progress'] as double?;
|
||||
final status = InstallStatusX.tryParse(statusRaw);
|
||||
|
||||
debugPrint(
|
||||
'[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode, desc=$description',
|
||||
);
|
||||
|
||||
if (appId == null || statusRaw == null) {
|
||||
debugPrint('[PackageManager] Ignoring event with null appId or status');
|
||||
return;
|
||||
@@ -129,18 +156,32 @@ final class AndroidPackageManager extends PackageManager {
|
||||
// Get target from existing operation state - no separate tracking needed
|
||||
final existingOp = getOperation(appId);
|
||||
if (existingOp == null) {
|
||||
debugPrint(
|
||||
'[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event',
|
||||
);
|
||||
debugPrint(
|
||||
'[PackageManager] Current operations: ${state.operations.keys.toList()}',
|
||||
);
|
||||
// INVARIANT: No hanging states (FEAT-001).
|
||||
// Orphaned native sessions can occur when the app is killed during install.
|
||||
// Abort the native session to clean up and allow user to retry from clean state.
|
||||
// Only attempt abort once per appId to prevent spam when native keeps sending events.
|
||||
if (_abortedOrphans.add(appId)) {
|
||||
debugPrint(
|
||||
'[PackageManager] No tracked operation for appId=$appId, aborting orphaned native session',
|
||||
);
|
||||
unawaited(abortInstall(appId));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})',
|
||||
);
|
||||
// INVARIANT: Don't process terminal events for already terminal operations.
|
||||
// This prevents stale or duplicate events from corrupting state.
|
||||
// For example, a stale 'cancelled' event arriving after 'success' would
|
||||
// clear the Completed operation (since Completed has no filePath).
|
||||
if (existingOp.isTerminal &&
|
||||
(status == InstallStatus.success ||
|
||||
status == InstallStatus.failed ||
|
||||
status == InstallStatus.cancelled)) {
|
||||
debugPrint(
|
||||
'[PackageManager] Ignoring terminal event $status for already terminal operation ${existingOp.runtimeType}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final target = existingOp.target;
|
||||
final filePath = existingOp.filePath;
|
||||
@@ -225,9 +266,8 @@ final class AndroidPackageManager extends PackageManager {
|
||||
// Sync in background to get accurate info (signature hash, etc.)
|
||||
// but our state machine doesn't depend on it.
|
||||
unawaited(syncInstalledPackages());
|
||||
// Advance to next queued install and check if batch is complete
|
||||
_onInstallComplete(appId);
|
||||
_checkBatchComplete();
|
||||
// Advance to next queued install
|
||||
clearInstallSlot(appId);
|
||||
break;
|
||||
|
||||
case InstallStatus.failed:
|
||||
@@ -242,7 +282,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
),
|
||||
);
|
||||
// Advance to next queued install
|
||||
_onInstallComplete(appId);
|
||||
clearInstallSlot(appId);
|
||||
break;
|
||||
|
||||
case InstallStatus.cancelled:
|
||||
@@ -255,58 +295,25 @@ final class AndroidPackageManager extends PackageManager {
|
||||
clearOperation(appId);
|
||||
}
|
||||
// Advance to next queued install
|
||||
_onInstallComplete(appId);
|
||||
clearInstallSlot(appId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void setOperation(String appId, InstallOperation op) {
|
||||
// Clear from aborted orphans when a new operation is set,
|
||||
// so we can abort again if it becomes orphaned in a future session.
|
||||
_abortedOrphans.remove(appId);
|
||||
super.setOperation(appId, op);
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallReady(String appId) {
|
||||
// Use base class queue processing
|
||||
super.onInstallReady(appId);
|
||||
}
|
||||
|
||||
/// Clear active install tracking and advance queue.
|
||||
void _onInstallComplete(String appId) {
|
||||
if (activeInstall == appId) {
|
||||
activeInstall = null;
|
||||
}
|
||||
installQueue.remove(appId);
|
||||
scheduleProcessQueue();
|
||||
}
|
||||
|
||||
/// Check if all operations are complete (terminal states).
|
||||
/// If so, schedule clearing of completed operations after a delay.
|
||||
void _checkBatchComplete() {
|
||||
final ops = state.operations.values;
|
||||
if (ops.isEmpty) return;
|
||||
|
||||
// Check if any operation is still in progress
|
||||
final hasInProgress = ops.any((op) => op.isInProgress);
|
||||
if (hasInProgress) return;
|
||||
|
||||
// All operations are terminal (Completed or Failed)
|
||||
// Wait a moment to show "X of X updated", then clear completed ones
|
||||
Future.delayed(const Duration(seconds: 3), _clearCompletedOperations);
|
||||
}
|
||||
|
||||
/// Clear all Completed operations from the map.
|
||||
/// Failed operations stay so user can see/dismiss them.
|
||||
void _clearCompletedOperations() {
|
||||
final completedIds = state.operations.entries
|
||||
.where((e) => e.value is Completed)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
if (completedIds.isEmpty) return;
|
||||
|
||||
final newOps = Map<String, InstallOperation>.from(state.operations);
|
||||
for (final id in completedIds) {
|
||||
newOps.remove(id);
|
||||
}
|
||||
state = state.copyWith(operations: newOps);
|
||||
}
|
||||
|
||||
/// Convert native error code to FailureType.
|
||||
/// Uses structured error code when available, falls back to message parsing.
|
||||
FailureType _errorCodeToFailureType(String? errorCode, String? message) {
|
||||
@@ -441,7 +448,9 @@ final class AndroidPackageManager extends PackageManager {
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
// Don't auto-advance after failure
|
||||
// IMPORTANT: Clear activeInstall and advance queue on fail-to-start.
|
||||
// Otherwise the install queue is stuck indefinitely (hanging state).
|
||||
clearInstallSlot(appId);
|
||||
}
|
||||
// If started, wait for events via EventChannel
|
||||
} catch (e) {
|
||||
@@ -454,7 +463,9 @@ final class AndroidPackageManager extends PackageManager {
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
// Don't auto-advance after failure
|
||||
// IMPORTANT: Clear activeInstall and advance queue on exception.
|
||||
// Otherwise the install queue is stuck indefinitely (hanging state).
|
||||
clearInstallSlot(appId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,9 +691,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
for (final appId in packages.keys) {
|
||||
final op = getOperation(appId);
|
||||
if (op == null) continue;
|
||||
if (op is! Installing &&
|
||||
op is! Verifying &&
|
||||
op is! InstallCancelled) {
|
||||
if (op is! Installing && op is! Verifying && op is! InstallCancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ const staleOperationThreshold = Duration(days: 7);
|
||||
/// Delay between queueing operations to prevent UI flood
|
||||
const batchQueueDelayMs = 50;
|
||||
|
||||
/// Dart-side watchdog timeout (fallback if native events stop arriving)
|
||||
const watchdogTimeout = Duration(minutes: 5);
|
||||
|
||||
/// How often the watchdog timer checks for stale operations
|
||||
const watchdogCheckInterval = Duration(seconds: 30);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// INSTALL OPERATION STATE MACHINE
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -70,18 +76,21 @@ class DownloadPaused extends InstallOperation {
|
||||
class Verifying extends InstallOperation {
|
||||
final String filePath;
|
||||
final double progress;
|
||||
final DateTime startedAt;
|
||||
|
||||
const Verifying({
|
||||
Verifying({
|
||||
required super.target,
|
||||
required this.filePath,
|
||||
this.progress = 0.0,
|
||||
});
|
||||
DateTime? startedAt,
|
||||
}) : startedAt = startedAt ?? DateTime.now();
|
||||
|
||||
Verifying copyWith({double? progress}) {
|
||||
return Verifying(
|
||||
target: target,
|
||||
filePath: filePath,
|
||||
progress: progress ?? this.progress,
|
||||
startedAt: startedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,8 +151,13 @@ class Uninstalling extends InstallOperation {
|
||||
/// and is taking longer than expected. The system will eventually complete or fail.
|
||||
class SystemProcessing extends InstallOperation {
|
||||
final String filePath;
|
||||
final DateTime startedAt;
|
||||
|
||||
const SystemProcessing({required super.target, required this.filePath});
|
||||
SystemProcessing({
|
||||
required super.target,
|
||||
required this.filePath,
|
||||
DateTime? startedAt,
|
||||
}) : startedAt = startedAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -156,7 +170,7 @@ class Completed extends InstallOperation {
|
||||
final DateTime completedAt;
|
||||
|
||||
Completed({required super.target, DateTime? completedAt})
|
||||
: completedAt = completedAt ?? DateTime.now();
|
||||
: completedAt = completedAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -226,6 +240,10 @@ extension InstallOperationX on InstallOperation {
|
||||
this is SystemProcessing ||
|
||||
this is Uninstalling;
|
||||
|
||||
/// Whether this operation needs watchdog monitoring (waiting for native events)
|
||||
bool get needsWatchdog =>
|
||||
this is Verifying || this is Installing || this is SystemProcessing;
|
||||
|
||||
/// Whether this operation is in the verification phase
|
||||
bool get isVerifying => this is Verifying;
|
||||
|
||||
@@ -235,6 +253,14 @@ extension InstallOperationX on InstallOperation {
|
||||
/// Whether this operation is still in progress (not terminal)
|
||||
bool get isInProgress => !isTerminal;
|
||||
|
||||
/// Get start time for watchdog-monitored operations
|
||||
DateTime? get startedAt => switch (this) {
|
||||
Verifying(:final startedAt) => startedAt,
|
||||
Installing(:final startedAt) => startedAt,
|
||||
SystemProcessing(:final startedAt) => startedAt,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Get file path if available
|
||||
String? get filePath => switch (this) {
|
||||
Verifying(:final filePath) => filePath,
|
||||
|
||||
@@ -108,6 +108,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
late final FileDownloader _downloader;
|
||||
late final Future<void> _downloaderInit;
|
||||
|
||||
/// Watchdog timer for detecting stale operations (Dart-side fallback)
|
||||
Timer? _watchdogTimer;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXPLICIT QUEUE TRACKING
|
||||
// Queues are the source of truth for order; operations map is for UI state.
|
||||
@@ -164,36 +167,82 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
],
|
||||
androidConfig: [(Config.useCacheDir, false)],
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('FileDownloader configure failed: $e');
|
||||
}
|
||||
|
||||
_downloader.configureNotificationForGroup(
|
||||
FileDownloader.defaultGroup,
|
||||
running: const TaskNotification(
|
||||
'Downloading {displayName}',
|
||||
'{progress}',
|
||||
),
|
||||
// No 'complete' notification - download completion immediately triggers install
|
||||
// so showing "Download complete" is redundant and creates notification clutter
|
||||
complete: null,
|
||||
error: const TaskNotification('Download failed', '{displayName}'),
|
||||
paused: const TaskNotification('Download paused', '{displayName}'),
|
||||
progressBar: true,
|
||||
);
|
||||
// Note: We don't call configureNotificationForGroup because we handle
|
||||
// our own UI for download progress. The package defaults to no notifications.
|
||||
|
||||
_downloader.registerCallbacks(
|
||||
taskStatusCallback: _handleDownloadUpdate,
|
||||
taskProgressCallback: _handleDownloadUpdate,
|
||||
);
|
||||
try {
|
||||
_downloader.registerCallbacks(
|
||||
taskStatusCallback: _handleDownloadUpdate,
|
||||
taskProgressCallback: _handleDownloadUpdate,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('FileDownloader registerCallbacks failed: $e');
|
||||
}
|
||||
|
||||
await _restoreOperations();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_watchdogTimer?.cancel();
|
||||
_downloader.unregisterCallbacks();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WATCHDOG TIMER (Dart-side fallback for stale operations)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Start or stop watchdog timer based on whether there are operations to monitor.
|
||||
void _updateWatchdogTimer() {
|
||||
final needsWatchdog = state.operations.values.any((op) => op.needsWatchdog);
|
||||
|
||||
if (needsWatchdog && _watchdogTimer == null) {
|
||||
_watchdogTimer = Timer.periodic(watchdogCheckInterval, (_) {
|
||||
_checkForStaleOperations();
|
||||
});
|
||||
} else if (!needsWatchdog && _watchdogTimer != null) {
|
||||
_watchdogTimer?.cancel();
|
||||
_watchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for operations stuck too long (Dart-side fallback if native events stop).
|
||||
void _checkForStaleOperations() {
|
||||
final now = DateTime.now();
|
||||
|
||||
for (final entry in state.operations.entries) {
|
||||
final appId = entry.key;
|
||||
final op = entry.value;
|
||||
final startedAt = op.startedAt;
|
||||
|
||||
if (startedAt == null || now.difference(startedAt) <= watchdogTimeout) {
|
||||
continue;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[PackageManager] Watchdog: $appId stuck in ${op.runtimeType}, '
|
||||
'transitioning to error',
|
||||
);
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.installFailed,
|
||||
message: 'Operation timed out (no response from system)',
|
||||
filePath: op.filePath,
|
||||
),
|
||||
);
|
||||
if (op is Installing || op is SystemProcessing) {
|
||||
clearInstallSlot(appId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// QUERIES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -225,12 +274,23 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
|
||||
void setOperation(String appId, InstallOperation op) {
|
||||
state = state.copyWith(operations: {...state.operations, appId: op});
|
||||
_updateWatchdogTimer();
|
||||
}
|
||||
|
||||
void clearOperation(String appId) {
|
||||
state = state.copyWith(
|
||||
operations: Map.from(state.operations)..remove(appId),
|
||||
);
|
||||
_updateWatchdogTimer();
|
||||
}
|
||||
|
||||
/// Clear all completed and failed operations from the map.
|
||||
/// Called after the batch completion display timeout.
|
||||
void clearCompletedOperations() {
|
||||
final remaining = Map.of(state.operations)
|
||||
..removeWhere((_, op) => op is Completed || op is OperationFailed);
|
||||
state = state.copyWith(operations: remaining);
|
||||
_updateWatchdogTimer();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -326,7 +386,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
if (task is DownloadTask) {
|
||||
await _downloader.pause(task);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('[PackageManager] Failed to pause download for $appId: $e');
|
||||
// Download library will handle the state via callbacks
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resumeDownload(String appId) async {
|
||||
@@ -346,8 +409,29 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
taskId: op.taskId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Task not found - transition to error
|
||||
debugPrint('[PackageManager] Resume failed: task not found for $appId');
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Download task not found. Please try again.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('[PackageManager] Failed to resume download for $appId: $e');
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Failed to resume download: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelDownload(String appId) async {
|
||||
@@ -378,10 +462,15 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
// INSTALL OPERATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Trigger install from ReadyToInstall state
|
||||
/// Trigger install from ReadyToInstall state.
|
||||
/// CRITICAL: Must clear install slot on any early return to prevent queue stall.
|
||||
Future<void> triggerInstall(String appId) async {
|
||||
final op = getOperation(appId);
|
||||
if (op is! ReadyToInstall) return;
|
||||
if (op is! ReadyToInstall) {
|
||||
// Operation changed (e.g., cancelled) - clear slot and advance queue
|
||||
clearInstallSlot(appId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await File(op.filePath).exists()) {
|
||||
setOperation(
|
||||
@@ -392,6 +481,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
message: 'Downloaded file not found. Please download again.',
|
||||
),
|
||||
);
|
||||
// CRITICAL: Clear install slot so queue can advance to next app
|
||||
clearInstallSlot(appId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -834,6 +925,17 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
Future.delayed(const Duration(milliseconds: 500), processQueue);
|
||||
}
|
||||
|
||||
/// Clear the active install slot and advance the queue.
|
||||
/// Call this when an install fails to start or completes.
|
||||
@protected
|
||||
void clearInstallSlot(String appId) {
|
||||
if (activeInstall == appId) {
|
||||
activeInstall = null;
|
||||
}
|
||||
installQueue.remove(appId);
|
||||
scheduleProcessQueue();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// INSTALL FLOW
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -908,6 +1010,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
appId,
|
||||
InstallCancelled(target: target, filePath: filePath),
|
||||
);
|
||||
// CRITICAL: Clear install slot so queue can advance to next app.
|
||||
// This handles exceptions that escape install()'s internal error handling.
|
||||
clearInstallSlot(appId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -936,6 +1041,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
// CRITICAL: Clear install slot so queue can advance to next app.
|
||||
// This handles exceptions that escape install()'s internal error handling.
|
||||
clearInstallSlot(appId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,6 +1114,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
case TaskStatus.running:
|
||||
case TaskStatus.enqueued:
|
||||
case TaskStatus.waitingToRetry:
|
||||
// Track as active download to respect maxConcurrentDownloads limit
|
||||
activeDownloads.add(appId);
|
||||
setOperation(
|
||||
appId,
|
||||
Downloading(
|
||||
@@ -1016,7 +1126,18 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
);
|
||||
try {
|
||||
await _downloader.resume(task);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
// Resume failed - transition to error state to avoid hang
|
||||
activeDownloads.remove(appId);
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: fileMetadata,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Failed to resume download: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case TaskStatus.paused:
|
||||
@@ -1282,19 +1403,10 @@ class BatchProgress {
|
||||
/// Whether all operations are complete (all terminal)
|
||||
bool get isAllComplete => !hasInProgress && total > 0;
|
||||
|
||||
/// Status text for display
|
||||
/// Status text for display - simple "X of Y completed" format
|
||||
String get statusText {
|
||||
if (total == 0) return '';
|
||||
|
||||
final completedText = '$completed of $total updated';
|
||||
|
||||
return switch (phase) {
|
||||
BatchPhase.installing => '$completedText • Installing...',
|
||||
BatchPhase.verifying => '$completedText • Verifying...',
|
||||
BatchPhase.downloading => '$completedText • Downloading...',
|
||||
BatchPhase.completed => '$completedText ✓',
|
||||
BatchPhase.idle => completedText,
|
||||
};
|
||||
return '$completed of $total completed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1344,12 +1456,12 @@ final batchProgressProvider = Provider<BatchProgress?>((ref) {
|
||||
final phase = installing > 0
|
||||
? BatchPhase.installing
|
||||
: verifying > 0
|
||||
? BatchPhase.verifying
|
||||
: downloading > 0
|
||||
? BatchPhase.downloading
|
||||
: (completed > 0 && queued == 0)
|
||||
? BatchPhase.completed
|
||||
: BatchPhase.idle;
|
||||
? BatchPhase.verifying
|
||||
: downloading > 0
|
||||
? BatchPhase.downloading
|
||||
: (completed > 0 && queued == 0)
|
||||
? BatchPhase.completed
|
||||
: BatchPhase.idle;
|
||||
|
||||
return BatchProgress(
|
||||
total: total,
|
||||
|
||||
@@ -68,8 +68,105 @@ Root causes identified and fixed:
|
||||
- Defined `batchQueueDelayMs` constant in `install_operation.dart`
|
||||
- Prevents Riverpod rebuild flood on "Update All" with many apps
|
||||
|
||||
## Problem Solved (Phase 3 - Complete)
|
||||
|
||||
User report: Apps stuck in "Queued for update" / "Requesting update" state indefinitely after app restart.
|
||||
|
||||
Root cause: When the app is killed during install phase, native Android retains pending install sessions but Dart's operations map starts fresh. On restart, native sends `pendingUserAction` events for sessions that Dart doesn't track. Previously these were ignored, leaving orphaned sessions that blocked the PackageInstaller.
|
||||
|
||||
## Tasks Completed (Phase 3)
|
||||
|
||||
- [x] 1. Abort orphaned native sessions on event receipt
|
||||
- Modified `_handleInstallEvent()` to call `abortInstall(appId)` when receiving events for untracked appIds
|
||||
- Satisfies FEAT-001 requirement: "If the app is killed during install, reset UI and clean up temp files on next launch"
|
||||
- Allows user to retry from clean state
|
||||
|
||||
- [x] 2. Prevent abort spam when native keeps sending events
|
||||
- Added `_abortedOrphans: Set<String>` to track appIds already aborted
|
||||
- Only call `abortInstall()` once per appId per session
|
||||
- Clear from set in `setOperation()` override so future orphans can be aborted
|
||||
|
||||
## Problem Solved (Phase 4 - Complete)
|
||||
|
||||
User report: Multiple apps stuck in "Queued for update" state indefinitely, with no app actively installing.
|
||||
|
||||
Root cause: In `processQueue()`, `activeInstall` is set BEFORE calling `triggerInstall()`. If `triggerInstall()` returns early (operation changed, or downloaded file not found), `activeInstall` is never cleared. This permanently blocks the install queue since `activeInstall != null` prevents any new installs from starting.
|
||||
|
||||
## Tasks Completed (Phase 4)
|
||||
|
||||
- [x] 1. Add `clearInstallSlot()` helper method in base class
|
||||
- Clears `activeInstall` if it matches the appId
|
||||
- Removes appId from `installQueue`
|
||||
- Calls `scheduleProcessQueue()` to advance to next app
|
||||
- Replaces duplicate `_onInstallComplete()` in android subclass
|
||||
|
||||
- [x] 2. Fix `triggerInstall()` early return paths
|
||||
- Call `clearInstallSlot(appId)` when operation is not ReadyToInstall
|
||||
- Call `clearInstallSlot(appId)` when downloaded file is missing
|
||||
- Prevents install queue from getting permanently stuck
|
||||
|
||||
## Problem Solved (Phase 5 - Complete)
|
||||
|
||||
Code audit found several potential hanging state bugs not covered by existing timeout mechanisms:
|
||||
|
||||
1. `_performInstall` catch block didn't call `clearInstallSlot`, causing queue hang if exception escapes
|
||||
2. Restored downloads weren't added to `activeDownloads`, potentially exceeding concurrent limits
|
||||
3. Download resume failures during restoration were silently swallowed, causing permanent hang
|
||||
4. EventChannel disconnect was silently ignored with no reconnection
|
||||
5. No Dart-side watchdog as fallback if native events stop arriving
|
||||
|
||||
## Tasks Completed (Phase 5)
|
||||
|
||||
- [x] 1. Fix `_performInstall` catch block to call `clearInstallSlot`
|
||||
- Ensures install queue advances even if exception escapes `install()` internal handling
|
||||
|
||||
- [x] 2. Track restored downloads in `activeDownloads`
|
||||
- Added `activeDownloads.add(appId)` in `_restoreOperation` for running downloads
|
||||
- Prevents exceeding `maxConcurrentDownloads` limit after app restart
|
||||
|
||||
- [x] 3. Handle download resume failures during restoration
|
||||
- On resume failure, transition to `OperationFailed` instead of silent failure
|
||||
- Prevents operations stuck in `Downloading` state with no active task
|
||||
|
||||
- [x] 4. Add EventChannel reconnection logic
|
||||
- Added `onError` and `onDone` handlers to event stream
|
||||
- Automatic reconnection with 2-second delay on disconnect
|
||||
- Prevents installs hanging forever if EventChannel breaks
|
||||
|
||||
- [x] 5. Add Dart-side watchdog timer as fallback
|
||||
- Added `startedAt` field to `Verifying` and `SystemProcessing` states
|
||||
- Added `needsWatchdog` and `startedAt` getters to extension for uniform access
|
||||
- Single 5-minute timeout for all watchdog-monitored states
|
||||
- Timer runs every 30s, only when there are operations needing watchdog
|
||||
- Transitions stale operations to `OperationFailed` state
|
||||
|
||||
- [x] 6. Improve pause/resume error handling
|
||||
- Added debug logging for pause failures
|
||||
- Resume failure now transitions to `OperationFailed` instead of silent failure
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-02-04 — Dart-side watchdog timer
|
||||
|
||||
**Context:** Native Kotlin has timeouts but if EventChannel breaks or native crashes before sending events, Dart operations hang forever.
|
||||
**Options:** A) Trust native completely, B) Add Dart watchdog as fallback, C) Duplicate all timeout logic in Dart.
|
||||
**Decision:** Option B - Dart-side watchdog as defense-in-depth.
|
||||
**Rationale:** Minimal complexity (~50 lines), only runs when needed, uses generous timeouts (2-10min) so it doesn't interfere with native handling but catches catastrophic failures.
|
||||
|
||||
### 2026-02-04 — Install slot clearing on early return
|
||||
|
||||
**Context:** `triggerInstall()` can bail early if operation changed or file is missing, but `activeInstall` was set before the call, blocking the queue forever.
|
||||
**Options:** A) Move `activeInstall` assignment inside `triggerInstall()` after validation, B) Have `triggerInstall()` clear slot on early returns, C) Return success boolean and only set slot if true.
|
||||
**Decision:** Option B - Clear install slot on early returns.
|
||||
**Rationale:** Minimal change, maintains existing flow, easy to audit. Added `clearInstallSlot()` to base class to consolidate the pattern.
|
||||
|
||||
### 2026-02-04 — Orphaned session cleanup
|
||||
|
||||
**Context:** Native install sessions persist across app restarts, but Dart state doesn't. Events arrive for untracked appIds.
|
||||
**Options:** A) Ignore events (existing), B) Abort orphaned sessions, C) Attempt to restore operations from native state.
|
||||
**Decision:** Option B - Abort orphaned sessions immediately.
|
||||
**Rationale:** Simplest fix that satisfies "no hanging states" invariant. User can retry from clean state. Option C adds complexity and may restore stale/invalid state.
|
||||
|
||||
### 2026-02-03 — Committed session handling
|
||||
|
||||
**Context:** `abandonSession()` is ineffective after commit.
|
||||
@@ -164,7 +261,7 @@ Every state MUST resolve to a terminal state. No exceptions.
|
||||
| `blocked` | Device policy |
|
||||
| `installTimeout` | Watchdog timeout (uncommitted only) |
|
||||
|
||||
### Watchdog Timeouts
|
||||
### Watchdog Timeouts (Kotlin Native)
|
||||
|
||||
| Phase | Initial | Max | Behavior |
|
||||
|-------|---------|-----|----------|
|
||||
@@ -172,6 +269,12 @@ Every state MUST resolve to a terminal state. No exceptions.
|
||||
| Install (uncommitted) | 10s | 120s | Abandon session, emit FAILED |
|
||||
| Install (committed) | 10s | ∞ | Cannot abandon; extend deadline, show "processing" |
|
||||
|
||||
### Dart-side Watchdog (Fallback)
|
||||
|
||||
Single 5-minute timeout for all watchdog-monitored states (Verifying, Installing, SystemProcessing).
|
||||
Timer runs every 30s, only when monitored operations exist.
|
||||
Defense-in-depth for cases where EventChannel breaks or native crashes before sending events.
|
||||
|
||||
### Session Tracking Maps
|
||||
|
||||
| Map | Purpose |
|
||||
|
||||
Reference in New Issue
Block a user