Revamped package manager

This commit is contained in:
franzap
2025-12-25 19:49:14 -03:00
parent f3bae46769
commit 77316a5984
26 changed files with 3447 additions and 3135 deletions
+1
View File
@@ -98,4 +98,5 @@ flutter {
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
} }
@@ -9,7 +9,7 @@ class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Register the unified package manager plugin // Register plugins
flutterEngine.plugins.add(AndroidPackageManagerPlugin()) flutterEngine.plugins.add(AndroidPackageManagerPlugin())
flutterEngine.plugins.add(AppRestartPlugin()) flutterEngine.plugins.add(AppRestartPlugin())
} }
File diff suppressed because it is too large Load Diff
@@ -1,30 +1,22 @@
package dev.zapstore.alpha.plugins package dev.zapstore.alpha.plugins
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
private const val TAG = "InstallResultReceiver" private const val TAG = "InstallResultReceiver"
/** /**
* BroadcastReceiver to handle APK installation results. * BroadcastReceiver for APK installation results.
* *
* This receiver completes the pending method channel Result stored in * Translates Android PackageInstaller status codes to simple status events
* AndroidPackageManagerPlugin, allowing Dart's await to finish. * that are emitted to Dart via EventChannel.
* *
* For STATUS_PENDING_USER_ACTION (installer takeover prompt): * This receiver is stateless - it simply maps broadcasts to events.
* - If app is in foreground: launch confirmation dialog immediately * All state management happens in AndroidPackageManagerPlugin.
* - If app is backgrounded: store intent for later launch via retryPendingInstall
*
* We DON'T complete the Result for pending user action - we wait for the
* actual success/failure that follows after user confirms/cancels.
*/ */
class InstallResultReceiver : BroadcastReceiver() { class InstallResultReceiver : BroadcastReceiver() {
@@ -32,174 +24,154 @@ class InstallResultReceiver : BroadcastReceiver() {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
?: intent.getStringExtra("packageName") ?: intent.getStringExtra("packageName")
?: "unknown" ?: ""
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
val isUpdate = intent.getBooleanExtra("isUpdate", false) val isUpdate = intent.getBooleanExtra("isUpdate", false)
Log.d(TAG, "Install result: status=$status, package=$packageName, sessionId=$sessionId, message=$message") Log.d(TAG, "Received: status=$status, package=$packageName, session=$sessionId, msg=$message")
when (status) { when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> { PackageInstaller.STATUS_PENDING_USER_ACTION -> {
Log.d(TAG, "User confirmation required for $packageName (installer takeover)") // User confirmation required - extract the dialog intent
val confirmIntent = extractConfirmIntent(intent)
// Extract confirmation dialog intent AndroidPackageManagerPlugin.onInstallResult(
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { sessionId = sessionId,
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) status = InstallStatus.PENDING_USER_ACTION,
} else { packageName = packageName,
@Suppress("DEPRECATION") isUpdate = isUpdate,
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT) confirmIntent = confirmIntent
} )
if (confirmIntent != null) {
// Always store the intent for potential re-launch from retryPendingInstall
AndroidPackageManagerPlugin.storePendingUserActionIntent(packageName, confirmIntent)
// Only launch dialog if app is in foreground
if (AndroidPackageManagerPlugin.isAppInForeground()) {
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
try {
context.startActivity(confirmIntent)
// Prompt was shown while in foreground: do NOT re-launch on resume.
AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, false)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch confirmation dialog", e)
// Launch failed -> treat as deferred so it can be re-launched later.
AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true)
showUserActionNotification(context, sessionId, confirmIntent, packageName)
}
} else {
Log.d(TAG, "App backgrounded, deferring user action for $packageName")
AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true)
}
}
// DON'T complete the Result here - wait for the actual success/failure
// that will come after user confirms or cancels the dialog.
// The same sessionId will be used for the final result.
} }
PackageInstaller.STATUS_SUCCESS -> { PackageInstaller.STATUS_SUCCESS -> {
Log.d(TAG, "Installation successful: $packageName (sessionId=$sessionId)") Log.d(TAG, "Installation successful: $packageName")
// Complete the pending method channel result AndroidPackageManagerPlugin.onInstallResult(
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( sessionId = sessionId,
"isSuccess" to true, status = InstallStatus.SUCCESS,
"errorMessage" to "", packageName = packageName,
"packageName" to packageName, isUpdate = isUpdate
"isUpdate" to isUpdate )
))
}
PackageInstaller.STATUS_FAILURE -> {
Log.w(TAG, "Installation failed: $packageName - $message (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Installation failed"),
"packageName" to packageName
), context)
} }
PackageInstaller.STATUS_FAILURE_ABORTED -> { PackageInstaller.STATUS_FAILURE_ABORTED -> {
Log.d(TAG, "Installation cancelled: $packageName (sessionId=$sessionId)") Log.d(TAG, "Installation cancelled: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to "Installation was cancelled by user", status = InstallStatus.CANCELLED,
"packageName" to packageName, packageName = packageName,
"cancelled" to true message = "Installation was cancelled"
), context) )
}
PackageInstaller.STATUS_FAILURE -> {
Log.w(TAG, "Installation failed: $packageName - $message")
// Try to determine error code from message content
val errorCode = categorizeFailure(message)
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Installation failed",
errorCode = errorCode
)
} }
PackageInstaller.STATUS_FAILURE_BLOCKED -> { PackageInstaller.STATUS_FAILURE_BLOCKED -> {
Log.w(TAG, "Installation blocked: $packageName (sessionId=$sessionId)") Log.w(TAG, "Installation blocked: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to (message ?: "Installation blocked by device policy"), status = InstallStatus.FAILED,
"packageName" to packageName packageName = packageName,
), context) message = message ?: "Installation blocked by device policy",
errorCode = ErrorCode.BLOCKED
)
} }
PackageInstaller.STATUS_FAILURE_CONFLICT -> { PackageInstaller.STATUS_FAILURE_CONFLICT -> {
Log.w(TAG, "Installation conflict: $packageName (sessionId=$sessionId)") Log.w(TAG, "Installation conflict: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( // Conflict typically means signature mismatch
"isSuccess" to false, AndroidPackageManagerPlugin.onInstallResult(
"errorMessage" to (message ?: "Installation conflicts with existing package"), sessionId = sessionId,
"packageName" to packageName status = InstallStatus.FAILED,
), context) packageName = packageName,
message = message ?: "Installation conflicts with existing package",
errorCode = ErrorCode.CERT_MISMATCH
)
} }
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> { PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> {
Log.w(TAG, "Installation incompatible: $packageName (sessionId=$sessionId)") Log.w(TAG, "Incompatible package: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to (message ?: "Package is incompatible with this device"), status = InstallStatus.FAILED,
"packageName" to packageName packageName = packageName,
), context) message = message ?: "Package is incompatible with this device",
errorCode = ErrorCode.INCOMPATIBLE
)
} }
PackageInstaller.STATUS_FAILURE_INVALID -> { PackageInstaller.STATUS_FAILURE_INVALID -> {
Log.w(TAG, "Installation invalid: $packageName (sessionId=$sessionId)") Log.w(TAG, "Invalid package: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to (message ?: "Invalid installation package"), status = InstallStatus.FAILED,
"packageName" to packageName packageName = packageName,
), context) message = message ?: "Invalid installation package",
errorCode = ErrorCode.INVALID_FILE
)
} }
PackageInstaller.STATUS_FAILURE_STORAGE -> { PackageInstaller.STATUS_FAILURE_STORAGE -> {
Log.w(TAG, "Installation failed - storage: $packageName (sessionId=$sessionId)") Log.w(TAG, "Storage failure: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to (message ?: "Insufficient storage space"), status = InstallStatus.FAILED,
"packageName" to packageName packageName = packageName,
), context) message = message ?: "Insufficient storage space",
errorCode = ErrorCode.INSUFFICIENT_STORAGE
)
} }
else -> { else -> {
Log.w(TAG, "Unknown installation status $status: $packageName (sessionId=$sessionId)") Log.w(TAG, "Unknown status $status: $packageName")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( AndroidPackageManagerPlugin.onInstallResult(
"isSuccess" to false, sessionId = sessionId,
"errorMessage" to (message ?: "Installation failed with status $status"), status = InstallStatus.FAILED,
"packageName" to packageName packageName = packageName,
), context) message = message ?: "Installation failed (status: $status)",
errorCode = ErrorCode.INSTALL_FAILED
)
} }
} }
} }
private fun showUserActionNotification( /**
context: Context, * Categorize a generic failure message to determine the error code.
sessionId: Int, */
confirmIntent: Intent, private fun categorizeFailure(message: String?): String {
packageName: String if (message == null) return ErrorCode.INSTALL_FAILED
) { val lower = message.lowercase()
// Check notification permission on Android 13+ return when {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { lower.contains("signature") ||
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) lower.contains("certificate") ||
!= android.content.pm.PackageManager.PERMISSION_GRANTED) { lower.contains("update_incompatible") -> ErrorCode.CERT_MISMATCH
return lower.contains("storage") || lower.contains("space") -> ErrorCode.INSUFFICIENT_STORAGE
} lower.contains("permission") || lower.contains("denied") -> ErrorCode.PERMISSION_DENIED
else -> ErrorCode.INSTALL_FAILED
} }
}
val notificationManager = context.getSystemService<NotificationManager>()
if (notificationManager?.areNotificationsEnabled() == true) { /**
val pendingIntent = PendingIntent.getActivity( * Extract the confirmation dialog intent from a PENDING_USER_ACTION broadcast.
context, */
sessionId, private fun extractConfirmIntent(intent: Intent): Intent? {
confirmIntent, return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
) } else {
@Suppress("DEPRECATION")
val notification = NotificationCompat.Builder(context, "install_channel") intent.getParcelableExtra(Intent.EXTRA_INTENT)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Installation requires confirmation")
.setContentText("Tap to continue installing $packageName")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
try {
notificationManager.notify(sessionId, notification)
} catch (e: Exception) {
Log.w(TAG, "Failed to show notification", e)
}
} }
} }
} }
+27 -15
View File
@@ -12,7 +12,6 @@ import 'package:purplebase/purplebase.dart';
import 'package:amber_signer/amber_signer.dart'; import 'package:amber_signer/amber_signer.dart';
import 'package:zapstore/services/app_restart_service.dart'; import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/background_update_service.dart'; import 'package:zapstore/services/background_update_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/router.dart'; import 'package:zapstore/router.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
@@ -272,36 +271,49 @@ Future<void> onSignInSuccess(Ref ref) async {
); );
} }
/// Observes app lifecycle events and re-syncs package state when app regains focus /// Observes app lifecycle events and manages package/storage state
class _AppLifecycleObserver with WidgetsBindingObserver { class _AppLifecycleObserver with WidgetsBindingObserver {
_AppLifecycleObserver(this._ref); _AppLifecycleObserver(this._ref);
final WidgetRef _ref; final WidgetRef _ref;
/// Handle permission grants that happened while app was backgrounded
Future<void> _checkPermissionGrants() async {
final packageManager = _ref.read(packageManagerProvider.notifier);
if (packageManager is! AndroidPackageManager) return;
final hasPermission = await packageManager.hasPermission();
if (!hasPermission) return;
// Advance any operations waiting for permission
final state = _ref.read(packageManagerProvider);
final awaitingPermission = state.operations.entries
.where((e) => e.value is AwaitingPermission)
.map((e) => e.key)
.toList(growable: false);
for (final appId in awaitingPermission) {
await packageManager.onPermissionGranted(appId);
}
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
final downloadService = _ref.read(downloadServiceProvider.notifier);
final notifier = final notifier =
_ref.read(storageNotifierProvider.notifier) _ref.read(storageNotifierProvider.notifier)
as PurplebaseStorageNotifier; as PurplebaseStorageNotifier;
final packageManager = _ref.read(packageManagerProvider.notifier);
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// Process any pending installations // Sync installed packages to detect installs that completed while backgrounded
unawaited(downloadService.setAppForeground(true)); unawaited(packageManager.syncInstalledPackages());
// Re-sync installed packages from Android system // Check for permission grants that happened in settings
unawaited( unawaited(_checkPermissionGrants());
_ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
);
// Note: UpdateNotifier automatically triggers checkForUpdates() when
// packageManagerProvider state changes, so we don't need to call it explicitly
// Force immediate health check on storage/relay connections // Reconnect storage/relay connections
// This detects stale connections after system sleep and triggers reconnection
notifier.connect(); notifier.connect();
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
// Note: paused corresponds to Android's `onStop`
unawaited(downloadService.setAppForeground(false));
notifier.disconnect(); notifier.disconnect();
} }
} }
+6 -6
View File
@@ -140,10 +140,9 @@ class _AppDetailContent extends HookConsumerWidget {
final latestMetadata = app.latestFileMetadata; final latestMetadata = app.latestFileMetadata;
// Check if app is installed for menu options // Check if app is installed for menu options
final installedPackage = ref final installedPackage = ref.watch(
.watch(packageManagerProvider) installedPackageProvider(app.identifier),
.where((p) => p.appId == app.identifier) );
.firstOrNull;
final isInstalled = installedPackage != null; final isInstalled = installedPackage != null;
// Show skeleton while relationships are loading // Show skeleton while relationships are loading
@@ -634,7 +633,9 @@ class _AppDetailContent extends HookConsumerWidget {
// Save to local storage and publish to relays // Save to local storage and publish to relays
await ref.storage.save({signedStack}); await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social')); ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(
@@ -675,7 +676,6 @@ class _AppDetailContent extends HookConsumerWidget {
Future<void> _openApp(BuildContext context, WidgetRef ref, App app) async { Future<void> _openApp(BuildContext context, WidgetRef ref, App app) async {
try { try {
final packageManager = ref.read(packageManagerProvider.notifier); final packageManager = ref.read(packageManagerProvider.notifier);
context.showInfo('Launching ${app.name ?? app.identifier}...');
await packageManager.launchApp(app.identifier); await packageManager.launchApp(app.identifier);
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
+7 -17
View File
@@ -218,10 +218,8 @@ class _AuthenticationSection extends ConsumerWidget {
} }
Widget _buildSignInOptions(BuildContext context, WidgetRef ref) { Widget _buildSignInOptions(BuildContext context, WidgetRef ref) {
final packageManager = ref.watch(packageManagerProvider); final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any( final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
(p) => p.appId == kAmberPackageId,
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1248,23 +1246,18 @@ class _SavedAppsList extends ConsumerWidget {
class _AboutSection extends ConsumerWidget { class _AboutSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final manager = ref.watch(packageManagerProvider); final pmState = ref.watch(packageManagerProvider);
final zsPackage = manager.firstWhereOrNull( final zsPackage = pmState.installed[kZapstoreAppIdentifier];
(i) => i.appId == kZapstoreAppIdentifier,
);
if (zsPackage == null) { if (zsPackage == null) {
final isLoading = manager.isEmpty; final isLoading = pmState.installed.isEmpty;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text('About', style: Theme.of(context).textTheme.titleMedium),
'About',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (isLoading) ...[ if (isLoading) ...[
Row( Row(
@@ -1318,10 +1311,7 @@ class _AboutSection extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text('About', style: Theme.of(context).textTheme.titleMedium),
'About',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
leading: ClipOval( leading: ClipOval(
+176 -41
View File
@@ -2,7 +2,7 @@ import 'package:async_button_builder/async_button_builder.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
@@ -18,9 +18,7 @@ class UpdatesScreen extends HookConsumerWidget {
final categorized = ref.watch(categorizedAppsProvider); final categorized = ref.watch(categorizedAppsProvider);
if (categorized.isLoading) { if (categorized.isLoading) {
return const Scaffold( return const Scaffold(body: Center(child: CircularProgressIndicator()));
body: Center(child: CircularProgressIndicator()),
);
} }
return Scaffold( return Scaffold(
@@ -43,13 +41,16 @@ enum _UpdatesItemType {
manualApp, manualApp,
upToDateHeader, upToDateHeader,
upToDateApp, upToDateApp,
uncatalogedHeader,
uncatalogedApp,
} }
class _UpdatesItem { class _UpdatesItem {
final _UpdatesItemType type; final _UpdatesItemType type;
final App? app; final App? app;
final PackageInfo? packageInfo;
const _UpdatesItem(this.type, [this.app]); const _UpdatesItem(this.type, {this.app, this.packageInfo});
} }
class _UpdatesContent extends HookConsumerWidget { class _UpdatesContent extends HookConsumerWidget {
@@ -62,26 +63,33 @@ class _UpdatesContent extends HookConsumerWidget {
final automaticUpdates = categorized.automaticUpdates; final automaticUpdates = categorized.automaticUpdates;
final manualUpdates = categorized.manualUpdates; final manualUpdates = categorized.manualUpdates;
final upToDateApps = categorized.upToDateApps; final upToDateApps = categorized.upToDateApps;
final uncatalogedApps = categorized.uncatalogedApps;
final downloads = ref.watch(downloadServiceProvider); // Watch operations from PackageManager
final installingAppIds = downloads.entries final operations = ref.watch(
.where((entry) => entry.value.isActiveOrInstalling) packageManagerProvider.select((s) => s.operations),
);
final activeAppIds = operations.entries
.where((entry) => entry.value.isActive)
.map((entry) => entry.key) .map((entry) => entry.key)
.toSet(); .toSet();
if (installingAppIds.isEmpty) {
if (activeAppIds.isEmpty) {
return _UpdatesListBody( return _UpdatesListBody(
automaticUpdates: automaticUpdates, automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates, manualUpdates: manualUpdates,
installingApps: const [], installingApps: const [],
upToDateApps: upToDateApps, upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
); );
} }
return _UpdatesListBodyWithInstallingAppIds( return _UpdatesListBodyWithInstallingAppIds(
installingAppIds: installingAppIds, installingAppIds: activeAppIds,
automaticUpdates: automaticUpdates, automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates, manualUpdates: manualUpdates,
upToDateApps: upToDateApps, upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
); );
} }
} }
@@ -92,12 +100,14 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
required this.automaticUpdates, required this.automaticUpdates,
required this.manualUpdates, required this.manualUpdates,
required this.upToDateApps, required this.upToDateApps,
required this.uncatalogedApps,
}); });
final Set<String> installingAppIds; final Set<String> installingAppIds;
final List<App> automaticUpdates; final List<App> automaticUpdates;
final List<App> manualUpdates; final List<App> manualUpdates;
final List<App> upToDateApps; final List<App> upToDateApps;
final List<PackageInfo> uncatalogedApps;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -128,6 +138,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
manualUpdates: manualUpdates, manualUpdates: manualUpdates,
installingApps: installingApps, installingApps: installingApps,
upToDateApps: upToDateApps, upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
); );
} }
} }
@@ -138,19 +149,22 @@ class _UpdatesListBody extends StatelessWidget {
required this.manualUpdates, required this.manualUpdates,
required this.installingApps, required this.installingApps,
required this.upToDateApps, required this.upToDateApps,
required this.uncatalogedApps,
}); });
final List<App> automaticUpdates; final List<App> automaticUpdates;
final List<App> manualUpdates; final List<App> manualUpdates;
final List<App> installingApps; final List<App> installingApps;
final List<App> upToDateApps; final List<App> upToDateApps;
final List<PackageInfo> uncatalogedApps;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (automaticUpdates.isEmpty && if (automaticUpdates.isEmpty &&
manualUpdates.isEmpty && manualUpdates.isEmpty &&
installingApps.isEmpty && installingApps.isEmpty &&
upToDateApps.isEmpty) { upToDateApps.isEmpty &&
uncatalogedApps.isEmpty) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return Center(
@@ -209,14 +223,14 @@ class _UpdatesListBody extends StatelessWidget {
if (installingApps.isNotEmpty) { if (installingApps.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.installingHeader)); items.add(const _UpdatesItem(_UpdatesItemType.installingHeader));
for (final app in installingApps) { for (final app in installingApps) {
items.add(_UpdatesItem(_UpdatesItemType.installingApp, app)); items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app));
} }
} }
if (automaticUpdates.isNotEmpty) { if (automaticUpdates.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader)); items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader));
for (final app in automaticUpdates) { for (final app in automaticUpdates) {
items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app)); items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app: app));
} }
} }
@@ -224,18 +238,28 @@ class _UpdatesListBody extends StatelessWidget {
items.add(const _UpdatesItem(_UpdatesItemType.manualHeader)); items.add(const _UpdatesItem(_UpdatesItemType.manualHeader));
items.add(const _UpdatesItem(_UpdatesItemType.manualInfoBox)); items.add(const _UpdatesItem(_UpdatesItemType.manualInfoBox));
for (final app in manualUpdates) { for (final app in manualUpdates) {
items.add(_UpdatesItem(_UpdatesItemType.manualApp, app)); items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app));
} }
} }
if (upToDateApps.isNotEmpty) { if (upToDateApps.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader)); items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader));
for (final app in upToDateApps) { for (final app in upToDateApps) {
items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app)); items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app: app));
}
}
if (uncatalogedApps.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.uncatalogedHeader));
for (final pkg in uncatalogedApps) {
items.add(
_UpdatesItem(_UpdatesItemType.uncatalogedApp, packageInfo: pkg),
);
} }
} }
return ListView.builder( return ListView.builder(
key: const PageStorageKey<String>('updates_list'),
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -244,6 +268,7 @@ class _UpdatesListBody extends StatelessWidget {
return _InstallingHeader(count: installingApps.length); return _InstallingHeader(count: installingApps.length);
case _UpdatesItemType.installingApp: case _UpdatesItemType.installingApp:
return AppCard( return AppCard(
key: ValueKey('installing_${item.app?.identifier}'),
app: item.app, app: item.app,
showUpdateArrow: false, showUpdateArrow: false,
showUpdateButton: true, showUpdateButton: true,
@@ -259,6 +284,7 @@ class _UpdatesListBody extends StatelessWidget {
); );
case _UpdatesItemType.automaticApp: case _UpdatesItemType.automaticApp:
return AppCard( return AppCard(
key: ValueKey('automatic_${item.app?.identifier}'),
app: item.app, app: item.app,
showUpdateArrow: true, showUpdateArrow: true,
showUpdateButton: true, showUpdateButton: true,
@@ -276,6 +302,7 @@ class _UpdatesListBody extends StatelessWidget {
return _ManualUpdatesInfoBox(); return _ManualUpdatesInfoBox();
case _UpdatesItemType.manualApp: case _UpdatesItemType.manualApp:
return AppCard( return AppCard(
key: ValueKey('manual_${item.app?.identifier}'),
app: item.app, app: item.app,
showUpdateArrow: true, showUpdateArrow: true,
showUpdateButton: true, showUpdateButton: true,
@@ -286,10 +313,18 @@ class _UpdatesListBody extends StatelessWidget {
return _UpToDateHeader(count: upToDateApps.length); return _UpToDateHeader(count: upToDateApps.length);
case _UpdatesItemType.upToDateApp: case _UpdatesItemType.upToDateApp:
return AppCard( return AppCard(
key: ValueKey('uptodate_${item.app?.identifier}'),
app: item.app, app: item.app,
showUpdateArrow: false, showUpdateArrow: false,
showDescription: false, showDescription: false,
); );
case _UpdatesItemType.uncatalogedHeader:
return _UncatalogedHeader(count: uncatalogedApps.length);
case _UpdatesItemType.uncatalogedApp:
return _UncatalogedAppCard(
key: ValueKey('uncataloged_${item.packageInfo?.appId}'),
packageInfo: item.packageInfo!,
);
} }
}, },
); );
@@ -309,10 +344,7 @@ class _InstallingHeader extends StatelessWidget {
children: [ children: [
Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary), Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text('Installing', style: context.textTheme.titleMedium),
'Installing',
style: context.textTheme.titleMedium,
),
const SizedBox(width: 8), const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground), CountBadge(count: count, color: AppColors.darkPillBackground),
], ],
@@ -350,10 +382,7 @@ class _UpdatesSectionHeader extends ConsumerWidget {
Icon(icon, size: 20, color: AppColors.darkActionPrimary), Icon(icon, size: 20, color: AppColors.darkActionPrimary),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(title, style: context.textTheme.titleMedium),
title,
style: context.textTheme.titleMedium,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground), CountBadge(count: count, color: AppColors.darkPillBackground),
@@ -361,19 +390,18 @@ class _UpdatesSectionHeader extends ConsumerWidget {
), ),
AsyncButtonBuilder( AsyncButtonBuilder(
onPressed: () async { onPressed: () async {
final downloadService = ref.read( final pm = ref.read(packageManagerProvider.notifier);
downloadServiceProvider.notifier, final items = apps
); .where((app) => app.latestFileMetadata != null)
for (final app in apps) { .map(
final release = app.latestRelease.value; (app) => (
if (release != null) { appId: app.identifier,
try { target: app.latestFileMetadata!,
await downloadService.downloadApp(app, release); displayName: app.name,
} catch (_) { ),
// Ignore download start failures per-app to keep others going )
} .toList();
} await pm.queueDownloads(items);
}
}, },
builder: (context, child, callback, buttonState) { builder: (context, child, callback, buttonState) {
const pillBg = AppColors.darkPillBackground; const pillBg = AppColors.darkPillBackground;
@@ -475,13 +503,14 @@ class _UpToDateHeader extends StatelessWidget {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Row( child: Row(
children: [ children: [
Icon(Icons.check_circle, size: 20, color: AppColors.darkActionPrimary), Icon(
const SizedBox(width: 8), Icons.check_circle,
Text( size: 20,
'Up to date', color: AppColors.darkActionPrimary,
style: context.textTheme.titleMedium,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Up to date', style: context.textTheme.titleMedium),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground), CountBadge(count: count, color: AppColors.darkPillBackground),
], ],
), ),
@@ -489,3 +518,109 @@ class _UpToDateHeader extends StatelessWidget {
} }
} }
class _UncatalogedHeader extends StatelessWidget {
const _UncatalogedHeader({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
Icons.help_outline,
size: 20,
color: AppColors.darkOnSurfaceSecondary,
),
const SizedBox(width: 8),
Text('Other installed', style: context.textTheme.titleMedium),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground),
],
),
);
}
}
class _UncatalogedAppCard extends StatelessWidget {
const _UncatalogedAppCard({super.key, required this.packageInfo});
final PackageInfo packageInfo;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
child: Row(
children: [
// Generic app icon
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.android,
color: AppColors.darkOnSurfaceSecondary,
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packageInfo.name ?? packageInfo.appId,
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
packageInfo.appId,
style: context.textTheme.bodySmall?.copyWith(
color: AppColors.darkOnSurfaceSecondary,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Version pill
Container(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 5,
),
decoration: BoxDecoration(
color: AppColors.darkPillBackground,
borderRadius: BorderRadius.circular(8),
),
child: Text(
packageInfo.version,
style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
),
],
),
);
}
}
+3 -3
View File
@@ -169,12 +169,12 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
final packageManager = container.read(packageManagerProvider.notifier); final packageManager = container.read(packageManagerProvider.notifier);
await packageManager.syncInstalledPackages(); await packageManager.syncInstalledPackages();
final packages = container.read(packageManagerProvider); final pmState = container.read(packageManagerProvider);
if (packages.isEmpty) { if (pmState.installed.isEmpty) {
return true; // No installed apps to check return true; // No installed apps to check
} }
final installedIds = packages.map((p) => p.appId).toSet(); final installedIds = pmState.installed.keys.toSet();
// Query for apps with updates from relay // Query for apps with updates from relay
final storage = container.read(storageNotifierProvider.notifier); final storage = container.read(storageNotifierProvider.notifier);
-84
View File
@@ -1,84 +0,0 @@
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;
/// True when this download is actively progressing, queued, paused, retrying,
/// or currently installing.
bool get isActiveOrInstalling {
if (isInstalling) return true;
return switch (status) {
TaskStatus.running ||
TaskStatus.enqueued ||
TaskStatus.paused ||
TaskStatus.waitingToRetry => true,
_ => false,
};
}
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,
});
}
@@ -1,166 +0,0 @@
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<Map<String, DownloadInfo>> restoreState() async {
final restoredState = <String, DownloadInfo>{};
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<String, dynamic>;
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<FileMetadata?> _loadFileMetadata(
String? metadataId,
String filename,
) async {
// Try by metadataId first
if (metadataId != null) {
try {
final results = _storage.querySync(
RequestFilter<FileMetadata>(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<FileMetadata>(search: hash).toRequest(),
);
if (results.isNotEmpty) return results.first;
} catch (_) {}
}
return null;
}
/// Check if the downloaded file exists
Future<bool> _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<void> 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});
}
}
-455
View File
@@ -1,455 +0,0 @@
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<Map<String, DownloadInfo>> {
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<QueuedDownload> _downloadQueue = [];
@override
void dispose() {
_downloader.unregisterCallbacks();
super.dispose();
}
Future<void> _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 {displayName}',
'{progress}',
),
complete: const TaskNotification('Download complete', '{displayName}'),
error: const TaskNotification('Download failed', '{displayName}'),
paused: const TaskNotification('Download paused', '{displayName}'),
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<void> _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<void> 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<void> 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<void> pauseDownload(String appId) async {
final info = state[appId];
if (info == null) return;
try {
await _downloader.pause(info.task);
} catch (_) {}
}
/// Resume a paused download
Future<void> resumeDownload(String appId) async {
final info = state[appId];
if (info == null) return;
try {
await _downloader.resume(info.task);
} catch (_) {}
}
/// Cancel a download
Future<void> 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<void> 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<void> 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<void> _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, Map<String, DownloadInfo>>(
DownloadService.new,
);
/// Provider for getting download info for a specific app
final downloadInfoProvider = Provider.family<DownloadInfo?, String>((
ref,
appId,
) {
return ref.watch(downloadServiceProvider)[appId];
});
@@ -1,239 +0,0 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../package_manager/android_package_manager.dart';
import '../package_manager/package_manager.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<String> _queue = [];
final Set<String> _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<void> 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<void> handleDownloadComplete(String appId) async {
final downloadInfo = _getState(appId);
if (downloadInfo == null) return;
// Check silent install from PackageInfo (computed during syncInstalledPackages)
final packages = _ref.read(packageManagerProvider);
final packageInfo = packages.where((p) => p.appId == appId).firstOrNull;
final canSilent = packageInfo?.canInstallSilently ?? false;
// If backgrounded and not silent install, defer
if (!_isAppInForeground && !canSilent) {
_pendingInstallations.add(appId);
_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<void> _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<void> handleStalledApps(List<String> 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<void> _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: ', ''),
),
);
}
}
}
@@ -1,17 +1,71 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
/// Android implementation of PackageManager with integrated installation. /// Install status values from native side.
final class AndroidPackageManager extends PackageManager { ///
AndroidPackageManager(super.ref); /// IMPORTANT: The native side sends these as strings via EventChannel, so we
/// parse them into this enum for type-safe switching.
enum InstallStatus {
started,
verifying,
pendingUserAction,
alreadyInProgress,
success,
failed,
cancelled,
}
extension InstallStatusX on InstallStatus {
static InstallStatus? tryParse(String? raw) {
return switch (raw) {
'started' => InstallStatus.started,
'verifying' => InstallStatus.verifying,
'pendingUserAction' => InstallStatus.pendingUserAction,
'alreadyInProgress' => InstallStatus.alreadyInProgress,
'success' => InstallStatus.success,
'failed' => InstallStatus.failed,
'cancelled' => InstallStatus.cancelled,
_ => null,
};
}
}
/// Error codes from native side (structured, reliable)
class NativeErrorCode {
static const downloadFailed = 'downloadFailed';
static const hashMismatch = 'hashMismatch';
static const invalidFile = 'invalidFile';
static const installFailed = 'installFailed';
static const certMismatch = 'certMismatch';
static const permissionDenied = 'permissionDenied';
static const insufficientStorage = 'insufficientStorage';
static const incompatible = 'incompatible';
static const blocked = 'blocked';
static const alreadyInProgress = 'alreadyInProgress';
static const installTimeout = 'installTimeout';
}
/// Android implementation of PackageManager using event-driven architecture.
///
/// The native side streams install status events via EventChannel.
/// Foreground state is auto-detected by ProcessLifecycleOwner on the native side.
/// No polling, no probing, no hanging awaits.
final class AndroidPackageManager extends PackageManager {
AndroidPackageManager(super.ref) {
_setupEventStream();
}
static const _methodChannel = MethodChannel('android_package_manager');
static const _eventChannel = EventChannel('android_package_manager/events');
static const MethodChannel _channel = MethodChannel(
'android_package_manager',
);
bool _supportsSilentInstall = false; bool _supportsSilentInstall = false;
int _syncGeneration = 0;
StreamSubscription<dynamic>? _eventSubscription;
@override @override
String get platform => 'android-arm64-v8a'; String get platform => 'android-arm64-v8a';
@@ -19,80 +73,404 @@ final class AndroidPackageManager extends PackageManager {
@override @override
String get packageExtension => '.apk'; String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => _supportsSilentInstall;
// ═══════════════════════════════════════════════════════════════════════════
// EVENT STREAM HANDLING
// ═══════════════════════════════════════════════════════════════════════════
void _setupEventStream() {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleInstallEvent,
onError: (_) {}, // Events will resume when stream reconnects
);
}
/// Handle install status events from native side.
/// Events arrive sequentially (Dart is single-threaded), no lock needed.
void _handleInstallEvent(dynamic event) {
if (event is! Map) {
debugPrint('[PackageManager] Ignoring non-map event: $event');
return;
}
final appId = event['appId'] as String?;
final statusRaw = event['status'] as String?;
final message = event['message'] as String?;
final errorCode = event['errorCode'] as String?;
final status = InstallStatusX.tryParse(statusRaw);
debugPrint(
'[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode',
);
if (appId == null || statusRaw == null) {
debugPrint('[PackageManager] Ignoring event with null appId or status');
return;
}
if (status == null) {
debugPrint(
'[PackageManager] Ignoring event with unknown status: $statusRaw',
);
return;
}
// Get target from existing operation state - no separate tracking needed
final existingOp = getOperation(appId);
if (existingOp == null) {
debugPrint(
'[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event',
);
debugPrint(
'[PackageManager] Current operations: ${state.operations.keys.toList()}',
);
return;
}
debugPrint(
'[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})',
);
final target = existingOp.target;
final filePath = existingOp.filePath;
switch (status) {
case InstallStatus.verifying:
// Kotlin started hash verification - show Verifying state
if (filePath != null) {
setOperation(appId, Verifying(target: target, filePath: filePath));
}
break;
case InstallStatus.started:
// Install session started - transition to Installing
if (filePath != null) {
final pkg = state.installed[appId];
final isSilent = pkg?.canInstallSilently ?? false;
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: isSilent),
);
}
break;
case InstallStatus.pendingUserAction:
// User action required. Ensure we don't get stuck in Verifying if the
// STARTED event was missed; show Installing state.
if (filePath != null && existingOp is! Installing) {
final pkg = state.installed[appId];
final isSilent = pkg?.canInstallSilently ?? false;
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: isSilent),
);
}
break;
case InstallStatus.alreadyInProgress:
// Native is stuck thinking an install is in progress.
// Treat as failure so user can see the issue and retry manually.
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.installFailed,
message: 'Android is busy. Please try again in a moment.',
filePath: filePath,
),
);
// Move on to next app in queue
_advanceAfterDelay();
break;
case InstallStatus.success:
_deleteFile(filePath);
// CRITICAL: Update installed package info DIRECTLY from target metadata.
// We cannot rely on syncInstalledPackages() here because Android's package
// database may not have committed yet, causing a race condition where
// we get stale data and show "Update" instead of "Open".
_updateInstalledPackage(appId, target);
clearOperation(appId);
// Sync in background to get accurate info (signature hash, etc.)
// but our state machine doesn't depend on it.
unawaited(syncInstalledPackages());
// Advance to next queued install
_advanceAfterDelay();
break;
case InstallStatus.failed:
setOperation(
appId,
OperationFailed(
target: target,
type: _errorCodeToFailureType(errorCode, message),
message: message ?? 'Installation failed',
filePath: filePath,
),
);
// Advance to next queued install
_advanceAfterDelay();
break;
case InstallStatus.cancelled:
if (filePath != null) {
setOperation(
appId,
AwaitingUserAction(target: target, filePath: filePath),
);
} else {
clearOperation(appId);
}
// Advance to next queued install
_advanceAfterDelay();
break;
}
}
@override
void onInstallReady(String appId) {
_tryAdvanceNextInstall();
}
/// Advance to next install after a delay, giving Android time to clean up.
void _advanceAfterDelay() {
Future.delayed(const Duration(seconds: 1), _tryAdvanceNextInstall);
}
/// Try to start the next app in ReadyToInstall state.
/// Only advances if no other app is currently installing (one dialog at a time).
void _tryAdvanceNextInstall() {
// Check if any app is currently in an active install state
final hasActiveInstall = state.operations.values.any(
(op) => op is Verifying || op is Installing || op is Uninstalling,
);
if (hasActiveInstall) {
debugPrint('[PackageManager] Not advancing - install already active');
return;
}
// Get next app ready to install
final readyToInstall = getReadyToInstall();
if (readyToInstall.isEmpty) {
debugPrint('[PackageManager] No apps waiting in ReadyToInstall');
return;
}
final nextAppId = readyToInstall.first;
debugPrint('[PackageManager] Advancing to next install: $nextAppId');
// Trigger install (fire-and-forget, events will drive state)
unawaited(triggerInstall(nextAppId));
}
/// Convert native error code to FailureType.
/// Uses structured error code when available, falls back to message parsing.
FailureType _errorCodeToFailureType(String? errorCode, String? message) {
// Use structured error code when available (reliable)
if (errorCode != null) {
return switch (errorCode) {
NativeErrorCode.downloadFailed => FailureType.downloadFailed,
NativeErrorCode.hashMismatch => FailureType.hashMismatch,
NativeErrorCode.invalidFile => FailureType.invalidFile,
NativeErrorCode.installFailed => FailureType.installFailed,
NativeErrorCode.certMismatch => FailureType.certMismatch,
NativeErrorCode.permissionDenied => FailureType.permissionDenied,
NativeErrorCode.insufficientStorage => FailureType.insufficientStorage,
NativeErrorCode.incompatible => FailureType.installFailed,
NativeErrorCode.blocked => FailureType.permissionDenied,
NativeErrorCode.installTimeout => FailureType.installFailed,
_ => FailureType.installFailed,
};
}
// Fallback: categorize by message content (legacy, less reliable)
if (message == null) return FailureType.installFailed;
final lower = message.toLowerCase();
if (lower.contains('signature') ||
lower.contains('certificate') ||
lower.contains('update_incompatible')) {
return FailureType.certMismatch;
}
if (lower.contains('storage') || lower.contains('space')) {
return FailureType.insufficientStorage;
}
if (lower.contains('hash') || lower.contains('verification')) {
return FailureType.hashMismatch;
}
if (lower.contains('invalid') || lower.contains('corrupt')) {
return FailureType.invalidFile;
}
if (lower.contains('permission') || lower.contains('denied')) {
return FailureType.permissionDenied;
}
return FailureType.installFailed;
}
void _deleteFile(String? path) {
if (path == null) return;
try {
final file = File(path);
if (file.existsSync()) file.deleteSync();
} catch (_) {}
}
/// Directly update installed package from target metadata.
/// This ensures we don't depend on Android's package DB timing.
void _updateInstalledPackage(String appId, FileMetadata target) {
final existingPkg = state.installed[appId];
final newPkg = PackageInfo(
appId: appId,
name: existingPkg?.name,
version: target.version,
versionCode: target.versionCode,
// Keep existing signature hash if available, will be updated by sync
signatureHash: existingPkg?.signatureHash ?? '',
installTime: DateTime.now(),
canInstallSilently: existingPkg?.canInstallSilently ?? false,
);
state = state.copyWith(installed: {...state.installed, appId: newPkg});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTALLATION (Fire-and-forget - results come via events)
// ═══════════════════════════════════════════════════════════════════════════
@override @override
Future<void> install( Future<void> install(
String appId, String appId,
String filePath, { String filePath, {
required String expectedHash, required String expectedHash,
required int expectedSize, required int expectedSize,
bool skipVerification = false, required FileMetadata target,
}) async { }) async {
await _ensureInstallPermission(); // Permission is already checked by _proceedToInstall in base class
// No need for redundant _ensureInstallPermission() call here
final file = File(filePath); final file = File(filePath);
if (!file.existsSync()) { if (!file.existsSync()) {
throw Exception('APK file not found: $filePath'); throw Exception('APK file not found: $filePath');
} }
final result = await _channel // Native side is the source of truth for install state; it will emit
.invokeMethod<Map<Object?, Object?>>('install', { // VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events.
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'skipVerification': skipVerification,
});
final resultMap = Map<String, dynamic>.from(result ?? {}); try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('install', {
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
})
.timeout(const Duration(seconds: 30), onTimeout: () => null);
// If installation is already in progress, do NOT treat this call as success. final resultMap = Map<String, dynamic>.from(result ?? {});
// Returning silently would cause callers to delete APK files / advance queues while the
// original session is still active, which can lead to "session files in use" races. if (resultMap['alreadyInProgress'] == true) {
if (resultMap['alreadyInProgress'] == true) { // Native is stuck thinking an install is in progress.
throw Exception('INSTALL_ALREADY_IN_PROGRESS'); // Treat as failure so user can see and retry manually.
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.installFailed,
message: 'Android is busy. Please try again in a moment.',
filePath: filePath,
),
);
_advanceAfterDelay();
return;
}
if (resultMap['started'] != true) {
final error =
resultMap['error'] as String? ?? 'Failed to start install';
final errorCode = resultMap['errorCode'] as String?;
setOperation(
appId,
OperationFailed(
target: target,
type: _errorCodeToFailureType(errorCode, error),
message: error,
filePath: filePath,
),
);
// Don't auto-advance after failure
}
// If started, wait for events via EventChannel
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.installFailed,
message: e.toString(),
filePath: filePath,
),
);
// Don't auto-advance after failure
} }
if (!(resultMap['isSuccess'] == true)) {
final error = resultMap['errorMessage'] ?? 'Installation failed';
throw Exception(error);
}
// Refresh installed packages state
await syncInstalledPackages();
} }
@override @override
Future<void> uninstall(String appId) async { Future<void> uninstall(String appId) async {
final result = await _channel.invokeMethod<Map<Object?, Object?>>( final result = await _methodChannel
'uninstall', .invokeMethod<Map<Object?, Object?>>('uninstall', {
{'packageName': appId}, 'packageName': appId,
); })
.timeout(
const Duration(seconds: 60), // Uninstall needs user confirmation
onTimeout: () => <Object?, Object?>{
'isSuccess': false,
'errorMessage': 'Uninstall timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {}); final resultMap = Map<String, dynamic>.from(result ?? {});
if (!(resultMap['isSuccess'] == true)) { if (resultMap['isSuccess'] != true) {
final wasCancelled = resultMap['cancelled'] == true; if (resultMap['cancelled'] == true) {
if (wasCancelled) {
throw Exception('Uninstall was cancelled'); throw Exception('Uninstall was cancelled');
} }
throw Exception('Uninstallation failed'); throw Exception(resultMap['errorMessage'] ?? 'Uninstallation failed');
} }
// Refresh installed packages state // Remove from installed immediately (don't wait for sync)
await syncInstalledPackages(); state = state.copyWith(installed: Map.from(state.installed)..remove(appId));
// Background sync for consistency
syncInstalledPackages();
} }
// ═══════════════════════════════════════════════════════════════════════════
// PERMISSIONS
// ═══════════════════════════════════════════════════════════════════════════
@override @override
Future<void> requestPermission() async { Future<void> requestPermission() async {
try { try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>( final result = await _methodChannel
'requestInstallPermission', .invokeMethod<Map<Object?, Object?>>('requestInstallPermission')
); .timeout(
const Duration(seconds: 5),
onTimeout: () => <Object?, Object?>{
'success': false,
'message': 'Request timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {}); final resultMap = Map<String, dynamic>.from(result ?? {});
if (!(resultMap['success'] == true)) { if (resultMap['success'] != true) {
final error = resultMap['message'] ?? 'Failed to request permission'; throw Exception(resultMap['message'] ?? 'Failed to request permission');
throw Exception(error);
} }
} catch (e) { } catch (e) {
throw Exception( throw Exception(
@@ -109,47 +487,23 @@ final class AndroidPackageManager extends PackageManager {
@override @override
Future<bool> hasPermission() async { Future<bool> hasPermission() async {
try { try {
final hasUnknownSources = return await _methodChannel
await _channel.invokeMethod<bool>('hasUnknownSourcesPermission') ?? .invokeMethod<bool>('hasUnknownSourcesPermission')
.timeout(const Duration(seconds: 5), onTimeout: () => false) ??
false; false;
return hasUnknownSources; } catch (_) {
} catch (e) {
return false; return false;
} }
} }
@override // ═══════════════════════════════════════════════════════════════════════════
bool get supportsSilentInstall { // APP LAUNCH
// Cached capability determined asynchronously via plugin // ═══════════════════════════════════════════════════════════════════════════
return _supportsSilentInstall;
}
/// Check if automatic, unattended updates are possible
bool get supportsAutomaticUpdates {
return _supportsSilentInstall;
}
/// Install an update silently if possible, otherwise fall back to user confirmation
Future<void> installUpdate(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
}) async {
// Install regardless - the system will handle showing confirmation if needed
await install(
appId,
filePath,
expectedHash: expectedHash,
expectedSize: expectedSize,
);
}
/// Launch an installed app by its package identifier
@override @override
Future<void> launchApp(String appId) async { Future<void> launchApp(String appId) async {
try { try {
final result = await _channel final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('launchApp', { .invokeMethod<Map<Object?, Object?>>('launchApp', {
'packageName': appId, 'packageName': appId,
}) })
@@ -157,143 +511,141 @@ final class AndroidPackageManager extends PackageManager {
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () => { onTimeout: () => {
'isSuccess': false, 'isSuccess': false,
'errorMessage': 'App launch timed out after 10 seconds', 'errorMessage': 'App launch timed out',
}, },
); );
final resultMap = Map<String, dynamic>.from(result ?? {}); final resultMap = Map<String, dynamic>.from(result ?? {});
if (!(resultMap['isSuccess'] == true)) { if (resultMap['isSuccess'] != true) {
final error = resultMap['errorMessage'] ?? 'Failed to launch app'; throw Exception(resultMap['errorMessage'] ?? 'Failed to launch app');
throw Exception(error);
} }
} catch (e) { } catch (e) {
throw Exception('Failed to launch app: $e'); throw Exception('Failed to launch app: $e');
} }
} }
/// Re-launch a pending install prompt that was backgrounded. // ═══════════════════════════════════════════════════════════════════════════
/// Returns structured info so callers can avoid double-prompts and bad state resets. // SYNC INSTALLED PACKAGES
Future<RetryPendingInstallResult> retryPendingInstall(String appId) async { // ═══════════════════════════════════════════════════════════════════════════
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'retryPendingInstall',
{'packageName': appId},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
return RetryPendingInstallResult.fromMap(resultMap);
} catch (e) {
return const RetryPendingInstallResult(
hasPending: false,
relaunched: false,
sessionPending: false,
promptAlreadyShown: false,
);
}
}
/// Set app foreground state on the native side.
/// Called from DownloadService when app lifecycle changes.
Future<void> setAppForegroundState(bool foreground) async {
try {
await _channel.invokeMethod('setAppForegroundState', {
'foreground': foreground,
});
} catch (e) {
// Ignore errors - best effort
}
}
@override @override
Future<void> syncInstalledPackages() async { Future<void> syncInstalledPackages() async {
final syncGen = ++_syncGeneration;
try { try {
// Check and cache silent install capability (general check) // Single native call - getInstalledApps already returns canInstallSilently per-app
final canInstallSilentlyGeneral =
await _channel.invokeMethod<bool>('canInstallSilently') ?? false;
_supportsSilentInstall = canInstallSilentlyGeneral;
// Get installed apps via method channel, excluding system apps
// Each app includes per-package canInstallSilently status
final installedApps = final installedApps =
await _channel.invokeMethod<List<Object?>>('getInstalledApps', { await _methodChannel
'includeSystemApps': false, .invokeMethod<List<Object?>>('getInstalledApps', {
}) ?? 'includeSystemApps': false,
})
.timeout(
const Duration(seconds: 10),
onTimeout: () => <Object?>[],
) ??
[]; [];
final packages = <PackageInfo>[]; if (syncGen != _syncGeneration) return;
final packages = <String, PackageInfo>{};
var anyCanInstallSilently = false;
for (final appObj in installedApps) { for (final appObj in installedApps) {
final app = Map<String, dynamic>.from(appObj as Map<Object?, Object?>); final app = Map<String, dynamic>.from(appObj as Map<Object?, Object?>);
final appId = final appId =
app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; app['bundleId'] as String? ?? app['packageName'] as String? ?? '';
final name = app['name'] as String?;
final version = app['versionName'] as String? ?? '0.0.0'; final version = app['versionName'] as String? ?? '0.0.0';
final versionCode = app['versionCode'] as int?; final versionCode = app['versionCode'] as int?;
final signatureHash = app['signatureHash'] as String? ?? ''; final signatureHash = app['signatureHash'] as String? ?? '';
final canInstallSilently = app['canInstallSilently'] as bool? ?? false; final canInstallSilently = app['canInstallSilently'] as bool? ?? false;
if (canInstallSilently) anyCanInstallSilently = true;
if (appId.isNotEmpty) { if (appId.isNotEmpty) {
packages.add( packages[appId] = PackageInfo(
PackageInfo( appId: appId,
appId: appId, name: name,
version: version, version: version,
versionCode: versionCode, versionCode: versionCode,
installTime: null, // Method channel doesn't provide install time signatureHash: signatureHash,
signatureHash: signatureHash, installTime: null,
canInstallSilently: canInstallSilently, canInstallSilently: canInstallSilently,
),
); );
} }
} }
state = packages; if (syncGen != _syncGeneration) return;
} catch (e) {
// Fallback to empty state on error
state = [];
}
}
Future<void> _ensureInstallPermission() async { // Derive general silent install capability from per-app data
if (!Platform.isAndroid) return; _supportsSilentInstall = anyCanInstallSilently;
try { // Preserve any packages we've directly updated (they have installTime set)
final status = await Permission.requestInstallPackages.status; // This prevents race conditions where sync returns stale data
if (status.isGranted) { final preserved = <String, PackageInfo>{};
return; for (final entry in state.installed.entries) {
if (entry.value.installTime != null &&
!packages.containsKey(entry.key)) {
// We directly updated this but sync doesn't have it yet - keep ours
preserved[entry.key] = entry.value;
} else if (entry.value.installTime != null &&
packages.containsKey(entry.key)) {
// Both have it - use sync data but preserve installTime marker
final syncPkg = packages[entry.key]!;
preserved[entry.key] = PackageInfo(
appId: syncPkg.appId,
name: syncPkg.name,
version: syncPkg.version,
versionCode: syncPkg.versionCode,
signatureHash: syncPkg.signatureHash,
installTime: entry.value.installTime,
canInstallSilently: syncPkg.canInstallSilently,
);
}
} }
final newStatus = await Permission.requestInstallPackages.request(); state = state.copyWith(installed: {...packages, ...preserved});
if (!newStatus.isGranted) {
throw Exception( // Clear operations for apps where the installed version matches the target version
'Install permission required. Please enable "Install unknown apps" ' // This catches installs that succeeded but we missed the event
'for Zapstore and try again.', // IMPORTANT: Don't clear operations where we're updating to a NEWER version
); for (final appId in packages.keys) {
final op = getOperation(appId);
if (op == null) continue;
if (op is! Installing &&
op is! Verifying &&
op is! AwaitingUserAction) {
continue;
}
final installedPkg = packages[appId];
final targetVc = op.target.versionCode;
final installedVc = installedPkg?.versionCode;
final targetV = op.target.version;
final installedV = installedPkg?.version;
final completed =
(targetVc != null &&
installedVc != null &&
installedVc >= targetVc) ||
(installedV != null && installedV == targetV);
// Only clear if we can establish completion reliably.
if (completed) {
debugPrint(
'[PackageManager] Sync: clearing completed operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)',
);
clearOperation(appId);
} else {
debugPrint(
'[PackageManager] Sync: keeping operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)',
);
}
} }
} catch (e) { } catch (_) {
throw Exception('Unable to request install permission: $e'); // Don't clobber state on transient errors
} }
} }
} }
final class RetryPendingInstallResult {
const RetryPendingInstallResult({
required this.hasPending,
required this.relaunched,
required this.sessionPending,
required this.promptAlreadyShown,
});
factory RetryPendingInstallResult.fromMap(Map<String, dynamic> map) {
return RetryPendingInstallResult(
hasPending: map['hasPending'] == true,
relaunched: map['relaunched'] == true,
sessionPending: map['sessionPending'] == true,
promptAlreadyShown: map['promptAlreadyShown'] == true,
);
}
final bool hasPending;
final bool relaunched;
final bool sessionPending;
final bool promptAlreadyShown;
}
@@ -1,32 +1,35 @@
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
/// Dummy implementation of PackageManager for testing /// Dummy implementation of PackageManager for testing and non-Android platforms
final class DummyPackageManager extends PackageManager { final class DummyPackageManager extends PackageManager {
DummyPackageManager(super.ref) { DummyPackageManager(super.ref) {
// Initialize state with mock packages state = PackageManagerState(
state = [ installed: {
PackageInfo( 'com.example.test': const PackageInfo(
appId: 'com.example.test', appId: 'com.example.test',
version: '1.0.0', name: 'Test App',
versionCode: 1, version: '1.0.0',
signatureHash: 'dummy_signature_1', versionCode: 1,
installTime: DateTime.now().subtract(const Duration(days: 1)), signatureHash: 'dummy_signature_1',
), ),
PackageInfo( 'dev.zapstore.alpha': const PackageInfo(
appId: 'dev.zapstore.alpha', appId: 'dev.zapstore.alpha',
version: '1.0.0', name: 'Zapstore',
versionCode: 1, version: '1.0.0',
signatureHash: 'dummy_signature_2', versionCode: 1,
installTime: DateTime.now().subtract(const Duration(hours: 12)), signatureHash: 'dummy_signature_2',
), ),
PackageInfo( 'com.dummy.browser': const PackageInfo(
appId: 'com.dummy.browser', appId: 'com.dummy.browser',
version: '2.1.0', name: 'Dummy Browser',
versionCode: 210, version: '2.1.0',
signatureHash: 'dummy_signature_3', versionCode: 210,
installTime: DateTime.now().subtract(const Duration(days: 5)), signatureHash: 'dummy_signature_3',
), ),
]; },
operations: const {},
);
} }
@override @override
@@ -35,57 +38,58 @@ final class DummyPackageManager extends PackageManager {
@override @override
String get packageExtension => '.apk'; String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => false;
@override @override
Future<void> install( Future<void> install(
String appId, String appId,
String filePath, { String filePath, {
required String expectedHash, required String expectedHash,
required int expectedSize, required int expectedSize,
bool skipVerification = false, required FileMetadata target,
}) async { }) async {
// Mock implementation - just add to list if not already present // Mock: simulate install delay then add to installed
if (!state.any((p) => p.appId == appId)) { await Future.delayed(const Duration(milliseconds: 500));
state = [
...state, final newInstalled = Map<String, PackageInfo>.from(state.installed);
PackageInfo( newInstalled[appId] = PackageInfo(
appId: appId, appId: appId,
version: '1.0.0', version: target.version,
versionCode: 1, versionCode: target.versionCode,
signatureHash: 'mock_signature', signatureHash: 'mock_signature',
installTime: DateTime.now(), installTime: DateTime.now(),
), );
]; state = state.copyWith(installed: newInstalled);
}
// Clear the operation since install completed
clearOperation(appId);
} }
@override @override
Future<void> uninstall(String appId) async { Future<void> uninstall(String appId) async {
state = state.where((p) => p.appId != appId).toList(); await Future.delayed(const Duration(milliseconds: 300));
final newInstalled = Map<String, PackageInfo>.from(state.installed);
newInstalled.remove(appId);
state = state.copyWith(installed: newInstalled);
} }
@override @override
Future<void> launchApp(String appId) async { Future<void> launchApp(String appId) async {
// Mock implementation - just simulate launching if (!state.installed.containsKey(appId)) {
if (!state.any((p) => p.appId == appId)) {
throw Exception('App not installed: $appId'); throw Exception('App not installed: $appId');
} }
// In a real implementation, this would launch the app
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
} }
@override @override
Future<void> requestPermission() async { Future<void> requestPermission() async {
// Mock implementation - always succeeds // Mock: always succeeds
} }
@override @override
Future<bool> hasPermission() async { Future<bool> hasPermission() async => true;
// Mock implementation - always has permission
return true;
}
@override
bool get supportsSilentInstall => false;
@override @override
Future<void> syncInstalledPackages() async { Future<void> syncInstalledPackages() async {
@@ -0,0 +1,202 @@
import 'package:models/models.dart';
/// Stale download threshold - operations older than this will be cleaned up
const staleOperationThreshold = Duration(days: 7);
/// Maximum concurrent downloads allowed
const maxConcurrentDownloads = 3;
// ═══════════════════════════════════════════════════════════════════════════════
// 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;
const Downloading({
required super.target,
required this.progress,
required this.taskId,
});
Downloading copyWith({double? progress}) {
return Downloading(
target: target,
progress: progress ?? this.progress,
taskId: taskId,
);
}
}
/// 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;
const Verifying({required super.target, required this.filePath});
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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
class ReadyToInstall extends InstallOperation {
final String filePath;
const ReadyToInstall({required super.target, required this.filePath});
}
/// 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();
}
/// System dialog was dismissed/backgrounded - user can retry
/// Different from Failed: this is recoverable with a tap
class AwaitingUserAction extends InstallOperation {
final String filePath;
const AwaitingUserAction({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});
}
// ═══════════════════════════════════════════════════════════════════════════════
// FAILURE STATE (Dismiss available)
// ═══════════════════════════════════════════════════════════════════════════════
/// Operation failed - may be retryable depending on type
class OperationFailed extends InstallOperation {
final FailureType type;
final String message;
final String? filePath;
const OperationFailed({
required super.target,
required this.type,
required this.message,
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,
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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 Uninstalling;
/// Whether this operation is in the verification phase
bool get isVerifying => this is Verifying;
/// 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,
AwaitingUserAction(:final filePath) => filePath,
Uninstalling(:final filePath) => filePath,
OperationFailed(:final filePath) => filePath,
_ => null,
};
}
File diff suppressed because it is too large Load Diff
+52 -12
View File
@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/main.dart'; import 'package:zapstore/main.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/version_utils.dart';
/// Categorized apps state /// Categorized apps state
class CategorizedApps { class CategorizedApps {
@@ -13,24 +13,30 @@ class CategorizedApps {
required this.automaticUpdates, required this.automaticUpdates,
required this.manualUpdates, required this.manualUpdates,
required this.upToDateApps, required this.upToDateApps,
required this.uncatalogedApps,
this.isLoading = false, this.isLoading = false,
}); });
final List<App> automaticUpdates; final List<App> automaticUpdates;
final List<App> manualUpdates; final List<App> manualUpdates;
final List<App> upToDateApps; final List<App> upToDateApps;
/// Apps installed on device but not found in any relay catalog
final List<PackageInfo> uncatalogedApps;
final bool isLoading; final bool isLoading;
CategorizedApps copyWith({ CategorizedApps copyWith({
List<App>? automaticUpdates, List<App>? automaticUpdates,
List<App>? manualUpdates, List<App>? manualUpdates,
List<App>? upToDateApps, List<App>? upToDateApps,
List<PackageInfo>? uncatalogedApps,
bool? isLoading, bool? isLoading,
}) { }) {
return CategorizedApps( return CategorizedApps(
automaticUpdates: automaticUpdates ?? this.automaticUpdates, automaticUpdates: automaticUpdates ?? this.automaticUpdates,
manualUpdates: manualUpdates ?? this.manualUpdates, manualUpdates: manualUpdates ?? this.manualUpdates,
upToDateApps: upToDateApps ?? this.upToDateApps, upToDateApps: upToDateApps ?? this.upToDateApps,
uncatalogedApps: uncatalogedApps ?? this.uncatalogedApps,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
); );
} }
@@ -39,6 +45,7 @@ class CategorizedApps {
automaticUpdates: [], automaticUpdates: [],
manualUpdates: [], manualUpdates: [],
upToDateApps: [], upToDateApps: [],
uncatalogedApps: [],
isLoading: true, isLoading: true,
); );
} }
@@ -55,8 +62,9 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
} }
// Watch installed packages - this is the source of truth // Watch installed packages - this is the source of truth
final packages = ref.watch(packageManagerProvider); final pmState = ref.watch(packageManagerProvider);
final installedIds = packages.map((p) => p.appId).toSet(); final installedPackages = pmState.installed.values.toList();
final installedIds = pmState.installed.keys.toSet();
if (installedIds.isEmpty) { if (installedIds.isEmpty) {
_hasLoadedOnce = true; _hasLoadedOnce = true;
@@ -64,6 +72,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
automaticUpdates: [], automaticUpdates: [],
manualUpdates: [], manualUpdates: [],
upToDateApps: [], upToDateApps: [],
uncatalogedApps: [],
isLoading: false, isLoading: false,
); );
} }
@@ -96,34 +105,56 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
isLoading: !_hasLoadedOnce, isLoading: !_hasLoadedOnce,
), ),
StorageError() => CategorizedApps.empty.copyWith(isLoading: false), StorageError() => CategorizedApps.empty.copyWith(isLoading: false),
StorageData(:final models) => _categorize(models, packages), StorageData(:final models) => _categorize(models, installedPackages),
}; };
} }
CategorizedApps _categorize(List<App> apps, List<PackageInfo> packages) { CategorizedApps _categorize(
List<App> apps,
List<PackageInfo> installedPackages,
) {
_hasLoadedOnce = true; _hasLoadedOnce = true;
final automaticUpdates = <App>[]; final automaticUpdates = <App>[];
final manualUpdates = <App>[]; final manualUpdates = <App>[];
final upToDateApps = <App>[]; final upToDateApps = <App>[];
// Only process apps that are actually installed // Build lookup map from passed-in data (avoids separate provider reads)
final installedApps = apps.where((a) => a.installedPackage != null); final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg};
// Track ALL apps returned from relay query as "cataloged"
final catalogedAppIds = apps.map((a) => a.identifier).toSet();
// Only process apps that are actually installed (check against our map)
final installedApps = apps.where((a) => installedMap.containsKey(a.identifier));
for (final app in installedApps) { for (final app in installedApps) {
if (app.hasUpdate) { final pkg = installedMap[app.identifier]!;
// Look up silent install status from package info final latest = app.latestFileMetadata;
final pkg = packages.firstWhereOrNull((p) => p.appId == app.identifier);
if (pkg?.canInstallSilently ?? false) { // Determine if update available using local data
final hasUpdate = latest != null && _hasUpdate(pkg, latest);
if (hasUpdate) {
if (pkg.canInstallSilently) {
automaticUpdates.add(app); automaticUpdates.add(app);
} else { } else {
manualUpdates.add(app); manualUpdates.add(app);
} }
} else if (app.isUpdated) { } else {
// No update available - app is up to date
upToDateApps.add(app); upToDateApps.add(app);
} }
} }
// Find installed packages without catalog metadata
final uncatalogedApps = installedPackages
.where((pkg) => !catalogedAppIds.contains(pkg.appId))
.toList()
..sort((a, b) => (a.name ?? a.appId)
.toLowerCase()
.compareTo((b.name ?? b.appId).toLowerCase()));
int byName(App a, App b) => (a.name ?? a.identifier) int byName(App a, App b) => (a.name ?? a.identifier)
.toLowerCase() .toLowerCase()
.compareTo((b.name ?? b.identifier).toLowerCase()); .compareTo((b.name ?? b.identifier).toLowerCase());
@@ -139,10 +170,19 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
automaticUpdates: automaticUpdates, automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates, manualUpdates: manualUpdates,
upToDateApps: upToDateApps, upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
isLoading: false, isLoading: false,
); );
} }
/// Check if an update is available by comparing versions
bool _hasUpdate(PackageInfo installed, FileMetadata latest) {
if (latest.versionCode != null && installed.versionCode != null) {
return latest.versionCode! > installed.versionCode!;
}
return canUpgrade(installed.version, latest.version);
}
void _fetchAuthors(Iterable<App> apps) { void _fetchAuthors(Iterable<App> apps) {
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet(); final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
if (authorPubkeys.isEmpty) return; if (authorPubkeys.isEmpty) return;
+1 -1
View File
@@ -28,7 +28,7 @@ extension AppExt on App {
/// Returns PackageInfo if installed, otherwise null /// Returns PackageInfo if installed, otherwise null
PackageInfo? get installedPackage => PackageInfo? get installedPackage =>
ref.read(packageManagerProvider.notifier).getInfo(identifier); ref.read(packageManagerProvider).installed[identifier];
/// Whether the app is installed on the device /// Whether the app is installed on the device
bool get isInstalled => bool get isInstalled =>
+6 -6
View File
@@ -8,7 +8,7 @@ import 'package:markdown/markdown.dart' as md;
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/utils/url_utils.dart';
import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/widgets/zap_widgets.dart'; import 'package:zapstore/widgets/zap_widgets.dart';
import 'common/profile_avatar.dart'; import 'common/profile_avatar.dart';
@@ -446,9 +446,9 @@ class _AppCardUpdateButtonSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); final operation = ref.watch(installOperationProvider(app.identifier));
final hasDownload = downloadInfo != null; final hasOperation = operation != null;
final shouldShow = app.hasUpdate || hasDownload; final shouldShow = app.hasUpdate || hasOperation;
if (!shouldShow) return const SizedBox.shrink(); if (!shouldShow) return const SizedBox.shrink();
@@ -478,9 +478,9 @@ class _AppCardZapEncouragementSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final downloadInfo = ref.watch(downloadInfoProvider(app.identifier)); final operation = ref.watch(installOperationProvider(app.identifier));
final isActive = downloadInfo?.isActiveOrInstalling ?? false; final isActive = operation?.isActive ?? false;
final hasLud16 = publisher?.lud16?.trim().isNotEmpty ?? false; final hasLud16 = publisher?.lud16?.trim().isNotEmpty ?? false;
final canZap = publisher != null && hasLud16; final canZap = publisher != null && hasLud16;
final shouldShow = isActive && !app.isRelaySigned && canZap; final shouldShow = isActive && !app.isRelaySigned && canZap;
+7 -9
View File
@@ -9,7 +9,7 @@ import 'package:models/models.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/download/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
@@ -244,7 +244,9 @@ class SocialActionsRow extends HookConsumerWidget {
// Save to local storage and publish to relays // Save to local storage and publish to relays
await ref.storage.save({signedStack}); await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social')); ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) { if (context.mounted) {
context.showInfo( context.showInfo(
@@ -909,12 +911,8 @@ class DebugVersionsSection extends HookConsumerWidget {
Release release, Release release,
FileMetadata metadata, FileMetadata metadata,
) async { ) async {
// Use the download service with specific metadata // Use PackageManager to start download
final downloadService = ref.read(downloadServiceProvider.notifier); final pm = ref.read(packageManagerProvider.notifier);
await downloadService.downloadAppWithMetadata( await pm.startDownload(app.identifier, metadata, displayName: app.name);
app.identifier,
app.name ?? app.identifier,
metadata,
);
} }
} }
+6 -5
View File
@@ -19,10 +19,8 @@ class SignInPrompt extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final packageManager = ref.watch(packageManagerProvider); final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any( final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
(p) => p.appId == kAmberPackageId,
);
final isLoading = useState(false); final isLoading = useState(false);
Future<void> handleSignIn() async { Future<void> handleSignIn() async {
@@ -32,7 +30,10 @@ class SignInPrompt extends HookConsumerWidget {
context.showInfo( context.showInfo(
'Install Amber to sign in with your Nostr identity', 'Install Amber to sign in with your Nostr identity',
actions: [ actions: [
('Open Amber', () async => context.push('/search/app/$kAmberNaddr')), (
'Open Amber',
() async => context.push('/search/app/$kAmberNaddr'),
),
], ],
); );
} else { } else {
File diff suppressed because it is too large Load Diff
-172
View File
@@ -1,172 +0,0 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
/// Represents all possible button states in the install flow.
/// This state machine ensures UI consistency and prevents invalid states.
sealed class InstallButtonState {
const InstallButtonState();
}
/// App is not installed, ready to install
class ReadyToInstall extends InstallButtonState {
final bool hasRelease;
const ReadyToInstall({required this.hasRelease});
}
/// App is installed and up to date, can be opened
class InstalledUpToDate extends InstallButtonState {
const InstalledUpToDate();
}
/// App is installed, update available
class UpdateAvailable extends InstallButtonState {
final bool hasRelease;
const UpdateAvailable({required this.hasRelease});
}
/// App is installed, but relay has older version (downgrade not allowed)
class DowngradeBlocked extends InstallButtonState {
const DowngradeBlocked();
}
/// Download is in progress
class Downloading extends InstallButtonState {
final double progress;
final String? totalSizeMb;
const Downloading({required this.progress, this.totalSizeMb});
}
/// Download is paused
class DownloadPaused extends InstallButtonState {
final double progress;
final String? totalSizeMb;
const DownloadPaused({required this.progress, this.totalSizeMb});
}
/// Download is enqueued or waiting to retry
class DownloadEnqueued extends InstallButtonState {
final bool isUpdate;
const DownloadEnqueued({required this.isUpdate});
}
/// Download completed, ready to install from file
class DownloadedReadyToInstall extends InstallButtonState {
final bool isUpdate;
const DownloadedReadyToInstall({required this.isUpdate});
}
/// Certificate mismatch - force update required (uninstall + install)
class ForceUpdateRequired extends InstallButtonState {
const ForceUpdateRequired();
}
/// Installation is in progress
class Installing extends InstallButtonState {
final bool isSilent;
const Installing({this.isSilent = false});
}
/// Download or installation failed
class Failed extends InstallButtonState {
final String errorMessage;
final bool canRetryReckless;
final DownloadInfo downloadInfo;
const Failed({
required this.errorMessage,
required this.canRetryReckless,
required this.downloadInfo,
});
}
/// Determines the current button state from app, download info, and release data.
/// This is the single source of truth for button state logic.
InstallButtonState determineInstallButtonState({
required App app,
required PackageInfo? installedPackage,
required DownloadInfo? downloadInfo,
required Release? release,
required String? Function(Release?) formatTotalSizeMb,
}) {
final isInstalled = installedPackage != null;
final hasUpdate = app.hasUpdate;
final hasDowngrade = app.hasDowngrade;
final hasRelease = release != null;
final canInstallSilently = installedPackage?.canInstallSilently ?? false;
// PRIORITY 1: Check active download states first
if (downloadInfo != null) {
// Ready to install from downloaded file
if (downloadInfo.isReadyToInstall) {
// Check for certificate mismatch
if (downloadInfo.errorDetails == 'CERTIFICATE_MISMATCH') {
return const ForceUpdateRequired();
}
return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate);
}
// Installation in progress
if (downloadInfo.isInstalling) {
return Installing(isSilent: canInstallSilently);
}
// Check task status
switch (downloadInfo.status) {
case TaskStatus.running:
return Downloading(
progress: downloadInfo.progress,
totalSizeMb: formatTotalSizeMb(release),
);
case TaskStatus.paused:
return DownloadPaused(
progress: downloadInfo.progress,
totalSizeMb: formatTotalSizeMb(release),
);
case TaskStatus.enqueued:
case TaskStatus.waitingToRetry:
return DownloadEnqueued(isUpdate: isInstalled && hasUpdate);
case TaskStatus.complete:
return DownloadedReadyToInstall(isUpdate: isInstalled && hasUpdate);
case TaskStatus.failed:
final errorMessage =
downloadInfo.errorDetails ?? 'Download failed. Please try again.';
final isHashError =
errorMessage.contains('Hash verification failed') &&
!errorMessage.contains('Invalid APK file');
return Failed(
errorMessage: errorMessage,
canRetryReckless: isHashError,
downloadInfo: downloadInfo,
);
case TaskStatus.canceled:
case TaskStatus.notFound:
break;
}
}
// PRIORITY 2: Check installation state (no active download)
if (isInstalled) {
// Downgrade case
if (hasDowngrade) {
return const DowngradeBlocked();
}
// Update available
if (hasUpdate) {
return UpdateAvailable(hasRelease: hasRelease);
}
// Up to date
return const InstalledUpToDate();
}
// PRIORITY 3: Not installed, no active download
return ReadyToInstall(hasRelease: hasRelease);
}
+6 -5
View File
@@ -22,10 +22,8 @@ class SignInButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final packageManager = ref.watch(packageManagerProvider); final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any( final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
(p) => p.appId == kAmberPackageId,
);
return AsyncButtonBuilder( return AsyncButtonBuilder(
onPressed: () async { onPressed: () async {
@@ -33,7 +31,10 @@ class SignInButton extends ConsumerWidget {
context.showInfo( context.showInfo(
'Install Amber to sign in with your Nostr identity', 'Install Amber to sign in with your Nostr identity',
actions: [ actions: [
('Open Amber', () async => context.push('/search/app/$kAmberNaddr')), (
'Open Amber',
() async => context.push('/search/app/$kAmberNaddr'),
),
], ],
); );
} else { } else {
+9 -26
View File
@@ -23,7 +23,9 @@ class VersionPillWidget extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Watch package manager state for reactivity // Watch package manager state for reactivity
final installedPackages = ref.watch(packageManagerProvider); ref.watch(
packageManagerProvider.select((s) => s.installed[app.identifier]),
);
// Resolve installed and available versions using AppExt // Resolve installed and available versions using AppExt
final installedVersion = app.installedPackage?.version; final installedVersion = app.installedPackage?.version;
@@ -38,8 +40,6 @@ class VersionPillWidget extends HookConsumerWidget {
availableVersion != null) { availableVersion != null) {
return _buildDualVersionPills( return _buildDualVersionPills(
context, context,
ref,
installedPackages,
installedVersion, installedVersion,
availableVersion, availableVersion,
isDowngrade: downgradeAvailable, isDowngrade: downgradeAvailable,
@@ -55,8 +55,6 @@ class VersionPillWidget extends HookConsumerWidget {
return _buildVersionPill( return _buildVersionPill(
context, context,
ref,
installedPackages,
version, version,
AppColors.darkPillBackground, AppColors.darkPillBackground,
Colors.white, Colors.white,
@@ -65,8 +63,6 @@ class VersionPillWidget extends HookConsumerWidget {
Widget _buildDualVersionPills( Widget _buildDualVersionPills(
BuildContext context, BuildContext context,
WidgetRef ref,
List<PackageInfo> installedPackages,
String installedVersion, String installedVersion,
String availableVersion, { String availableVersion, {
bool isDowngrade = false, bool isDowngrade = false,
@@ -74,9 +70,10 @@ class VersionPillWidget extends HookConsumerWidget {
// Get version codes // Get version codes
final installedVersionCode = app.installedPackage?.versionCode; final installedVersionCode = app.installedPackage?.versionCode;
final availableVersionCode = app.latestFileMetadata?.versionCode; final availableVersionCode = app.latestFileMetadata?.versionCode;
// When version strings are equal but version codes differ, show version codes in parentheses // When version strings are equal but version codes differ, show version codes in parentheses
final showVersionCodes = installedVersion == availableVersion && final showVersionCodes =
installedVersion == availableVersion &&
installedVersionCode != null && installedVersionCode != null &&
availableVersionCode != null && availableVersionCode != null &&
installedVersionCode != availableVersionCode; installedVersionCode != availableVersionCode;
@@ -88,8 +85,6 @@ class VersionPillWidget extends HookConsumerWidget {
Flexible( Flexible(
child: _buildVersionPill( child: _buildVersionPill(
context, context,
ref,
installedPackages,
installedVersion, installedVersion,
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
Theme.of(context).colorScheme.onSurface, Theme.of(context).colorScheme.onSurface,
@@ -98,7 +93,7 @@ class VersionPillWidget extends HookConsumerWidget {
), ),
), ),
// Arrow icon (always arrow, forbidden icon is in the pill itself) // Arrow icon
Icon( Icon(
Icons.arrow_right, Icons.arrow_right,
size: 16, size: 16,
@@ -109,8 +104,6 @@ class VersionPillWidget extends HookConsumerWidget {
Flexible( Flexible(
child: _buildVersionPill( child: _buildVersionPill(
context, context,
ref,
installedPackages,
availableVersion, availableVersion,
isDowngrade isDowngrade
? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3) ? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)
@@ -128,8 +121,6 @@ class VersionPillWidget extends HookConsumerWidget {
Widget _buildVersionPill( Widget _buildVersionPill(
BuildContext context, BuildContext context,
WidgetRef ref,
List<PackageInfo> installedPackages,
String version, String version,
Color backgroundColor, Color backgroundColor,
Color textColor, { Color textColor, {
@@ -168,16 +159,12 @@ class VersionPillWidget extends HookConsumerWidget {
} }
} }
// Use grey colors for "do not disturb" states (downgrade, certificate mismatch)
final finalBackgroundColor = backgroundColor;
final finalTextColor = textColor;
final double verticalPadding = isInstalledVersion ? 5.0 * 1.05 : 5.0; final double verticalPadding = isInstalledVersion ? 5.0 * 1.05 : 5.0;
return Container( return Container(
padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding), padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: finalBackgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -190,7 +177,7 @@ class VersionPillWidget extends HookConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith( style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: finalTextColor, color: textColor,
), ),
), ),
), ),
@@ -207,13 +194,9 @@ class VersionPillWidget extends HookConsumerWidget {
} }
String _displayVersion(String version, {int? versionCode}) { String _displayVersion(String version, {int? versionCode}) {
// Show full version - let Flexible + TextOverflow.ellipsis handle truncation only when needed
// If versionCode is provided, append it in parentheses
if (versionCode != null) { if (versionCode != null) {
return '$version ($versionCode)'; return '$version ($versionCode)';
} }
return version; return version;
} }
// No width enforcement; both pills share the same text style
} }