Files
zapstore/lib/services/package_manager/install_operation.dart
T
Henrique Velloso 84906164bb feat: auto-backup installed apps on install/uninstall
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.
2026-02-26 18:27:41 -03:00

321 lines
13 KiB
Dart

import 'package:models/models.dart';
/// Source that initiated an install/download operation.
enum InstallSource {
normal,
restore;
static InstallSource fromMetadata(String? value) {
return switch (value) {
'restore' => InstallSource.restore,
_ => InstallSource.normal,
};
}
}
/// Stale download threshold - operations older than this will be cleaned up
const staleOperationThreshold = Duration(days: 7);
/// Delay between queueing operations to prevent UI flood
const batchQueueDelayMs = 50;
/// Dart-side watchdog timeout (fallback if native events stop arriving).
/// For downloads, this is measured from the last progress update (activity-based).
/// For other phases, this is measured from when the phase started.
const watchdogTimeout = Duration(minutes: 2);
/// How often the watchdog timer checks for stale operations
const watchdogCheckInterval = Duration(seconds: 30);
// ═══════════════════════════════════════════════════════════════════════════════
// INSTALL OPERATION STATE MACHINE
// ═══════════════════════════════════════════════════════════════════════════════
/// Represents an active install operation for an app.
/// When there's no operation, the app simply has no entry in the operations map.
sealed class InstallOperation {
/// The target file metadata being installed (works for both FileMetadata and SoftwareAsset)
final FileMetadata target;
const InstallOperation({required this.target});
}
// ═══════════════════════════════════════════════════════════════════════════════
// DOWNLOAD PHASE (Cancel allowed)
// ═══════════════════════════════════════════════════════════════════════════════
/// Waiting for download slot (max concurrent reached)
class DownloadQueued extends InstallOperation {
final String? displayName;
const DownloadQueued({required super.target, this.displayName});
}
/// Actively downloading
class Downloading extends InstallOperation {
final double progress;
final String taskId;
final DateTime startedAt;
/// Last time progress was received. The watchdog uses this (not [startedAt])
/// so that slow-but-active downloads are not killed prematurely.
final DateTime lastProgressAt;
Downloading({
required super.target,
required this.progress,
required this.taskId,
DateTime? startedAt,
DateTime? lastProgressAt,
}) : startedAt = startedAt ?? DateTime.now(),
lastProgressAt = lastProgressAt ?? startedAt ?? DateTime.now();
Downloading copyWith({double? progress}) {
return Downloading(
target: target,
progress: progress ?? this.progress,
taskId: taskId,
startedAt: startedAt,
// Reset activity timestamp when progress changes
lastProgressAt: progress != null ? DateTime.now() : lastProgressAt,
);
}
}
/// Download paused by user
class DownloadPaused extends InstallOperation {
final double progress;
final String taskId;
const DownloadPaused({
required super.target,
required this.progress,
required this.taskId,
});
}
// ═══════════════════════════════════════════════════════════════════════════════
// VERIFICATION PHASE (Kotlin is verifying hash)
// ═══════════════════════════════════════════════════════════════════════════════
/// Verifying downloaded file hash (happens in Kotlin, visible to UI)
class Verifying extends InstallOperation {
final String filePath;
final double progress;
final DateTime startedAt;
Verifying({
required super.target,
required this.filePath,
this.progress = 0.0,
DateTime? startedAt,
}) : startedAt = startedAt ?? DateTime.now();
Verifying copyWith({double? progress}) {
return Verifying(
target: target,
filePath: filePath,
progress: progress ?? this.progress,
startedAt: startedAt,
);
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// PERMISSION PHASE (Explicit for UX feedback)
// ═══════════════════════════════════════════════════════════════════════════════
/// Waiting for user to grant install permission
class AwaitingPermission extends InstallOperation {
final String filePath;
const AwaitingPermission({required super.target, required this.filePath});
}
// ═══════════════════════════════════════════════════════════════════════════════
// INSTALL PHASE (No cancel - Android controls)
// ═══════════════════════════════════════════════════════════════════════════════
/// File verified, waiting for install to be triggered.
/// [triggeredAt] is set when [triggerInstall] is actually called, so the
/// watchdog can detect native sessions that never respond.
class ReadyToInstall extends InstallOperation {
final String filePath;
final DateTime? triggeredAt;
const ReadyToInstall({
required super.target,
required this.filePath,
this.triggeredAt,
});
}
/// Native installation in progress
class Installing extends InstallOperation {
final String filePath;
final bool isSilent;
final DateTime startedAt;
Installing({
required super.target,
required this.filePath,
this.isSilent = false,
DateTime? startedAt,
}) : startedAt = startedAt ?? DateTime.now();
}
/// User cancelled the install dialog - file is still ready, no re-download needed.
/// User can tap "Install (retry)" to show the dialog again.
class InstallCancelled extends InstallOperation {
final String filePath;
const InstallCancelled({required super.target, required this.filePath});
}
/// Uninstalling app (for force update: uninstall → install)
class Uninstalling extends InstallOperation {
final String filePath;
const Uninstalling({required super.target, required this.filePath});
}
/// System is processing the install - cannot be cancelled.
/// This state is entered when the install session has been committed to Android
/// and is taking longer than expected. The system will eventually complete or fail.
class SystemProcessing extends InstallOperation {
final String filePath;
final DateTime startedAt;
SystemProcessing({
required super.target,
required this.filePath,
DateTime? startedAt,
}) : startedAt = startedAt ?? DateTime.now();
}
// ═══════════════════════════════════════════════════════════════════════════════
// TERMINAL STATES
// ═══════════════════════════════════════════════════════════════════════════════
/// Operation completed successfully.
/// Stays in operations map until batch completes (for progress tracking).
class Completed extends InstallOperation {
final DateTime completedAt;
/// Whether this was an update (app was already installed) or a new install.
final bool isUpdate;
Completed({required super.target, DateTime? completedAt, this.isUpdate = false})
: completedAt = completedAt ?? DateTime.now();
}
// ═══════════════════════════════════════════════════════════════════════════════
// FAILURE STATE (Dismiss available)
// ═══════════════════════════════════════════════════════════════════════════════
/// Operation failed - may be retryable depending on type
class OperationFailed extends InstallOperation {
final FailureType type;
final String message;
final String? description;
final String? filePath;
const OperationFailed({
required super.target,
required this.type,
required this.message,
this.description,
this.filePath,
});
/// Whether this requires force update (uninstall + install)
bool get needsForceUpdate => type == FailureType.certMismatch;
}
/// Types of failures that can occur during install operations
enum FailureType {
/// Network error, timeout, server error during download
downloadFailed,
/// Hash doesn't match expected - can retry with reckless mode
hashMismatch,
/// File is corrupted or not a valid APK
invalidFile,
/// Generic installation error
installFailed,
/// Certificate/signature mismatch - needs force update (uninstall + install)
certMismatch,
/// User doesn't have install permission
permissionDenied,
/// Not enough storage space
insufficientStorage,
/// Device is incompatible with the app (wrong architecture, API level, etc.)
incompatible,
}
// ═══════════════════════════════════════════════════════════════════════════════
// HELPER EXTENSIONS
// ═══════════════════════════════════════════════════════════════════════════════
extension InstallOperationX on InstallOperation {
/// Whether this operation is in a download state (cancel allowed)
bool get isDownloading =>
this is DownloadQueued || this is Downloading || this is DownloadPaused;
/// Whether this operation is actively processing (not waiting for user)
bool get isActive =>
this is Downloading ||
this is Verifying ||
this is Installing ||
this is SystemProcessing ||
this is Uninstalling;
/// Whether this operation needs watchdog monitoring (waiting for native events)
bool get needsWatchdog =>
this is Downloading ||
this is Verifying ||
this is Installing ||
this is SystemProcessing ||
(this is ReadyToInstall && (this as ReadyToInstall).triggeredAt != null);
/// Whether this operation is in the verification phase
bool get isVerifying => this is Verifying;
/// Whether this operation is in a terminal state (completed or failed)
bool get isTerminal => this is Completed || this is OperationFailed;
/// Whether this operation is still in progress (not terminal)
bool get isInProgress => !isTerminal;
/// Get the relevant timestamp for watchdog monitoring.
/// For downloads: last progress update (activity-based, so slow downloads survive).
/// For other phases: when the phase started.
DateTime? get watchdogTimestamp => switch (this) {
Downloading(:final lastProgressAt) => lastProgressAt,
Verifying(:final startedAt) => startedAt,
Installing(:final startedAt) => startedAt,
SystemProcessing(:final startedAt) => startedAt,
ReadyToInstall(:final triggeredAt) => triggeredAt,
_ => null,
};
/// Get file path if available
String? get filePath => switch (this) {
Verifying(:final filePath) => filePath,
AwaitingPermission(:final filePath) => filePath,
ReadyToInstall(:final filePath) => filePath,
Installing(:final filePath) => filePath,
SystemProcessing(:final filePath) => filePath,
InstallCancelled(:final filePath) => filePath,
Uninstalling(:final filePath) => filePath,
OperationFailed(:final filePath) => filePath,
_ => null,
};
}