mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Back up the list of installed apps to Nostr (encrypted AppStack) when batch installs complete or apps are uninstalled. Toggle in profile, off by default. Restore screen fetches backup and lets users reinstall apps. Changes by file: - installed_apps_backup_service: Listener for batch completion and installed-list changes; debounced backup (3s); restore-only batches ignored; timer cancelled on provider dispose; logs reduced to errors and completion only; null-aware assignment for baseline keys. - secure_storage_service: Persistence for backup-enabled toggle (off by default). - profile_screen: Toggle to enable/disable backup; entry point for restore flow. - restore_installed_apps_screen: Restore UI with shared AppBar; lists apps to install and already installed; cards navigate to app detail. - main_scaffold: Watches backup listener provider so it stays active. - package_manager: Removed unused installSourceProvider; install source handling for restore operations. - install_operation, android_package_manager, install_button, batch_progress_banner: InstallSource.restore support for restore flow. - app_card: Factory constructors (forRestore, forDiscovery, etc.) to simplify restore and other screens. - router, installed_packages_snapshot: Supporting wiring for the feature.
1674 lines
55 KiB
Dart
1674 lines
55 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;
|
|
|
|
/// Tracks the source for each app operation (normal vs restore).
|
|
final Map<String, InstallSource> _operationSourceByAppId = {};
|
|
|
|
/// 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();
|
|
|
|
InstallSource getOperationSource(String appId) {
|
|
return _operationSourceByAppId[appId] ?? InstallSource.normal;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// STATE MANAGEMENT (Public for subclass use)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
void setOperation(
|
|
String appId,
|
|
InstallOperation op, {
|
|
InstallSource? source,
|
|
}) {
|
|
if (source != null) {
|
|
_operationSourceByAppId[appId] = source;
|
|
} else {
|
|
_operationSourceByAppId.putIfAbsent(appId, () => InstallSource.normal);
|
|
}
|
|
state = state.copyWith(operations: {...state.operations, appId: op});
|
|
_updateWatchdogTimer();
|
|
}
|
|
|
|
void clearOperation(String appId) {
|
|
_operationSourceByAppId.remove(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 completedIds = state.operations.entries
|
|
.where(
|
|
(entry) =>
|
|
entry.value is Completed ||
|
|
entry.value is OperationFailed ||
|
|
entry.value is InstallCancelled,
|
|
)
|
|
.map((entry) => entry.key)
|
|
.toSet();
|
|
final remaining = Map.of(state.operations)
|
|
..removeWhere((appId, _) => completedIds.contains(appId));
|
|
for (final appId in completedIds) {
|
|
_operationSourceByAppId.remove(appId);
|
|
}
|
|
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,
|
|
InstallSource source = InstallSource.normal,
|
|
}) 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),
|
|
source: source,
|
|
);
|
|
|
|
// 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,
|
|
InstallSource source,
|
|
})
|
|
> 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),
|
|
source: item.source,
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
source: getOperationSource(appId),
|
|
);
|
|
|
|
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, source) = _parseTaskMetadata(metaData);
|
|
isCdnRetry = cdnRetry;
|
|
if (decodedAppId != null) {
|
|
_operationSourceByAppId.putIfAbsent(decodedAppId, () => source);
|
|
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, _, source) = _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, source);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to restore operations: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _restoreOperation(
|
|
String appId,
|
|
TaskRecord record,
|
|
DownloadTask task,
|
|
FileMetadata fileMetadata,
|
|
InstallSource source,
|
|
) 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,
|
|
),
|
|
source: source,
|
|
);
|
|
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,
|
|
),
|
|
source: source,
|
|
);
|
|
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,
|
|
InstallSource source = InstallSource.normal,
|
|
}) {
|
|
return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}|${source.name}';
|
|
}
|
|
|
|
(String? appId, String? metadataId, bool isCdnRetry, InstallSource source)
|
|
_parseTaskMetadata(
|
|
String metaData,
|
|
) {
|
|
final parts = metaData.split('|');
|
|
if (parts.length >= 2) {
|
|
final isCdnRetry = parts.length >= 3 && parts[2] == '1';
|
|
final source = InstallSource.fromMetadata(
|
|
parts.length >= 4 ? parts[3] : null,
|
|
);
|
|
return (parts[0], parts[1], isCdnRetry, source);
|
|
}
|
|
return (
|
|
metaData.isNotEmpty ? metaData : null,
|
|
null,
|
|
false,
|
|
InstallSource.normal,
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
});
|