From 661d134ef091bdbace5ad2ef12ec1080f555ea4a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 16 Dec 2025 15:32:09 -0300 Subject: [PATCH] Revamped download service --- lib/main.dart | 2 +- lib/screens/updates_screen.dart | 2 +- lib/services/background_update_service.dart | 100 +- lib/services/download/download_info.dart | 72 ++ .../download/download_persistence.dart | 166 +++ lib/services/download/download_service.dart | 427 +++++++ lib/services/download/installation_queue.dart | 241 ++++ lib/services/download_service.dart | 1013 ----------------- lib/widgets/app_card.dart | 2 +- lib/widgets/app_detail_widgets.dart | 2 +- lib/widgets/install_button.dart | 2 +- lib/widgets/install_button_state.dart | 2 +- 12 files changed, 1011 insertions(+), 1020 deletions(-) create mode 100644 lib/services/download/download_info.dart create mode 100644 lib/services/download/download_persistence.dart create mode 100644 lib/services/download/download_service.dart create mode 100644 lib/services/download/installation_queue.dart delete mode 100644 lib/services/download_service.dart diff --git a/lib/main.dart b/lib/main.dart index 2b09208..e4c6e8b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,7 +12,7 @@ import 'package:purplebase/purplebase.dart'; import 'package:amber_signer/amber_signer.dart'; import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/background_update_service.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/updates_service.dart'; diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 6239b2a..39828b5 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/bookmarks_service.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 1205197..31f32ab 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -1,5 +1,6 @@ -import 'dart:io' show Platform; +import 'dart:io' show Directory, Platform; +import 'package:background_downloader/background_downloader.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; @@ -15,15 +16,24 @@ import 'package:zapstore/utils/extensions.dart'; /// Unique task name for background update checking const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; +/// Unique task name for weekly cleanup +const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; + /// Unique task identifier const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; +/// Unique task identifier for weekly cleanup +const kWeeklyCleanupTaskId = 'weeklyCleanup'; + /// Notification channel for update notifications const kUpdateNotificationChannelId = 'zapstore_updates'; const kUpdateNotificationChannelName = 'App Updates'; const kUpdateNotificationChannelDescription = 'Notifications for available app updates'; +/// Stale download threshold for cleanup +const _staleDownloadThreshold = Duration(days: 7); + /// The entry point for WorkManager background tasks. /// This MUST be a top-level function (not a class method). @pragma('vm:entry-point') @@ -32,12 +42,86 @@ void callbackDispatcher() { switch (task) { case kBackgroundUpdateTaskName: return await _checkForUpdatesInBackground(); + case kWeeklyCleanupTaskName: + return await _performWeeklyCleanup(); default: return Future.value(false); } }); } +/// Perform weekly cleanup of stale downloads +Future _performWeeklyCleanup() async { + try { + final downloader = FileDownloader(); + + // Get all tracked records from background_downloader's database + final trackedRecords = await downloader.database.allRecords( + group: FileDownloader.defaultGroup, + ); + + for (final record in trackedRecords) { + final task = record.task; + if (task is! DownloadTask) continue; + + final taskAge = DateTime.now().difference(record.task.creationTime); + if (taskAge > _staleDownloadThreshold) { + try { + // Cancel if active + await downloader.cancelTaskWithId(task.taskId); + } catch (_) {} + + try { + // Delete the file if it exists + final filePath = await task.filePath(); + final file = Directory(filePath).parent.listSync().firstWhere( + (f) => f.path.endsWith(task.filename), + orElse: () => Directory(''), + ); + if (file.path.isNotEmpty) { + await file.delete(); + } + } catch (_) {} + + try { + // Remove from database + await downloader.database.deleteRecordWithId(task.taskId); + } catch (_) {} + + } + } + + // Also clean up orphaned APK files in download directory + try { + final cacheDir = await getApplicationCacheDirectory(); + final downloadDir = Directory( + path.join(cacheDir.path, 'flutter_background_downloader'), + ); + + if (await downloadDir.exists()) { + final files = downloadDir.listSync(); + final cutoff = DateTime.now().subtract(_staleDownloadThreshold); + + for (final file in files) { + if (file is! Directory) { + final stat = await file.stat(); + if (stat.modified.isBefore(cutoff)) { + await file.delete(); + } + } + } + } + } catch (_) { + // Ignore cleanup failures for orphaned files + } + + return true; + } catch (e) { + // Cleanup failed - return false for retry + return false; + } +} + /// Background update check logic - runs in a separate isolate Future _checkForUpdatesInBackground() async { try { @@ -226,6 +310,20 @@ class BackgroundUpdateService { backoffPolicy: BackoffPolicy.exponential, initialDelay: const Duration(minutes: 15), ); + + // Register weekly cleanup task + await Workmanager().registerPeriodicTask( + kWeeklyCleanupTaskId, + kWeeklyCleanupTaskName, + frequency: const Duration(days: 7), + constraints: Constraints( + requiresBatteryNotLow: true, + requiresCharging: true, // Only run when charging for cleanup + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + backoffPolicy: BackoffPolicy.exponential, + initialDelay: const Duration(hours: 24), // Start first cleanup after 24h + ); } /// Initialize local notifications plugin diff --git a/lib/services/download/download_info.dart b/lib/services/download/download_info.dart new file mode 100644 index 0000000..e52cc02 --- /dev/null +++ b/lib/services/download/download_info.dart @@ -0,0 +1,72 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:models/models.dart'; + +/// Stale download threshold - downloads older than this will be cleaned up +const staleDownloadThreshold = Duration(days: 7); + +/// Maximum concurrent downloads allowed +const maxConcurrentDownloads = 3; + +/// Wrapper around DownloadTask with app-specific metadata +class DownloadInfo { + const DownloadInfo({ + required this.appId, + required this.task, + required this.fileMetadata, + this.status = TaskStatus.enqueued, + this.progress = 0.0, + this.isInstalling = false, + this.isReadyToInstall = false, + this.skipVerificationOnInstall = false, + this.errorDetails, + }); + + final String appId; + final DownloadTask task; + final FileMetadata fileMetadata; + final TaskStatus status; + final double progress; + final bool isInstalling; + final bool isReadyToInstall; + final bool skipVerificationOnInstall; + final String? errorDetails; + + String get taskId => task.taskId; + String get fileName => task.filename; + + DownloadInfo copyWith({ + TaskStatus? status, + double? progress, + bool? isInstalling, + bool? isReadyToInstall, + bool? skipVerificationOnInstall, + String? errorDetails, + }) { + return DownloadInfo( + appId: appId, + task: task, + fileMetadata: fileMetadata, + status: status ?? this.status, + progress: progress ?? this.progress, + isInstalling: isInstalling ?? this.isInstalling, + isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall, + skipVerificationOnInstall: + skipVerificationOnInstall ?? this.skipVerificationOnInstall, + errorDetails: errorDetails ?? this.errorDetails, + ); + } +} + +/// Queued download waiting for a slot +class QueuedDownload { + final String appId; + final String appName; + final FileMetadata fileMetadata; + + const QueuedDownload({ + required this.appId, + required this.appName, + required this.fileMetadata, + }); +} + diff --git a/lib/services/download/download_persistence.dart b/lib/services/download/download_persistence.dart new file mode 100644 index 0000000..ec61c62 --- /dev/null +++ b/lib/services/download/download_persistence.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:models/models.dart'; + +import 'download_info.dart'; + +/// Handles persistence and restoration of download state from background_downloader's database +class DownloadPersistence { + DownloadPersistence(this._downloader, this._storage); + + final FileDownloader _downloader; + final StorageNotifier _storage; + + /// Restore download state from background_downloader's persisted database. + /// Returns a map of appId -> DownloadInfo for restored downloads. + Future> restoreState() async { + final restoredState = {}; + + try { + final trackedRecords = await _downloader.database.allRecords( + group: FileDownloader.defaultGroup, + ); + + for (final record in trackedRecords) { + final task = record.task; + if (task is! DownloadTask) continue; + + // Parse metadata JSON to get appId and metadataId + final metaDataJson = task.metaData; + if (metaDataJson.isEmpty) { + await cleanupTask(task); + continue; + } + + final (appId, metadataId) = _parseMetadata(metaDataJson); + if (appId == null) { + await cleanupTask(task); + continue; + } + + // Check if task is stale + final taskAge = DateTime.now().difference(record.task.creationTime); + if (taskAge > staleDownloadThreshold) { + await cleanupTask(task); + continue; + } + + // Try to load FileMetadata from purplebase + final fileMetadata = await _loadFileMetadata(metadataId, task.filename); + if (fileMetadata == null) { + await cleanupTask(task); + continue; + } + + // Check if file exists for completed tasks + if (record.status == TaskStatus.complete) { + if (!await _fileExists(task)) { + await _downloader.database.deleteRecordWithId(task.taskId); + continue; + } + } + + // Create DownloadInfo + restoredState[appId] = DownloadInfo( + appId: appId, + task: task, + fileMetadata: fileMetadata, + status: record.status, + progress: record.progress, + isReadyToInstall: record.status == TaskStatus.complete, + ); + } + } catch (e) { + debugPrint('Failed to restore download state: $e'); + } + + return restoredState; + } + + /// Parse metadata JSON, handling both legacy and new formats + (String? appId, String? metadataId) _parseMetadata(String metaDataJson) { + try { + final decoded = jsonDecode(metaDataJson) as Map; + return (decoded['appId'] as String?, decoded['metadataId'] as String?); + } catch (_) { + // Legacy format - metaData is just the appId + return (metaDataJson, null); + } + } + + /// Load FileMetadata from purplebase by ID or hash fallback + Future _loadFileMetadata( + String? metadataId, + String filename, + ) async { + // Try by metadataId first + if (metadataId != null) { + try { + final results = _storage.querySync( + RequestFilter(ids: {metadataId}).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + // Fallback: extract hash from filename + final hash = extractHashFromFilename(filename); + if (hash != null) { + try { + final results = _storage.querySync( + RequestFilter(search: hash).toRequest(), + ); + if (results.isNotEmpty) return results.first; + } catch (_) {} + } + + return null; + } + + /// Check if the downloaded file exists + Future _fileExists(DownloadTask task) async { + try { + final filePath = await task.filePath(); + return await File(filePath).exists(); + } catch (_) { + return false; + } + } + + /// Clean up a stale or invalid task + Future cleanupTask(DownloadTask task) async { + try { + await _downloader.cancelTaskWithId(task.taskId); + } catch (_) {} + + try { + final filePath = await task.filePath(); + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + + try { + await _downloader.database.deleteRecordWithId(task.taskId); + } catch (_) {} + } + + /// Extract hash from filename (format: {hash}.apk) + static String? extractHashFromFilename(String filename) { + final dotIndex = filename.lastIndexOf('.'); + if (dotIndex > 0) { + return filename.substring(0, dotIndex); + } + return filename.isNotEmpty ? filename : null; + } + + /// Encode metadata for storage in DownloadTask + static String encodeMetadata(String appId, String metadataId) { + return jsonEncode({'appId': appId, 'metadataId': metadataId}); + } +} + diff --git a/lib/services/download/download_service.dart b/lib/services/download/download_service.dart new file mode 100644 index 0000000..d5730ce --- /dev/null +++ b/lib/services/download/download_service.dart @@ -0,0 +1,427 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; + +import '../../utils/extensions.dart'; +import '../package_manager/package_manager.dart'; +import 'download_info.dart'; +import 'download_persistence.dart'; +import 'installation_queue.dart'; + +export 'download_info.dart'; + +/// Download service - orchestrates downloads and installations +class DownloadService extends StateNotifier> { + DownloadService(this.ref) : super({}) { + _installQueue = InstallationQueue(ref); + _initialize(); + } + + final Ref ref; + late final FileDownloader _downloader; + late final DownloadPersistence _persistence; + late final InstallationQueue _installQueue; + + // Download queue for overflow (>3 concurrent) + final List _downloadQueue = []; + + @override + void dispose() { + _downloader.unregisterCallbacks(); + super.dispose(); + } + + Future _initialize() async { + _downloader = FileDownloader(); + + // Configure downloader + 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 (_) {} + + // Configure notifications + _downloader.configureNotificationForGroup( + FileDownloader.defaultGroup, + running: const TaskNotification('Downloading {filename}', 'File: {filename}'), + complete: const TaskNotification('Download complete', 'File: {filename}'), + error: const TaskNotification('Download failed', 'File: {filename}'), + paused: const TaskNotification('Download paused', 'File: {filename}'), + progressBar: true, + ); + + // Set up callbacks + _downloader.registerCallbacks( + taskStatusCallback: _handleTaskUpdate, + taskProgressCallback: _handleTaskUpdate, + ); + + // Set up installation queue callbacks + _installQueue.setCallbacks( + updateState: (appId, updater) { + final current = state[appId]; + if (current != null) { + state = {...state, appId: updater(current)}; + } + }, + removeFromState: (appId) { + state = Map.from(state)..remove(appId); + }, + getState: (appId) => state[appId], + ); + + // Restore persisted downloads + _persistence = DownloadPersistence( + _downloader, + ref.read(storageNotifierProvider.notifier), + ); + await _restoreDownloads(); + } + + Future _restoreDownloads() async { + final restored = await _persistence.restoreState(); + state = restored; + + // Resume incomplete downloads + for (final entry in restored.entries) { + final info = entry.value; + if (_shouldResume(info.status)) { + try { + final isActive = await _downloader.taskForId(info.taskId) != null; + if (!isActive) { + await _downloader.resume(info.task); + } + } catch (e) { + state = { + ...state, + entry.key: info.copyWith( + status: TaskStatus.failed, + errorDetails: 'Could not resume download. Please retry.', + ), + }; + } + } + } + } + + bool _shouldResume(TaskStatus status) { + return status == TaskStatus.paused || + status == TaskStatus.running || + status == TaskStatus.enqueued || + status == TaskStatus.waitingToRetry; + } + + // ============ Public API ============ + + /// Set app foreground state (called from lifecycle observer) + Future setAppForeground(bool inForeground) async { + final stalledApps = state.entries + .where((e) => e.value.isInstalling) + .map((e) => e.key) + .toList(); + + await _installQueue.setAppForeground(inForeground); + + if (inForeground && stalledApps.isNotEmpty) { + await _installQueue.handleStalledApps(stalledApps); + } + } + + /// Start downloading an app + Future downloadApp(App app, Release release) async { + final fileMetadata = app.latestFileMetadata; + if (fileMetadata == null) { + throw Exception('No file metadata available for this release'); + } + + final appId = app.identifier; + + // Check if already downloading or queued + if (state.containsKey(appId)) { + final existing = state[appId]!; + if (existing.status == TaskStatus.running || + existing.status == TaskStatus.enqueued) { + return; + } + } + if (_downloadQueue.any((q) => q.appId == appId)) { + return; + } + + // Check concurrent limit + final activeCount = _countActiveDownloads(); + if (activeCount >= maxConcurrentDownloads) { + _downloadQueue.add(QueuedDownload( + appId: appId, + appName: app.name ?? appId, + fileMetadata: fileMetadata, + )); + return; + } + + await _startDownload(appId, app.name ?? appId, fileMetadata); + } + + /// Pause a download + Future pauseDownload(String appId) async { + final info = state[appId]; + if (info == null) return; + + try { + await _downloader.pause(info.task); + } catch (_) {} + } + + /// Resume a paused download + Future resumeDownload(String appId) async { + final info = state[appId]; + if (info == null) return; + + try { + await _downloader.resume(info.task); + } catch (_) {} + } + + /// Cancel a download + Future cancelDownload(String appId) async { + final info = state[appId]; + if (info == null) return; + + try { + await _downloader.cancel(info.task); + state = Map.from(state)..remove(appId); + + final filePath = await info.task.filePath(); + await File(filePath).delete().catchError((_) => File(filePath)); + } catch (_) {} + } + + /// Install from downloaded file + Future installFromDownloaded(String appId) async { + final info = state[appId]; + if (info == null || !info.isReadyToInstall) return; + + try { + final filePath = await info.task.filePath(); + if (!await File(filePath).exists()) { + state = Map.from(state)..remove(appId); + return; + } + + state = { + ...state, + appId: info.copyWith(isReadyToInstall: false, errorDetails: null), + }; + + _installQueue.enqueue(appId); + } catch (_) { + state = { + ...state, + appId: info.copyWith(isInstalling: false, isReadyToInstall: true), + }; + } + } + + /// Mark a download as ready to install (for reckless mode) + void markReadyToInstall(String appId, {bool skipVerificationOnInstall = false}) { + final info = state[appId]; + if (info == null) return; + + state = { + ...state, + appId: info.copyWith( + isInstalling: false, + isReadyToInstall: true, + skipVerificationOnInstall: skipVerificationOnInstall, + errorDetails: null, + ), + }; + } + + /// Clear error for retry + void clearError(String appId) { + final info = state[appId]; + if (info?.errorDetails != null) { + state = {...state, appId: info!.copyWith(errorDetails: null)}; + } + } + + /// Get download info (prefer using downloadInfoProvider) + DownloadInfo? getDownloadInfo(String appId) => state[appId]; + + /// Download with explicit metadata (used for alternative artifact selection) + Future downloadAppWithMetadata( + String appId, + String appName, + FileMetadata fileMetadata, + ) async { + // Check if already downloading or queued + if (state.containsKey(appId)) { + final existing = state[appId]!; + if (existing.status == TaskStatus.running || + existing.status == TaskStatus.enqueued) { + return; + } + } + if (_downloadQueue.any((q) => q.appId == appId)) { + return; + } + + // Check concurrent limit + final activeCount = _countActiveDownloads(); + if (activeCount >= maxConcurrentDownloads) { + _downloadQueue.add(QueuedDownload( + appId: appId, + appName: appName, + fileMetadata: fileMetadata, + )); + return; + } + + await _startDownload(appId, appName, fileMetadata); + } + + // ============ Internal ============ + + int _countActiveDownloads() { + return state.values.where((info) => + info.status == TaskStatus.running || + info.status == TaskStatus.enqueued || + info.status == TaskStatus.waitingToRetry).length; + } + + Future _startDownload( + String appId, + String appName, + FileMetadata fileMetadata, + ) async { + final packageManager = ref.read(packageManagerProvider.notifier); + final platform = packageManager.platform; + + String? downloadUrl; + if (fileMetadata.platforms.contains(platform) || + fileMetadata.platforms.isEmpty) { + downloadUrl = fileMetadata.urls.firstOrNull; + } + + if (downloadUrl == null || downloadUrl.isEmpty) return; + + final fileName = '${fileMetadata.hash}${packageManager.packageExtension}'; + final metaData = DownloadPersistence.encodeMetadata(appId, fileMetadata.id); + + final task = DownloadTask( + taskId: '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}', + url: downloadUrl, + filename: fileName, + updates: Updates.statusAndProgress, + requiresWiFi: false, + retries: 3, + allowPause: true, + metaData: metaData, + displayName: appName, + ); + + state = { + ...state, + appId: DownloadInfo( + appId: appId, + task: task, + fileMetadata: fileMetadata, + progress: 0.0, + ), + }; + + try { + final result = await _downloader.enqueue(task); + if (!result) { + state = Map.from(state)..remove(appId); + } + } catch (_) { + state = Map.from(state)..remove(appId); + } + } + + void _startNextQueuedDownload() { + if (_downloadQueue.isEmpty) return; + + if (_countActiveDownloads() < maxConcurrentDownloads) { + final next = _downloadQueue.removeAt(0); + _startDownload(next.appId, next.appName, next.fileMetadata); + } + } + + void _handleTaskUpdate(TaskUpdate update) { + String? appId; + for (final entry in state.entries) { + if (entry.value.taskId == update.task.taskId) { + appId = entry.key; + break; + } + } + if (appId == null) return; + + final current = state[appId]!; + + if (update is TaskStatusUpdate) { + _handleStatusUpdate(appId, current, update); + } else if (update is TaskProgressUpdate) { + _handleProgressUpdate(appId, current, update); + } + } + + void _handleStatusUpdate(String appId, DownloadInfo current, TaskStatusUpdate update) { + switch (update.status) { + case TaskStatus.failed: + String error = 'Download failed'; + if (update.exception != null) { + error = update.exception.toString(); + if (error.length > 200) error = '${error.substring(0, 197)}...'; + } + state = {...state, appId: current.copyWith(status: update.status, errorDetails: error)}; + _startNextQueuedDownload(); + + case TaskStatus.canceled: + state = {...state, appId: current.copyWith(status: update.status)}; + _startNextQueuedDownload(); + + case TaskStatus.complete: + state = {...state, appId: current.copyWith(status: update.status)}; + _installQueue.handleDownloadComplete(appId); + _startNextQueuedDownload(); + + default: + state = {...state, appId: current.copyWith(status: update.status)}; + } + } + + void _handleProgressUpdate(String appId, DownloadInfo current, TaskProgressUpdate update) { + double progress = current.progress; + if (update.progress.isFinite && !update.progress.isNaN && update.progress >= 0.0) { + progress = update.progress.clamp(0.0, 1.0); + } + state = {...state, appId: current.copyWith(progress: progress)}; + } +} + +/// Provider for the download service +final downloadServiceProvider = + StateNotifierProvider>( + DownloadService.new, + ); + +/// Provider for getting download info for a specific app +final downloadInfoProvider = Provider.family((ref, appId) { + return ref.watch(downloadServiceProvider)[appId]; +}); + diff --git a/lib/services/download/installation_queue.dart b/lib/services/download/installation_queue.dart new file mode 100644 index 0000000..137112a --- /dev/null +++ b/lib/services/download/installation_queue.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../package_manager/android_package_manager.dart'; +import '../package_manager/package_manager.dart'; +import '../secure_storage_service.dart'; +import 'download_info.dart'; + +/// Callback to update state in the main service +typedef StateUpdater = void Function( + String appId, + DownloadInfo Function(DownloadInfo) updater, +); + +/// Callback to remove from state +typedef StateRemover = void Function(String appId); + +/// Callback to get current state +typedef StateGetter = DownloadInfo? Function(String appId); + +/// Manages sequential installation of downloaded apps +class InstallationQueue { + InstallationQueue(this._ref); + + final Ref _ref; + + final List _queue = []; + final Set _pendingInstallations = {}; + bool _isProcessing = false; + bool _isAppInForeground = true; + + // Callbacks set by the service + late StateUpdater _updateState; + late StateRemover _removeFromState; + late StateGetter _getState; + + void setCallbacks({ + required StateUpdater updateState, + required StateRemover removeFromState, + required StateGetter getState, + }) { + _updateState = updateState; + _removeFromState = removeFromState; + _getState = getState; + } + + /// Set app foreground state + Future setAppForeground(bool inForeground) async { + final wasBackground = !_isAppInForeground; + _isAppInForeground = inForeground; + + final packageManager = _ref.read(packageManagerProvider.notifier); + if (packageManager is AndroidPackageManager) { + await packageManager.setAppForegroundState(inForeground); + } + + if (inForeground && wasBackground) { + await _handleReturnToForeground(); + } + } + + /// Handle download completion - add to installation queue + Future handleDownloadComplete(String appId) async { + final downloadInfo = _getState(appId); + if (downloadInfo == null) return; + + final packageManager = _ref.read(packageManagerProvider.notifier); + final canSilent = await packageManager.canInstallSilently(appId); + + // If backgrounded and not silent install, defer + if (!_isAppInForeground && !canSilent) { + _pendingInstallations.add(appId); + _updateState(appId, (info) => info.copyWith(isReadyToInstall: true)); + return; + } + + // Check if permission dialog needs to be shown (Android, non-silent, first time) + if (Platform.isAndroid && !canSilent) { + final secureStorage = _ref.read(secureStorageServiceProvider); + final hasSeenDialog = await secureStorage.hasSeenInstallPermissionDialog(); + if (!hasSeenDialog) { + _updateState(appId, (info) => info.copyWith(isReadyToInstall: true)); + return; + } + } + + // Add to queue and process + if (!_queue.contains(appId)) { + _queue.add(appId); + } + _processQueue(); + } + + /// Add to installation queue manually (e.g., from installFromDownloaded) + void enqueue(String appId) { + if (!_queue.contains(appId)) { + _queue.add(appId); + } + _processQueue(); + } + + /// Handle returning to foreground + Future _handleReturnToForeground() async { + // Process pending installations + if (_pendingInstallations.isNotEmpty) { + for (final appId in _pendingInstallations) { + if (!_queue.contains(appId)) { + _queue.add(appId); + } + } + _pendingInstallations.clear(); + _processQueue(); + } + } + + /// Process stalled apps (called by service with list of stalled appIds) + Future handleStalledApps(List stalledAppIds) async { + final packageManager = _ref.read(packageManagerProvider.notifier); + + for (final appId in stalledAppIds) { + if (packageManager is AndroidPackageManager) { + final retry = await packageManager.retryPendingInstall(appId); + + if (retry.hasPending && (retry.relaunched || retry.promptAlreadyShown)) { + continue; + } + + if (retry.hasPending && retry.sessionPending) { + _updateState(appId, (info) => info.copyWith( + isInstalling: false, + isReadyToInstall: true, + )); + continue; + } + } + + // No pending prompt - mark ready to install + _updateState(appId, (info) => info.copyWith( + isInstalling: false, + isReadyToInstall: true, + )); + } + + // Reset lock if there were stalled apps without prompts + final stalledWithoutPrompt = stalledAppIds.where((appId) { + final info = _getState(appId); + return info != null && !info.isInstalling && info.isReadyToInstall; + }).toList(); + + if (stalledWithoutPrompt.isNotEmpty) { + _isProcessing = false; + } + + _processQueue(); + } + + /// Process installation queue sequentially + Future _processQueue() async { + if (_isProcessing || _queue.isEmpty) return; + + _isProcessing = true; + + try { + while (_queue.isNotEmpty) { + final appId = _queue.removeAt(0); + final downloadInfo = _getState(appId); + + if (downloadInfo == null) continue; + + try { + final filePath = await downloadInfo.task.filePath(); + + _updateState(appId, (info) => info.copyWith( + isInstalling: true, + isReadyToInstall: false, + )); + + final packageManager = _ref.read(packageManagerProvider.notifier); + await packageManager.install( + appId, + filePath, + expectedHash: downloadInfo.fileMetadata.hash, + expectedSize: downloadInfo.fileMetadata.size ?? 0, + skipVerification: downloadInfo.skipVerificationOnInstall, + ); + + // Clean up file after successful install + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) {} + + _removeFromState(appId); + + if (_queue.isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 500)); + } + } catch (e) { + _handleInstallError(appId, downloadInfo, e.toString()); + } + } + } finally { + _isProcessing = false; + } + } + + void _handleInstallError(String appId, DownloadInfo downloadInfo, String errorMessage) { + // Already in progress - put back and stop + if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) { + _queue.insert(0, appId); + _updateState(appId, (info) => info.copyWith( + isInstalling: true, + isReadyToInstall: false, + )); + return; + } + + final isCertMismatch = + errorMessage.contains('signatures do not match') || + errorMessage.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || + errorMessage.contains('UPDATE_INCOMPATIBLE'); + + final wasCancelled = errorMessage.contains('cancelled'); + + if (wasCancelled) { + _removeFromState(appId); + } else { + _updateState(appId, (info) => info.copyWith( + isInstalling: false, + isReadyToInstall: true, + errorDetails: isCertMismatch + ? 'CERTIFICATE_MISMATCH' + : errorMessage.replaceFirst('Exception: ', ''), + )); + } + } +} + diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart deleted file mode 100644 index 172fd06..0000000 --- a/lib/services/download_service.dart +++ /dev/null @@ -1,1013 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; -import 'package:zapstore/services/secure_storage_service.dart'; -import 'package:zapstore/utils/extensions.dart'; - -import '../services/package_manager/android_package_manager.dart'; -import '../services/package_manager/package_manager.dart'; - -/// Helper class for queued downloads waiting for a slot -class _QueuedDownload { - final String appId; - final String appName; - final FileMetadata fileMetadata; - - _QueuedDownload({ - required this.appId, - required this.appName, - required this.fileMetadata, - }); -} - -/// Simplified Download Service leveraging background_downloader package -/// -/// This service acts as a thin wrapper around background_downloader, -/// providing integration with our app models and package manager. -/// -/// Key features: -/// - Platform-agnostic - works with any package manager implementation -/// - Uses FileMetadata.hash as filename for uniqueness and verification -/// - Automatic installation after download completion -/// - Leverages package's built-in retry, pause/resume, and progress tracking -/// - Minimal state management - relies on background_downloader's task tracking - -/// Minimal wrapper around Task with app-specific metadata -class DownloadInfo { - const DownloadInfo({ - required this.appId, - required this.task, - required this.fileMetadata, - this.status = TaskStatus.enqueued, - this.progress = 0.0, - this.isInstalling = false, - this.isReadyToInstall = false, - this.skipVerificationOnInstall = false, - this.errorDetails, - }); - - final String appId; - final DownloadTask task; - final FileMetadata fileMetadata; // Store the actual metadata being downloaded - final TaskStatus status; - final double progress; - final bool isInstalling; - final bool isReadyToInstall; // Downloaded but installation failed/canceled - final bool skipVerificationOnInstall; - final String? errorDetails; // Store error details for display - - String get taskId => task.taskId; - String get fileName => task.filename; - - String get formattedProgress { - final percent = (progress * 100).round(); - return switch (status) { - TaskStatus.enqueued => 'Queued', - TaskStatus.running => '$percent%', - TaskStatus.complete => 'Completed', - TaskStatus.failed => 'Failed', - TaskStatus.canceled => 'Canceled', - TaskStatus.paused => 'Paused', - TaskStatus.waitingToRetry => 'Retrying...', - TaskStatus.notFound => 'Not found', - }; - } - - DownloadInfo copyWith({ - TaskStatus? status, - double? progress, - bool? isInstalling, - bool? isReadyToInstall, - bool? skipVerificationOnInstall, - String? errorDetails, - }) { - return DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - status: status ?? this.status, - progress: progress ?? this.progress, - isInstalling: isInstalling ?? this.isInstalling, - isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall, - skipVerificationOnInstall: - skipVerificationOnInstall ?? this.skipVerificationOnInstall, - errorDetails: errorDetails ?? this.errorDetails, - ); - } -} - -/// Simplified download service -class DownloadService extends StateNotifier> { - DownloadService(this.ref) : super({}) { - _initialize(); - } - - final Ref ref; - late final FileDownloader _downloader; - - // Installation queue to prevent concurrent installations - final List _installQueue = []; - bool _isInstallingFromQueue = false; - - // Download queue for when 3+ concurrent downloads are requested - final List<_QueuedDownload> _downloadQueue = []; - - // Track pending installations (downloaded while app was backgrounded, non-silent only) - final Set _pendingInstallations = {}; - - // App lifecycle state - bool _isAppInForeground = true; - - /// Set app foreground state (called from lifecycle observer) - Future setAppForeground(bool inForeground) async { - final wasBackground = !_isAppInForeground; - _isAppInForeground = inForeground; - - // Notify native side of foreground state change - // This allows InstallResultReceiver to decide whether to launch dialogs immediately - final packageManager = ref.read(packageManagerProvider.notifier); - if (packageManager is AndroidPackageManager) { - await packageManager.setAppForegroundState(inForeground); - } - - // When returning to foreground, handle stalled installations and process pending - if (inForeground && wasBackground) { - await _handleReturnToForeground(); - } - } - - /// Handle returning to foreground - retry stalled installations and process pending - Future _handleReturnToForeground() async { - // Find any apps stuck in "installing" state (stalled while backgrounded) - final stalledApps = state.entries - .where((e) => e.value.isInstalling) - .map((e) => e.key) - .toList(); - - // Try to re-launch pending install prompts for stalled apps - // The Android side stores the confirmation intent when user action is needed - final packageManager = ref.read(packageManagerProvider.notifier); - for (final appId in stalledApps) { - // Try to re-launch the pending install prompt - // This re-uses the existing Android session instead of creating a new one - if (packageManager is AndroidPackageManager) { - final retry = await packageManager.retryPendingInstall(appId); - - // If there's a pending prompt/session, DO NOT reset the install state. - // - promptAlreadyShown: system dialog is/was already on screen (relaunching causes double prompts) - // - relaunched: we just re-launched a deferred prompt - if (retry.hasPending && - (retry.relaunched || retry.promptAlreadyShown)) { - continue; - } - - // If the system still has a session but we no longer have the confirm intent, the user must - // re-trigger install (we can't safely recover the intent). Mark ready-to-install. - if (retry.hasPending && retry.sessionPending) { - final downloadInfo = state[appId]; - if (downloadInfo != null) { - state = { - ...state, - appId: downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - ), - }; - } - continue; - } - } - - // No pending prompt found - mark as ready to install for manual retry - final downloadInfo = state[appId]; - if (downloadInfo != null) { - state = { - ...state, - appId: downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - ), - }; - } - } - - // If there were stalled apps without pending prompts, reset the lock - final stalledWithoutPrompt = stalledApps.where((appId) { - final info = state[appId]; - return info != null && !info.isInstalling && info.isReadyToInstall; - }).toList(); - - if (stalledWithoutPrompt.isNotEmpty) { - _isInstallingFromQueue = false; - } - - // Process pending installations (downloads that completed while backgrounded) - if (_pendingInstallations.isNotEmpty) { - await _processPendingInstallations(); - } - - // Process the install queue - _processInstallQueue(); - } - - /// Get count of pending installations - int get pendingInstallationCount => _pendingInstallations.length; - - /// Get count of queued downloads waiting for a slot - int get queuedDownloadCount => _downloadQueue.length; - - @override - void dispose() { - _downloader.unregisterCallbacks(); - super.dispose(); - } - - Future _initialize() async { - _downloader = FileDownloader(); - - // Configure downloader with optimized settings - try { - // Configure timeouts - the package uses iOS URL session timeout on iOS - // and OkHttp timeouts on Android - await _downloader.configure( - globalConfig: [ - ( - Config.requestTimeout, - const Duration(seconds: 20), - ), // 20 second timeout - ( - Config.resourceTimeout, - const Duration(minutes: 30), - ), // 30 minute total timeout - ( - Config.checkAvailableSpace, - Config.never, - ), // Don't check space as requested - ], - androidConfig: [ - (Config.useCacheDir, false), // Use external storage for APKs - ], - ); - - // Note: maxConcurrentTasks is controlled per-platform: - // - iOS: Set FDMaximumConcurrentTasks in Info.plist - // - Android: The library automatically manages concurrency - // We'll manually track and limit to 3 downloads in our service - } catch (e) { - // Continue with defaults if configuration fails - } - - // Configure notifications - _downloader.configureNotificationForGroup( - FileDownloader.defaultGroup, - running: const TaskNotification( - 'Downloading {filename}', - 'File: {filename}', - ), - complete: const TaskNotification('Download complete', 'File: {filename}'), - error: const TaskNotification('Download failed', 'File: {filename}'), - paused: const TaskNotification('Download paused', 'File: {filename}'), - progressBar: true, - ); - - // Set up global callbacks - _downloader.registerCallbacks( - taskStatusCallback: _handleTaskUpdate, - taskProgressCallback: _handleTaskUpdate, - ); - - // Clear any existing downloads on startup (no resume) - await _clearExistingDownloads(); - } - - /// Clear any existing downloads on startup (no resume) - Future _clearExistingDownloads() async { - try { - // Remove any tracked tasks and their files (including completed ones) - final trackedRecords = await _downloader.database.allRecords( - group: FileDownloader.defaultGroup, - ); - - for (final record in trackedRecords) { - final task = record.task; - if (task is DownloadTask) { - try { - final filePath = await task.filePath(); - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup failures - } - } - } - - // Cancel all existing tasks - final tasks = await _downloader.allTasks(); - - for (final task in tasks) { - if (task is DownloadTask && task.group == FileDownloader.defaultGroup) { - await _downloader.cancelTaskWithId(task.taskId); - - // Try to clean up the file - try { - final filePath = await task.filePath(); - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // File might not exist yet, ignore - } - } - } - - // Clear persisted task records (completed/failed and active) - await _downloader.database.deleteAllRecords( - group: FileDownloader.defaultGroup, - ); - - // Reset downloader state - await _downloader.reset(); - } catch (e) { - // Failed to clear existing downloads - } - } - - /// Start downloading an app with specific file metadata - Future downloadAppWithMetadata( - String appId, - String appName, - FileMetadata fileMetadata, - ) async { - // Get download URL for current platform - final packageManager = ref.read(packageManagerProvider.notifier); - final platform = packageManager.platform; - - String? downloadUrl; - if (fileMetadata.platforms.contains(platform) || - fileMetadata.platforms.isEmpty) { - downloadUrl = fileMetadata.urls.firstOrNull; - } - - if (downloadUrl == null || downloadUrl.isEmpty) { - throw Exception( - 'No download URL available for platform: $platform. ' - 'Available platforms: ${fileMetadata.platforms.join(', ')}', - ); - } - - // Check if already downloading - if (state.containsKey(appId)) { - final existing = state[appId]!; - if (existing.status == TaskStatus.running || - existing.status == TaskStatus.enqueued) { - return; - } - } - - // Use FileMetadata hash as filename with platform-specific extension - final packageExtension = packageManager.packageExtension; - final fileName = '${fileMetadata.hash}$packageExtension'; - - // Create download task - final taskId = - '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey().toString()}'; - final task = DownloadTask( - taskId: taskId, - url: downloadUrl, - filename: fileName, - updates: Updates.statusAndProgress, - requiresWiFi: false, - retries: 3, - allowPause: true, - metaData: appId, - displayName: appName, - ); - - // Update state - state = { - ...state, - appId: DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - progress: 0.0, - ), - }; - - try { - final result = await _downloader.enqueue(task); - if (!result) { - throw Exception('Failed to start download'); - } - } catch (e) { - state = Map.from(state)..remove(appId); - rethrow; - } - } - - /// Start downloading an app - Future downloadApp(App app, Release release) async { - final fileMetadata = app.latestFileMetadata; - if (fileMetadata == null) { - throw Exception('No file metadata available for this release'); - } - - // Get download URL for current platform - final packageManager = ref.read(packageManagerProvider.notifier); - final platform = packageManager.platform; - - String? downloadUrl; - if (fileMetadata.platforms.contains(platform) || - fileMetadata.platforms.isEmpty) { - downloadUrl = fileMetadata.urls.firstOrNull; - } - - if (downloadUrl == null || downloadUrl.isEmpty) { - throw Exception( - 'No download URL available for platform: $platform. ' - 'Available platforms: ${fileMetadata.platforms.join(', ')}', - ); - } - - // Check if already downloading or queued - if (state.containsKey(app.identifier)) { - final existing = state[app.identifier]!; - if (existing.status == TaskStatus.running || - existing.status == TaskStatus.enqueued) { - return; // Already downloading - } - } - - // Check if already in download queue - if (_downloadQueue.any((q) => q.appId == app.identifier)) { - return; // Already queued - } - - // Count active downloads - final activeDownloads = state.values - .where( - (info) => - info.status == TaskStatus.running || - info.status == TaskStatus.enqueued || - info.status == TaskStatus.waitingToRetry, - ) - .length; - - // If at max concurrent, queue instead of throwing - if (activeDownloads >= 3) { - _downloadQueue.add( - _QueuedDownload( - appId: app.identifier, - appName: app.name ?? app.identifier, - fileMetadata: fileMetadata, - ), - ); - return; // Will be started when a slot frees up - } - - // Start the download - await _startDownloadInternal( - app.identifier, - app.name ?? app.identifier, - fileMetadata, - ); - } - - /// Internal method to start a download (doesn't check limits) - Future _startDownloadInternal( - String appId, - String appName, - FileMetadata fileMetadata, - ) async { - final packageManager = ref.read(packageManagerProvider.notifier); - final platform = packageManager.platform; - - String? downloadUrl; - if (fileMetadata.platforms.contains(platform) || - fileMetadata.platforms.isEmpty) { - downloadUrl = fileMetadata.urls.firstOrNull; - } - - if (downloadUrl == null || downloadUrl.isEmpty) { - return; // Skip if no URL available - } - - // Use FileMetadata hash as filename with platform-specific extension - final packageExtension = packageManager.packageExtension; - final fileName = '${fileMetadata.hash}$packageExtension'; - - // Create download task with built-in retry - final taskId = - '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey().toString()}'; - final task = DownloadTask( - taskId: taskId, - url: downloadUrl, - filename: fileName, - updates: Updates.statusAndProgress, - requiresWiFi: false, - retries: 3, - allowPause: true, - metaData: appId, - displayName: appName, - ); - - // Update state with initial progress of 0.0 - state = { - ...state, - appId: DownloadInfo( - appId: appId, - task: task, - fileMetadata: fileMetadata, - progress: 0.0, - ), - }; - - try { - final result = await _downloader.enqueue(task); - if (!result) { - state = Map.from(state)..remove(appId); - } - } catch (e) { - state = Map.from(state)..remove(appId); - } - } - - /// Start next download from queue when a slot frees up - void _startNextQueuedDownload() { - if (_downloadQueue.isEmpty) return; - - final activeDownloads = state.values - .where( - (info) => - info.status == TaskStatus.running || - info.status == TaskStatus.enqueued || - info.status == TaskStatus.waitingToRetry, - ) - .length; - - if (activeDownloads < 3) { - final next = _downloadQueue.removeAt(0); - _startDownloadInternal(next.appId, next.appName, next.fileMetadata); - } - } - - /// Handle task updates (both status and progress) - void _handleTaskUpdate(TaskUpdate update) { - // Find the app ID for this task - String? appId; - for (final entry in state.entries) { - if (entry.value.taskId == update.task.taskId) { - appId = entry.key; - break; - } - } - - if (appId == null) { - return; - } - - final currentInfo = state[appId]!; - - // Update based on update type - if (update is TaskStatusUpdate) { - // Handle failure with detailed error - if (update.status == TaskStatus.failed) { - String errorDetails = 'Download failed'; - - if (update.exception != null) { - // Extract meaningful error message - final exception = update.exception!; - if (exception.runtimeType.toString().contains('TaskException')) { - // TaskException contains httpResponseCode and description - errorDetails = exception.toString(); - } else { - errorDetails = exception.toString(); - } - - // Trim long stack traces - if (errorDetails.length > 200) { - errorDetails = '${errorDetails.substring(0, 197)}...'; - } - } - - state = { - ...state, - appId: currentInfo.copyWith( - status: update.status, - errorDetails: errorDetails, - ), - }; - - // Start next queued download on failure - _startNextQueuedDownload(); - } else if (update.status == TaskStatus.paused) { - state = {...state, appId: currentInfo.copyWith(status: update.status)}; - } else if (update.status == TaskStatus.canceled) { - state = {...state, appId: currentInfo.copyWith(status: update.status)}; - - // Start next queued download on cancel - _startNextQueuedDownload(); - } else { - state = {...state, appId: currentInfo.copyWith(status: update.status)}; - - // Handle completion - if (update.status == TaskStatus.complete) { - _handleDownloadComplete(appId); - - // Start next queued download on complete - _startNextQueuedDownload(); - } - } - } else if (update is TaskProgressUpdate) { - // Background_downloader sends bogus -5.0 progress when pausing - // We need to preserve the last valid progress instead of using 0.0 - double safeProgress = currentInfo.progress; - - if (update.progress.isFinite && - !update.progress.isNaN && - update.progress >= 0.0) { - safeProgress = update.progress.clamp(0.0, 1.0); - } - // Otherwise keep the previous valid progress - - state = {...state, appId: currentInfo.copyWith(progress: safeProgress)}; - } - } - - /// Handle download completion - add to installation queue - Future _handleDownloadComplete(String appId) async { - final downloadInfo = state[appId]; - // ignore: unnecessary_null_comparison - if (downloadInfo == null) return; // Early exit if already removed - - // Check if this app can be installed silently - final packageManager = ref.read(packageManagerProvider.notifier); - final canSilent = await packageManager.canInstallSilently(appId); - - // If app is backgrounded AND this is NOT a silent install, defer to pending - if (!_isAppInForeground && !canSilent) { - _pendingInstallations.add(appId); - // Update state to show ready-to-install - state = {...state, appId: downloadInfo.copyWith(isReadyToInstall: true)}; - return; - } - - // Check if permission explainer dialog needs to be shown (Android only, non-silent, first time) - if (Platform.isAndroid && !canSilent) { - final secureStorage = ref.read(secureStorageServiceProvider); - final hasSeenDialog = await secureStorage.hasSeenInstallPermissionDialog(); - if (!hasSeenDialog) { - // Mark as ready-to-install so user interaction shows the dialog first - state = {...state, appId: downloadInfo.copyWith(isReadyToInstall: true)}; - return; - } - } - - // App is in foreground OR this is a silent install - proceed normally - if (!_installQueue.contains(appId)) { - _installQueue.add(appId); - } - - // Process queue if not already processing - _processInstallQueue(); - } - - /// Process installations that were pending while app was backgrounded - Future _processPendingInstallations() async { - if (_pendingInstallations.isEmpty) return; - - // Move all pending to install queue (keep isReadyToInstall: true until actually installing) - for (final appId in _pendingInstallations) { - if (!_installQueue.contains(appId)) { - _installQueue.add(appId); - } - } - _pendingInstallations.clear(); - - // Process the queue (will show system dialogs one after another) - _processInstallQueue(); - } - - /// Process installation queue sequentially to prevent concurrent installations. - Future _processInstallQueue() async { - // Don't start processing if already processing - if (_isInstallingFromQueue) return; - - // Don't process if queue is empty - if (_installQueue.isEmpty) return; - - _isInstallingFromQueue = true; - - try { - while (_installQueue.isNotEmpty) { - // Remove from queue FIRST to prevent race conditions - final appId = _installQueue.removeAt(0); - final downloadInfo = state[appId]; - - // Skip if download info no longer exists - if (downloadInfo == null) { - continue; - } - - try { - // Get the downloaded file path - final filePath = await downloadInfo.task.filePath(); - - // Update state to show installing (and clear ready-to-install) - state = { - ...state, - appId: downloadInfo.copyWith( - isInstalling: true, - isReadyToInstall: false, - ), - }; - - // Use the stored file metadata from the download info - final fileMetadata = downloadInfo.fileMetadata; - - final packageManager = ref.read(packageManagerProvider.notifier); - await packageManager.install( - appId, - filePath, - expectedHash: fileMetadata.hash, - expectedSize: fileMetadata.size ?? 0, - skipVerification: downloadInfo.skipVerificationOnInstall, - ); - - // Installation succeeded - clean up downloaded file - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (_) { - // Continue even if deletion fails - } - - // Remove from state (installation complete) - state = Map.from(state)..remove(appId); - - // Add a small delay between installations - if (_installQueue.isNotEmpty) { - await Future.delayed(const Duration(milliseconds: 500)); - } - } catch (e) { - // Installation failed for this app - continue with others - final errorMessage = e.toString(); - - // Another install session already exists (typically from a previous UI path). - // Do NOT advance the queue or delete the APK: keep state as installing and stop. - if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) { - // Put it back so we can retry when the existing session resolves. - _installQueue.insert(0, appId); - state = { - ...state, - appId: downloadInfo.copyWith( - isInstalling: true, - isReadyToInstall: false, - ), - }; - break; - } - - // Detect certificate mismatch - final isCertificateMismatch = - errorMessage.contains('signatures do not match') || - errorMessage.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || - errorMessage.contains('UPDATE_INCOMPATIBLE'); - - // Detect user cancellation - final wasCancelled = errorMessage.contains('cancelled'); - - if (wasCancelled) { - // User cancelled - just remove from state - state = Map.from(state)..remove(appId); - } else { - // Keep download info but mark as ready to install (can retry) - state = Map.from(state) - ..[appId] = downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - errorDetails: isCertificateMismatch - ? 'CERTIFICATE_MISMATCH' - : errorMessage.replaceFirst('Exception: ', ''), - ); - } - - // Continue processing other apps in queue - continue; - } - } - } finally { - // Always reset the lock, even if an unexpected error occurs - _isInstallingFromQueue = false; - } - } - - /// Pause a download - /// - /// The background_downloader package handles pause state persistence, - /// so downloads can be resumed even after app restart. - Future pauseDownload(String appId) async { - final downloadInfo = state[appId]; - if (downloadInfo == null) { - return; - } - - try { - await _downloader.pause(downloadInfo.task); - // State update happens automatically via task callbacks - } catch (e) { - // Exception while pausing download - } - } - - /// Resume a paused download - /// - /// The background_downloader package automatically handles resumption - /// from the exact byte position where it was paused. - Future resumeDownload(String appId) async { - final downloadInfo = state[appId]; - if (downloadInfo == null) { - return; - } - - try { - await _downloader.resume(downloadInfo.task); - // State update happens automatically via task callbacks - } catch (e) { - // Exception while resuming download - } - } - - /// Cancel a download - Future cancelDownload(String appId) async { - final downloadInfo = state[appId]; - if (downloadInfo == null) return; - - try { - await _downloader.cancel(downloadInfo.task); - - // Remove from state - state = Map.from(state)..remove(appId); - - // Clean up any partial file - final filePath = await downloadInfo.task.filePath(); - // ignore: unnecessary_null_comparison - if (filePath != null) { - // File might not exist yet - await File(filePath).delete().catchError((_) => File(filePath)); - } - } catch (e) { - // Failed to cancel download - } - } - - /// Retry a failed download - Future retryDownload(String appId) async { - final downloadInfo = state[appId]; - if (downloadInfo == null) return; - - try { - // The package handles retry automatically with the retries parameter - // For manual retry, we need to create a new task - await _downloader.cancel(downloadInfo.task); - state = Map.from(state)..remove(appId); - - // Re-download using the same logic - // This requires us to have access to the app and release - // For now, just remove it and let user re-initiate - } catch (e) { - // Failed to retry download - } - } - - /// Get download info for an app - DownloadInfo? getDownloadInfo(String appId) => state[appId]; - - /// Check if app is currently downloading - bool isDownloading(String appId) { - final info = state[appId]; - return info != null && - (info.status == TaskStatus.running || - info.status == TaskStatus.enqueued); - } - - /// Clear any error and prepare for retry - void clearError(String appId) { - final downloadInfo = state[appId]; - if (downloadInfo != null && downloadInfo.errorDetails != null) { - state = { - ...state, - appId: downloadInfo.copyWith( - errorDetails: null, - // Keep isReadyToInstall: true so installFromDownloaded() can proceed - ), - }; - } - } - - /// Explicitly mark a downloaded file as ready to install. - /// - /// Used by flows where the user explicitly chooses to proceed (e.g. "reckless" override) - /// and we want to drive installation through the single install queue. - void markReadyToInstall( - String appId, { - bool skipVerificationOnInstall = false, - }) { - final downloadInfo = state[appId]; - if (downloadInfo != null) { - state = { - ...state, - appId: downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - skipVerificationOnInstall: skipVerificationOnInstall, - errorDetails: null, // clear previous error to avoid confusing UI - ), - }; - } - } - - /// Install from existing downloaded file - Future installFromDownloaded(String appId) async { - final downloadInfo = state[appId]; - if (downloadInfo == null || !downloadInfo.isReadyToInstall) return; - - try { - final filePath = await downloadInfo.task.filePath(); - - // Check if file still exists - if (!await File(filePath).exists()) { - // File was deleted, remove from state - state = Map.from(state)..remove(appId); - return; - } - - // Update state to mark as no longer ready to install (queued for install) - state = { - ...state, - appId: downloadInfo.copyWith( - isReadyToInstall: false, - errorDetails: null, - ), - }; - - // Add to installation queue instead of installing directly - if (!_installQueue.contains(appId)) { - _installQueue.add(appId); - } - - // Process queue if not already processing - _processInstallQueue(); - } catch (e) { - // Reset to ready-to-install state on error - state = Map.from(state) - ..[appId] = downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - ); - } - } - - /// Clean up all downloads - Future cleanupDownloads() async { - try { - // Cancel all active downloads - for (final downloadInfo in state.values) { - await _downloader.cancel(downloadInfo.task); - } - - // Clear state - state = {}; - - // Clean up download directory - // background_downloader manages its own directory internally - // We rely on task.filePath() to get file locations - } catch (e) { - // Failed to cleanup downloads - } - } -} - -/// Provider for the download service -final downloadServiceProvider = - StateNotifierProvider>( - DownloadService.new, - ); - -/// Provider for getting download info for a specific app -final downloadInfoProvider = Provider.family(( - ref, - appId, -) { - final downloads = ref.watch(downloadServiceProvider); - return downloads[appId]; -}); diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index 4bcc2e2..e95a8a9 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -9,7 +9,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:background_downloader/background_downloader.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/url_utils.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/widgets/zap_widgets.dart'; import 'author_container.dart'; diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 1108cc5..a5e4a18 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -8,7 +8,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/services/bookmarks_service.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 03f1036..3bcfe30 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -6,7 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/services/trust_service.dart'; diff --git a/lib/widgets/install_button_state.dart b/lib/widgets/install_button_state.dart index f80bc27..4184c70 100644 --- a/lib/widgets/install_button_state.dart +++ b/lib/widgets/install_button_state.dart @@ -1,6 +1,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/download_service.dart'; +import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart';