Files
zapstore/lib/services/package_manager/package_manager.dart
T
Henrique Velloso 60e2bac3a2 hotfix: show installing state on install retry
Retrying from Install (retry) kept the operation in InstallCancelled until native callbacks arrived. In that state, the Android handler ignores install events, so the UI could stay stuck without showing progress.
Set retryInstall() to switch to Installing before invoking native install, ensuring immediate feedback and allowing retry events to be processed normally.
2026-02-20 15:23:11 -03:00

1623 lines
54 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/device_capabilities.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/install_operation.dart';
export 'device_capabilities.dart';
export 'install_operation.dart';
// ═══════════════════════════════════════════════════════════════════════════════
// PACKAGE INFO
// ═══════════════════════════════════════════════════════════════════════════════
/// Information about an installed package
class PackageInfo extends Equatable {
const PackageInfo({
required this.appId,
required this.version,
required this.versionCode,
required this.signatureHash,
this.name,
this.installTime,
this.canInstallSilently = false,
});
final String appId;
final String? name;
final String version;
final int? versionCode;
final String signatureHash;
final DateTime? installTime;
final bool canInstallSilently;
@override
List<Object?> get props => [
appId,
name,
version,
versionCode,
signatureHash,
installTime,
canInstallSilently,
];
}
// ═══════════════════════════════════════════════════════════════════════════════
// PACKAGE MANAGER STATE
// ═══════════════════════════════════════════════════════════════════════════════
/// Combined state for all package-related data
class PackageManagerState extends Equatable {
const PackageManagerState({
this.installed = const {},
this.operations = const {},
this.isScanning = false,
});
/// Map of appId → installed package info
final Map<String, PackageInfo> installed;
/// Map of appId → active install operation
final Map<String, InstallOperation> operations;
/// Whether installed packages are currently being scanned
final bool isScanning;
PackageManagerState copyWith({
Map<String, PackageInfo>? installed,
Map<String, InstallOperation>? operations,
bool? isScanning,
}) {
return PackageManagerState(
installed: installed ?? this.installed,
operations: operations ?? this.operations,
isScanning: isScanning ?? this.isScanning,
);
}
@override
List<Object?> get props => [installed, operations, isScanning];
}
// ═══════════════════════════════════════════════════════════════════════════════
// PACKAGE MANAGER BASE CLASS
// ═══════════════════════════════════════════════════════════════════════════════
/// Package management interface - the single source of truth for:
/// 1. Installed packages
/// 2. Active install operations (download/verify/install)
///
/// Architecture:
/// - Download phase: Managed by background_downloader (can pause/resume/cancel)
/// - Install phase: Platform-specific, event-driven (no hanging awaits)
/// - Explicit queues: Ordered lists for downloads and installs (not derived from state)
abstract class PackageManager extends StateNotifier<PackageManagerState> {
PackageManager(this.ref) : super(const PackageManagerState()) {
_downloaderInit = _initializeDownloader();
}
final Ref ref;
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.
// Protected for subclass access (e.g., AndroidPackageManager).
// ═══════════════════════════════════════════════════════════════════════════
/// Ordered download queue (appIds waiting for download slot)
@protected
final List<String> downloadQueue = [];
/// Ordered install queue (appIds waiting for install slot)
@protected
final List<String> installQueue = [];
/// Currently active downloads (appIds)
@protected
final Set<String> activeDownloads = {};
/// Currently active install (only 1 allowed due to Android PackageInstaller)
@protected
String? activeInstall;
/// Lock to prevent concurrent queue processing
bool _processingQueue = false;
/// Dynamic max concurrent downloads based on device capability
int get maxConcurrentDownloads =>
DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads;
Future<void> _ensureDownloaderReady() => _downloaderInit;
/// Hook called when an app transitions into [ReadyToInstall].
///
/// Default behavior is to process the queue, which will start the install
/// if no other install is active. Subclasses can override for custom behavior.
@protected
void onInstallReady(String appId) {
unawaited(processQueue());
}
// ═══════════════════════════════════════════════════════════════════════════
// INITIALIZATION
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _initializeDownloader() async {
_downloader = FileDownloader();
try {
await _downloader.configure(
globalConfig: [
(Config.requestTimeout, const Duration(seconds: 20)),
(Config.resourceTimeout, const Duration(minutes: 30)),
(Config.checkAvailableSpace, Config.never),
],
androidConfig: [(Config.useCacheDir, false)],
);
} catch (e) {
debugPrint('FileDownloader configure failed: $e');
}
// Note: We don't call configureNotificationForGroup because we handle
// our own UI for download progress. The package defaults to no notifications.
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();
var needsQueueProcessing = false;
for (final entry in state.operations.entries) {
final appId = entry.key;
final op = entry.value;
final timestamp = op.watchdogTimestamp;
if (timestamp == null || now.difference(timestamp) <= watchdogTimeout) {
continue;
}
debugPrint(
'[PackageManager] Watchdog: $appId stuck in ${op.runtimeType}, '
'transitioning to error',
);
// Use appropriate failure type and cleanup based on operation type
if (op is Downloading) {
activeDownloads.remove(appId);
// Try to cancel the stuck task
unawaited(
_downloader.cancelTaskWithId(op.taskId).catchError((_) => false),
);
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message:
'Download timed out. Please check your internet connection and try again.',
),
);
needsQueueProcessing = true;
} else {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.installFailed,
message: 'Installation timed out. Please try again.',
filePath: op.filePath,
),
);
if (op is Installing ||
op is SystemProcessing ||
op is ReadyToInstall) {
clearInstallSlot(appId);
needsQueueProcessing = true;
}
}
}
if (needsQueueProcessing) {
scheduleProcessQueue();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// QUERIES
// ═══════════════════════════════════════════════════════════════════════════
bool isInstalled(String appId) => state.installed.containsKey(appId);
PackageInfo? getInstalled(String appId) => state.installed[appId];
InstallOperation? getOperation(String appId) => state.operations[appId];
bool hasOperation(String appId) => state.operations.containsKey(appId);
int countOperations<T extends InstallOperation>() =>
state.operations.values.whereType<T>().length;
List<String> getReadyToInstall() => state.operations.entries
.where((e) => e.value is ReadyToInstall)
.map((e) => e.key)
.toList();
List<String> getInstallCancelled() => state.operations.entries
.where((e) => e.value is InstallCancelled)
.map((e) => e.key)
.toList();
// ═══════════════════════════════════════════════════════════════════════════
// STATE MANAGEMENT (Public for subclass use)
// ═══════════════════════════════════════════════════════════════════════════
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 terminal operations from the map.
/// Called after the batch completion display timeout.
/// Terminal states: Completed, OperationFailed, InstallCancelled.
void clearCompletedOperations() {
final remaining = Map.of(state.operations)
..removeWhere(
(_, op) =>
op is Completed || op is OperationFailed || op is InstallCancelled,
);
state = state.copyWith(operations: remaining);
_updateWatchdogTimer();
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD OPERATIONS
// ═══════════════════════════════════════════════════════════════════════════
/// Start download - returns false if operation already exists for this app
/// [displayName] is shown in system notification (defaults to appId if null)
Future<bool> startDownload(
String appId,
FileMetadata target, {
String? displayName,
}) async {
await _ensureDownloaderReady();
final existing = getOperation(appId);
if (existing != null) {
// We keep terminal states (Completed/Failed) in the operations map briefly so
// batch progress UI can derive totals. However, starting a new download for the
// same app should not be blocked by a stale terminal op (e.g. install -> uninstall
// -> install again). Only in-flight operations should block.
if (existing is Completed || existing is OperationFailed) {
clearOperation(appId);
} else {
return false;
}
}
final downloadUrl = target.urls.firstOrNull;
if (downloadUrl == null || downloadUrl.isEmpty) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Download link unavailable.',
),
);
return false;
}
// Add to explicit queue and set UI state
downloadQueue.add(appId);
setOperation(
appId,
DownloadQueued(target: target, displayName: displayName),
);
// Process queue to potentially start this download immediately
unawaited(processQueue());
return true;
}
/// Queue multiple downloads at once - staggered to prevent UI flood.
/// This is the primary method for "Update All" functionality.
Future<void> queueDownloads(
List<({String appId, FileMetadata target, String? displayName})> items,
) async {
await _ensureDownloaderReady();
// Filter out items that already have operations
final toQueue = items.where((item) => !hasOperation(item.appId)).toList();
if (toQueue.isEmpty) return;
// Queue items with staggered delays to prevent UI flood
for (var i = 0; i < toQueue.length; i++) {
final item = toQueue[i];
final downloadUrl = item.target.urls.firstOrNull;
if (downloadUrl == null || downloadUrl.isEmpty) {
setOperation(
item.appId,
OperationFailed(
target: item.target,
type: FailureType.downloadFailed,
message: 'Download link unavailable. Please try again later.',
),
);
} else {
// Add to explicit queue
downloadQueue.add(item.appId);
setOperation(
item.appId,
DownloadQueued(target: item.target, displayName: item.displayName),
);
}
// Stagger state updates to prevent Riverpod rebuild flood
if (i < toQueue.length - 1) {
await Future.delayed(const Duration(milliseconds: batchQueueDelayMs));
}
}
// Process queue to start actual downloads
unawaited(processQueue());
}
Future<void> pauseDownload(String appId) async {
await _ensureDownloaderReady();
final op = getOperation(appId);
if (op is! Downloading) return;
try {
final task = await _downloader.taskForId(op.taskId);
if (task is DownloadTask) {
final paused = await _downloader.pause(task);
if (!paused) {
// Pause failed - task may be stuck, transition to failed state
debugPrint(
'[PackageManager] Pause returned false for $appId, marking as failed',
);
activeDownloads.remove(appId);
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download stopped. Please try again.',
),
);
// Cancel the stuck task to clean up
try {
await _downloader.cancelTaskWithId(op.taskId);
} catch (_) {}
scheduleProcessQueue();
}
} else {
// Task not found - download is in zombie state
debugPrint(
'[PackageManager] Task not found for $appId, marking as failed',
);
activeDownloads.remove(appId);
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download was interrupted. Please try again.',
),
);
scheduleProcessQueue();
}
} catch (e) {
debugPrint('[PackageManager] Failed to pause download for $appId: $e');
activeDownloads.remove(appId);
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download failed. Please try again.',
description: '$e',
),
);
scheduleProcessQueue();
}
}
Future<void> resumeDownload(String appId) async {
await _ensureDownloaderReady();
final op = getOperation(appId);
if (op is! DownloadPaused) return;
try {
final task = await _downloader.taskForId(op.taskId);
if (task is DownloadTask) {
await _downloader.resume(task);
setOperation(
appId,
Downloading(
target: op.target,
progress: op.progress,
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 was interrupted. Please try again.',
),
);
}
} 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. Please start again.',
description: '$e',
),
);
}
}
Future<void> cancelDownload(String appId) async {
await _ensureDownloaderReady();
final op = getOperation(appId);
if (op == null || !op.isDownloading) return;
if (op is Downloading) {
try {
await _downloader.cancelTaskWithId(op.taskId);
} catch (_) {}
} else if (op is DownloadPaused) {
try {
await _downloader.cancelTaskWithId(op.taskId);
} catch (_) {}
}
// Remove from queues
downloadQueue.remove(appId);
activeDownloads.remove(appId);
clearOperation(appId);
// Advance queue
scheduleProcessQueue();
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTALL OPERATIONS
// ═══════════════════════════════════════════════════════════════════════════
/// 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) {
// Operation changed (e.g., cancelled) - clear slot and advance queue
clearInstallSlot(appId);
return;
}
if (!await File(op.filePath).exists()) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Downloaded file not found. Please download again.',
),
);
// CRITICAL: Clear install slot so queue can advance to next app
clearInstallSlot(appId);
return;
}
// Directly perform install - permission was already checked when
// transitioning to ReadyToInstall state. Don't call _proceedToInstall
// here as that would re-set ReadyToInstall and call onInstallReady again,
// causing an infinite loop.
await _performInstall(appId, op.target, op.filePath);
}
/// Retry install from InstallCancelled state
Future<void> retryInstall(String appId) async {
final op = getOperation(appId);
if (op is! InstallCancelled) return;
if (!await File(op.filePath).exists()) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Downloaded file not found. Please download again.',
),
);
return;
}
// Exit InstallCancelled immediately so UI shows progress and native
// install events are no longer ignored by AndroidPackageManager.
setOperation(appId, Installing(target: op.target, filePath: op.filePath));
await _performInstall(appId, op.target, op.filePath);
}
/// Force update (uninstall + install) from OperationFailed with certMismatch
Future<void> forceUpdate(String appId) async {
final op = getOperation(appId);
if (op is! OperationFailed || !op.needsForceUpdate) return;
final filePath = op.filePath;
if (filePath == null || !await File(filePath).exists()) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Downloaded file not found. Please download again.',
),
);
return;
}
setOperation(appId, Uninstalling(target: op.target, filePath: filePath));
try {
await uninstall(appId);
await _performInstall(appId, op.target, filePath);
} catch (e) {
final errorMessage = e.toString();
if (!errorMessage.contains('cancelled')) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.installFailed,
message: 'Update failed.',
description: errorMessage,
filePath: filePath,
),
);
} else {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.certMismatch,
message:
'Update signed by different developer. Uninstall current version to update.',
filePath: filePath,
),
);
}
}
}
/// Dismiss error and clean up
void dismissError(String appId) {
final op = getOperation(appId);
if (op is! OperationFailed) return;
final filePath = op.filePath;
if (filePath != null) {
_deleteFile(filePath);
}
clearOperation(appId);
}
/// Called when permission is granted.
/// Advances the specified app AND all other apps awaiting permission.
Future<void> onPermissionGranted(String appId) async {
// Collect all apps that need to advance (AwaitingPermission or permissionDenied failures)
final toAdvance = <String, (FileMetadata target, String filePath)>{};
for (final entry in state.operations.entries) {
final id = entry.key;
final op = entry.value;
switch (op) {
case AwaitingPermission(:final target, :final filePath):
toAdvance[id] = (target, filePath);
case OperationFailed(:final target, :final type, :final filePath)
when type == FailureType.permissionDenied && filePath != null:
toAdvance[id] = (target, filePath);
default:
continue;
}
}
if (toAdvance.isEmpty) return;
// Advance the requested app first (for responsive UX)
if (toAdvance.containsKey(appId)) {
final (target, filePath) = toAdvance.remove(appId)!;
_addToInstallQueue(appId, target, filePath);
}
// Advance remaining apps
for (final entry in toAdvance.entries) {
final (target, filePath) = entry.value;
_addToInstallQueue(entry.key, target, filePath);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD INTERNALS
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _startDownloadTask(
String appId,
FileMetadata target,
String downloadUrl, {
String? displayName,
bool isCdnRetry = false,
}) async {
final fileName = '${target.hash}$packageExtension';
final metaData = _encodeTaskMetadata(
appId,
target.id,
isCdnRetry: isCdnRetry,
);
final task = DownloadTask(
taskId:
'${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}',
url: downloadUrl,
filename: fileName,
updates: Updates.statusAndProgress,
requiresWiFi: false,
retries: 10,
allowPause: true,
metaData: metaData,
displayName: displayName ?? appId,
);
setOperation(
appId,
Downloading(target: target, progress: 0.0, taskId: task.taskId),
);
try {
final result = await _downloader.enqueue(task);
if (!result) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Failed to start download',
),
);
}
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Failed to start download. Please try again.',
description: '$e',
),
);
}
}
void _handleDownloadUpdate(TaskUpdate update) {
// Fast path: decode appId from task metadata (no O(n) scan on each tick).
String? appId;
InstallOperation? operation;
final metaData = update.task.metaData;
bool isCdnRetry = false;
if (metaData.isNotEmpty) {
final (decodedAppId, _, cdnRetry) = _parseTaskMetadata(metaData);
isCdnRetry = cdnRetry;
if (decodedAppId != null) {
final op = getOperation(decodedAppId);
// Ensure the operation actually matches this taskId (metadata could be stale).
if (op is Downloading && op.taskId == update.task.taskId) {
appId = decodedAppId;
operation = op;
} else if (op is DownloadPaused && op.taskId == update.task.taskId) {
appId = decodedAppId;
operation = op;
}
}
}
// Fallback: old tasks without metadata, or mismatched metadata.
if (appId == null || operation == null) {
for (final entry in state.operations.entries) {
final op = entry.value;
if (op is Downloading && op.taskId == update.task.taskId) {
appId = entry.key;
operation = op;
break;
} else if (op is DownloadPaused && op.taskId == update.task.taskId) {
appId = entry.key;
operation = op;
break;
}
}
}
if (appId == null || operation == null) return;
if (update is TaskStatusUpdate) {
_handleDownloadStatusUpdate(appId, operation, update, isCdnRetry);
} else if (update is TaskProgressUpdate) {
_handleDownloadProgressUpdate(appId, operation, update);
}
}
void _handleDownloadStatusUpdate(
String appId,
InstallOperation operation,
TaskStatusUpdate update,
bool isCdnRetry,
) {
final target = operation.target;
switch (update.status) {
case TaskStatus.running:
if (operation is DownloadPaused) {
setOperation(
appId,
Downloading(
target: target,
progress: operation.progress,
taskId: operation.taskId,
),
);
}
break;
case TaskStatus.paused:
if (operation is Downloading) {
setOperation(
appId,
DownloadPaused(
target: target,
progress: operation.progress,
taskId: operation.taskId,
),
);
}
break;
case TaskStatus.complete:
unawaited(
_handleDownloadComplete(appId, target, update.task as DownloadTask),
);
break;
case TaskStatus.notFound:
// 404 error - retry with CDN fallback if not already tried
if (!isCdnRetry) {
final cdnUrl = 'https://cdn.zapstore.dev/${target.hash}';
unawaited(
_startDownloadTask(appId, target, cdnUrl, isCdnRetry: true),
);
return;
}
// CDN also returned 404 - fail the operation
activeDownloads.remove(appId);
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message:
'File no longer available (404). Please check for a newer version.',
),
);
scheduleProcessQueue();
break;
case TaskStatus.failed:
String? errorDetails;
final exception = update.exception;
if (exception != null) {
errorDetails = exception.toString();
if (errorDetails.length > 500) {
errorDetails = '${errorDetails.substring(0, 497)}...';
}
}
activeDownloads.remove(appId);
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Download failed. Please try again.',
description: errorDetails,
),
);
scheduleProcessQueue();
break;
case TaskStatus.canceled:
activeDownloads.remove(appId);
downloadQueue.remove(appId);
clearOperation(appId);
scheduleProcessQueue();
break;
default:
break;
}
}
void _handleDownloadProgressUpdate(
String appId,
InstallOperation operation,
TaskProgressUpdate update,
) {
if (operation is! Downloading) return;
double progress = operation.progress;
if (update.progress.isFinite &&
!update.progress.isNaN &&
update.progress >= 0.0) {
progress = update.progress.clamp(0.0, 1.0);
}
// Throttle: update state only when the displayed percentage changes.
final oldPercent = (operation.progress * 100).floor();
final newPercent = (progress * 100).floor();
if (newPercent == oldPercent) return;
setOperation(appId, operation.copyWith(progress: progress));
}
Future<void> _handleDownloadComplete(
String appId,
FileMetadata target,
DownloadTask task,
) async {
// Remove from active downloads
activeDownloads.remove(appId);
try {
final filePath = await task.filePath();
// Proceed to install (this will add to install queue when ready)
await _proceedToInstall(appId, target, filePath);
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Failed to access downloaded file.',
description: '$e',
),
);
}
// Advance download queue
scheduleProcessQueue();
}
/// Unified queue processor with lock to prevent race conditions.
/// Handles both download and install queues.
@protected
Future<void> processQueue() async {
// Lock to prevent concurrent processing
if (_processingQueue) return;
_processingQueue = true;
try {
// Process download queue: fill available slots
while (activeDownloads.length < maxConcurrentDownloads &&
downloadQueue.isNotEmpty) {
final appId = downloadQueue.removeAt(0);
final op = getOperation(appId);
if (op is! DownloadQueued) {
// Operation was cancelled or changed, skip
continue;
}
final downloadUrl = op.target.urls.firstOrNull;
if (downloadUrl == null) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download link unavailable.',
),
);
continue;
}
activeDownloads.add(appId);
await _startDownloadTask(
appId,
op.target,
downloadUrl,
displayName: op.displayName,
);
}
// Process install queue: only 1 at a time (Android PackageInstaller limit)
if (activeInstall == null && installQueue.isNotEmpty) {
final appId = installQueue.removeAt(0);
final op = getOperation(appId);
if (op is ReadyToInstall) {
activeInstall = appId;
// Mark triggeredAt so the watchdog can detect native sessions that
// never respond (e.g. Android PackageInstaller silently fails).
setOperation(
appId,
ReadyToInstall(
target: op.target,
filePath: op.filePath,
triggeredAt: DateTime.now(),
),
);
unawaited(triggerInstall(appId));
}
// If operation changed, it will be picked up on next process cycle
}
} finally {
_processingQueue = false;
}
}
/// Schedule queue processing on the next microtask.
@protected
void scheduleProcessQueue() {
Future.microtask(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
// ═══════════════════════════════════════════════════════════════════════════
/// Check permission and proceed to install
Future<void> _proceedToInstall(
String appId,
FileMetadata target,
String filePath,
) async {
if (!await hasPermission()) {
setOperation(
appId,
AwaitingPermission(target: target, filePath: filePath),
);
try {
await requestPermission();
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.permissionDenied,
message: e.toString().replaceFirst('Exception: ', ''),
filePath: filePath,
),
);
return;
}
if (!await hasPermission()) return;
// Permission was just granted - advance ALL waiting apps, not just this one
await onPermissionGranted(appId);
return;
}
// Permission was already granted - add to install queue
_addToInstallQueue(appId, target, filePath);
}
/// Add app to install queue and trigger processing.
void _addToInstallQueue(String appId, FileMetadata target, String filePath) {
if (!installQueue.contains(appId)) {
installQueue.add(appId);
}
setOperation(appId, ReadyToInstall(target: target, filePath: filePath));
onInstallReady(appId);
}
/// Perform the actual installation
Future<void> _performInstall(
String appId,
FileMetadata target,
String filePath,
) async {
try {
await install(
appId,
filePath,
expectedHash: target.hash,
expectedSize: target.size ?? 0,
target: target,
);
// For event-driven platforms, install() returns immediately
// and results come via events. For sync platforms, it completes here.
} catch (e) {
final message = e.toString().replaceFirst('Exception: ', '');
if (message.contains('cancelled') || message.contains('ABORTED')) {
setOperation(
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;
}
final isCertMismatch =
message.contains('signatures do not match') ||
message.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') ||
message.contains('UPDATE_INCOMPATIBLE');
final isHashMismatch = message.contains('Hash verification failed');
final isInvalidFile = message.contains('Invalid APK file');
final userMessage = isCertMismatch
? 'Update signed by different developer. Uninstall current version to update.'
: isHashMismatch
? 'Hash mismatch. Possibly a malicious file, aborting installation.'
: isInvalidFile
? 'Invalid app file. The download may be corrupt.'
: 'Installation failed.';
setOperation(
appId,
OperationFailed(
target: target,
type: isInvalidFile
? FailureType.invalidFile
: isHashMismatch
? FailureType.hashMismatch
: isCertMismatch
? FailureType.certMismatch
: FailureType.installFailed,
message: userMessage,
description: message,
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);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESTORATION
// ═══════════════════════════════════════════════════════════════════════════
Future<void> _restoreOperations() async {
try {
final records = await _downloader.database.allRecords(
group: FileDownloader.defaultGroup,
);
for (final record in records) {
final task = record.task;
if (task is! DownloadTask) continue;
final metaData = task.metaData;
if (metaData.isEmpty) {
await _cleanupTask(task);
continue;
}
final (appId, metadataId, _) = _parseTaskMetadata(metaData);
if (appId == null) {
await _cleanupTask(task);
continue;
}
final taskAge = DateTime.now().difference(task.creationTime);
if (taskAge > staleOperationThreshold) {
await _cleanupTask(task);
continue;
}
final fileMetadata = await _loadFileMetadata(metadataId, task.filename);
if (fileMetadata == null) {
await _cleanupTask(task);
continue;
}
await _restoreOperation(appId, record, task, fileMetadata);
}
} catch (e) {
debugPrint('Failed to restore operations: $e');
}
}
Future<void> _restoreOperation(
String appId,
TaskRecord record,
DownloadTask task,
FileMetadata fileMetadata,
) async {
switch (record.status) {
case TaskStatus.complete:
final filePath = await task.filePath();
if (await File(filePath).exists()) {
await syncInstalledPackages();
if (state.installed.containsKey(appId)) {
_deleteFile(filePath);
} else {
await _proceedToInstall(appId, fileMetadata, filePath);
}
}
break;
case TaskStatus.running:
case TaskStatus.enqueued:
case TaskStatus.waitingToRetry:
// Track as active download to respect maxConcurrentDownloads limit
activeDownloads.add(appId);
setOperation(
appId,
Downloading(
target: fileMetadata,
progress: record.progress,
taskId: task.taskId,
),
);
try {
await _downloader.resume(task);
} 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. Please start again.',
description: '$e',
),
);
}
break;
case TaskStatus.paused:
setOperation(
appId,
DownloadPaused(
target: fileMetadata,
progress: record.progress,
taskId: task.taskId,
),
);
break;
default:
await _cleanupTask(task);
break;
}
}
Future<void> _cleanupTask(DownloadTask task) async {
try {
await _downloader.cancelTaskWithId(task.taskId);
} catch (_) {}
try {
final filePath = await task.filePath();
_deleteFile(filePath);
} catch (_) {}
try {
await _downloader.database.deleteRecordWithId(task.taskId);
} catch (_) {}
}
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
void _deleteFile(String filePath) {
try {
final file = File(filePath);
if (file.existsSync()) {
file.deleteSync();
}
} catch (_) {}
}
String _encodeTaskMetadata(
String appId,
String metadataId, {
bool isCdnRetry = false,
}) {
return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}';
}
(String? appId, String? metadataId, bool isCdnRetry) _parseTaskMetadata(
String metaData,
) {
final parts = metaData.split('|');
if (parts.length >= 2) {
final isCdnRetry = parts.length >= 3 && parts[2] == '1';
return (parts[0], parts[1], isCdnRetry);
}
return (metaData.isNotEmpty ? metaData : null, null, false);
}
Future<FileMetadata?> _loadFileMetadata(
String? metadataId,
String filename,
) async {
final storage = ref.read(storageNotifierProvider.notifier);
if (metadataId != null) {
try {
final results = await storage.query(
RequestFilter<FileMetadata>(ids: {metadataId}).toRequest(),
);
if (results.isNotEmpty) return results.first;
} catch (_) {}
}
final dotIndex = filename.lastIndexOf('.');
final hash = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
if (hash.isNotEmpty) {
try {
final results = await storage.query(
RequestFilter<FileMetadata>(search: hash).toRequest(),
);
if (results.isNotEmpty) return results.first;
} catch (_) {}
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// PLATFORM ABSTRACT METHODS
// ═══════════════════════════════════════════════════════════════════════════
/// Install a package from file path.
/// For event-driven platforms (Android), this returns immediately and
/// results come via EventChannel. The `target` parameter is passed through
/// for state management.
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
});
Future<void> uninstall(String appId);
Future<void> launchApp(String appId);
Future<void> requestPermission();
Future<bool> hasPermission();
bool get supportsSilentInstall;
String get platform;
String get packageExtension;
Future<void> syncInstalledPackages();
/// Whether [latest] is an update over the installed version of the same app.
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasUpdate(String appId, FileMetadata latest) {
final installed = state.installed[appId];
if (installed == null) return false;
final installedCode = installed.versionCode;
final latestCode = latest.versionCode;
if (installedCode == null || latestCode == null) return false;
return latestCode > installedCode;
}
/// Whether [latest] would be a downgrade from the installed version.
///
/// Comparison uses Android versionCode only. Returns false when either
/// versionCode is unavailable or the app is not installed.
bool hasDowngrade(String appId, FileMetadata latest) {
final installed = state.installed[appId];
if (installed == null) return false;
final installedCode = installed.versionCode;
final latestCode = latest.versionCode;
if (installedCode == null || latestCode == null) return false;
return latestCode < installedCode;
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// PROVIDERS
// ═══════════════════════════════════════════════════════════════════════════════
final packageManagerProvider =
StateNotifierProvider<PackageManager, PackageManagerState>(
DummyPackageManager.new,
);
final installedPackageProvider = Provider.family<PackageInfo?, String>((
ref,
appId,
) {
return ref.watch(packageManagerProvider.select((s) => s.installed[appId]));
});
final installOperationProvider = Provider.family<InstallOperation?, String>((
ref,
appId,
) {
return ref.watch(packageManagerProvider.select((s) => s.operations[appId]));
});
final activeOperationsCountProvider = Provider<int>((ref) {
return ref.watch(packageManagerProvider.select((s) => s.operations.length));
});
final readyToInstallCountProvider = Provider<int>((ref) {
return ref.watch(
packageManagerProvider.select(
(s) => s.operations.values.whereType<ReadyToInstall>().length,
),
);
});
final installCancelledCountProvider = Provider<int>((ref) {
return ref.watch(
packageManagerProvider.select(
(s) => s.operations.values.whereType<InstallCancelled>().length,
),
);
});
/// Returns all installed packages as a list
final allInstalledPackagesProvider = Provider<List<PackageInfo>>((ref) {
return ref.watch(
packageManagerProvider.select((s) => s.installed.values.toList()),
);
});
/// Returns packages installed on device but not tracked in relay data.
/// [knownAppIds] should be the set of app identifiers from relay storage.
final systemOnlyPackagesProvider =
Provider.family<List<PackageInfo>, Set<String>>((ref, knownAppIds) {
return ref.watch(
packageManagerProvider.select(
(s) => s.installed.values
.where((pkg) => !knownAppIds.contains(pkg.appId))
.toList(),
),
);
});
// ═══════════════════════════════════════════════════════════════════════════════
// BATCH PROGRESS (Fully Derived State)
// ═══════════════════════════════════════════════════════════════════════════════
/// Current phase of batch operations
enum BatchPhase { downloading, verifying, installing, completed, idle }
/// Batch progress summary - ALL state derived from operations map.
///
/// When an operation succeeds, it transitions to Completed instead of being removed.
/// InstallCancelled operations are excluded from totals (already resolved).
class BatchProgress {
const BatchProgress({
required this.total,
required this.completed,
required this.completedLabel,
required this.downloading,
required this.verifying,
required this.installing,
required this.queued,
required this.failed,
required this.phase,
});
/// Total operations (excludes InstallCancelled — those are already resolved
/// from the batch's perspective and should not inflate the count).
final int total;
/// Operations that completed successfully
final int completed;
/// Label for completed operations: "updated", "installed", or "completed" (mixed)
final String completedLabel;
/// Operations currently downloading
final int downloading;
/// Operations currently verifying
final int verifying;
/// Operations currently installing
final int installing;
/// Operations waiting in queue
final int queued;
/// Operations that failed
final int failed;
/// Current dominant phase
final BatchPhase phase;
/// Whether any operations are in progress (not terminal)
/// Terminal states: Completed, OperationFailed
bool get hasInProgress =>
downloading > 0 || verifying > 0 || installing > 0 || queued > 0;
/// Whether all operations are complete (all terminal)
bool get isAllComplete => !hasInProgress && total > 0;
/// Status text for display - simple "X of Y completed" format
String get statusText {
if (total == 0) return '';
return '$completed of $total completed';
}
}
/// Provider for batch progress - ALL state derived from operations map.
///
/// No parameters needed - derives everything from PackageManagerState.operations.
final batchProgressProvider = Provider<BatchProgress?>((ref) {
final ops = ref.watch(packageManagerProvider.select((s) => s.operations));
// No operations = no banner
if (ops.isEmpty) return null;
// Count operations by type
int completed = 0,
completedUpdates = 0,
downloading = 0,
verifying = 0,
installing = 0,
queued = 0,
failed = 0;
for (final op in ops.values) {
switch (op) {
case Completed():
completed++;
if (op.isUpdate) completedUpdates++;
case DownloadQueued() || ReadyToInstall():
queued++;
case Downloading() || DownloadPaused():
downloading++;
case Verifying():
verifying++;
case Installing() || SystemProcessing():
installing++;
case OperationFailed():
failed++;
case InstallCancelled():
// Not counted in batch — already resolved (user can retry individually).
// Stays in the operations map for the "Install (retry)" UI button.
break;
case AwaitingPermission():
queued++; // Waiting for permission
case Uninstalling():
installing++; // Count as install phase
}
}
// Total excludes InstallCancelled — those are already resolved from the
// batch's perspective and should not inflate the count.
final total =
completed + downloading + verifying + installing + queued + failed;
// If only InstallCancelled operations remain, no batch to show
if (total == 0) return null;
// Determine current phase (priority: installing > verifying > downloading > completed)
final phase = installing > 0
? BatchPhase.installing
: verifying > 0
? BatchPhase.verifying
: downloading > 0
? BatchPhase.downloading
: (completed > 0 && queued == 0)
? BatchPhase.completed
: BatchPhase.idle;
// Derive label: "updated" if all updates, "installed" if all new, "completed" if mixed
final completedInstalls = completed - completedUpdates;
final completedLabel = completedUpdates > 0 && completedInstalls == 0
? 'updated'
: completedInstalls > 0 && completedUpdates == 0
? 'installed'
: 'completed';
return BatchProgress(
total: total,
completed: completed,
completedLabel: completedLabel,
downloading: downloading,
verifying: verifying,
installing: installing,
queued: queued,
failed: failed,
phase: phase,
);
});