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 {
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) {
super.configureFlutterEngine(flutterEngine)
// Register the unified package manager plugin
// Register plugins
flutterEngine.plugins.add(AndroidPackageManagerPlugin())
flutterEngine.plugins.add(AppRestartPlugin())
}
File diff suppressed because it is too large Load Diff
@@ -1,30 +1,22 @@
package dev.zapstore.alpha.plugins
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
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
* AndroidPackageManagerPlugin, allowing Dart's await to finish.
* Translates Android PackageInstaller status codes to simple status events
* that are emitted to Dart via EventChannel.
*
* For STATUS_PENDING_USER_ACTION (installer takeover prompt):
* - If app is in foreground: launch confirmation dialog immediately
* - 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.
* This receiver is stateless - it simply maps broadcasts to events.
* All state management happens in AndroidPackageManagerPlugin.
*/
class InstallResultReceiver : BroadcastReceiver() {
@@ -32,174 +24,154 @@ class InstallResultReceiver : BroadcastReceiver() {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
?: intent.getStringExtra("packageName")
?: "unknown"
?: ""
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
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) {
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
val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
}
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.
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.PENDING_USER_ACTION,
packageName = packageName,
isUpdate = isUpdate,
confirmIntent = confirmIntent
)
}
PackageInstaller.STATUS_SUCCESS -> {
Log.d(TAG, "Installation successful: $packageName (sessionId=$sessionId)")
// Complete the pending method channel result
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to true,
"errorMessage" to "",
"packageName" to packageName,
"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)
Log.d(TAG, "Installation successful: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.SUCCESS,
packageName = packageName,
isUpdate = isUpdate
)
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
Log.d(TAG, "Installation cancelled: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to "Installation was cancelled by user",
"packageName" to packageName,
"cancelled" to true
), context)
Log.d(TAG, "Installation cancelled: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.CANCELLED,
packageName = packageName,
message = "Installation was cancelled"
)
}
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 -> {
Log.w(TAG, "Installation blocked: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Installation blocked by device policy"),
"packageName" to packageName
), context)
Log.w(TAG, "Installation blocked: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Installation blocked by device policy",
errorCode = ErrorCode.BLOCKED
)
}
PackageInstaller.STATUS_FAILURE_CONFLICT -> {
Log.w(TAG, "Installation conflict: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Installation conflicts with existing package"),
"packageName" to packageName
), context)
Log.w(TAG, "Installation conflict: $packageName")
// Conflict typically means signature mismatch
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Installation conflicts with existing package",
errorCode = ErrorCode.CERT_MISMATCH
)
}
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> {
Log.w(TAG, "Installation incompatible: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Package is incompatible with this device"),
"packageName" to packageName
), context)
Log.w(TAG, "Incompatible package: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Package is incompatible with this device",
errorCode = ErrorCode.INCOMPATIBLE
)
}
PackageInstaller.STATUS_FAILURE_INVALID -> {
Log.w(TAG, "Installation invalid: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Invalid installation package"),
"packageName" to packageName
), context)
Log.w(TAG, "Invalid package: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Invalid installation package",
errorCode = ErrorCode.INVALID_FILE
)
}
PackageInstaller.STATUS_FAILURE_STORAGE -> {
Log.w(TAG, "Installation failed - storage: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Insufficient storage space"),
"packageName" to packageName
), context)
Log.w(TAG, "Storage failure: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Insufficient storage space",
errorCode = ErrorCode.INSUFFICIENT_STORAGE
)
}
else -> {
Log.w(TAG, "Unknown installation status $status: $packageName (sessionId=$sessionId)")
AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf(
"isSuccess" to false,
"errorMessage" to (message ?: "Installation failed with status $status"),
"packageName" to packageName
), context)
}
}
}
private fun showUserActionNotification(
context: Context,
sessionId: Int,
confirmIntent: Intent,
packageName: String
) {
// Check notification permission on Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
return
}
}
val notificationManager = context.getSystemService<NotificationManager>()
if (notificationManager?.areNotificationsEnabled() == true) {
val pendingIntent = PendingIntent.getActivity(
context,
sessionId,
confirmIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
Log.w(TAG, "Unknown status $status: $packageName")
AndroidPackageManagerPlugin.onInstallResult(
sessionId = sessionId,
status = InstallStatus.FAILED,
packageName = packageName,
message = message ?: "Installation failed (status: $status)",
errorCode = ErrorCode.INSTALL_FAILED
)
}
}
}
val notification = NotificationCompat.Builder(context, "install_channel")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Installation requires confirmation")
.setContentText("Tap to continue installing $packageName")
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
/**
* Categorize a generic failure message to determine the error code.
*/
private fun categorizeFailure(message: String?): String {
if (message == null) return ErrorCode.INSTALL_FAILED
val lower = message.lowercase()
return when {
lower.contains("signature") ||
lower.contains("certificate") ||
lower.contains("update_incompatible") -> ErrorCode.CERT_MISMATCH
lower.contains("storage") || lower.contains("space") -> ErrorCode.INSUFFICIENT_STORAGE
lower.contains("permission") || lower.contains("denied") -> ErrorCode.PERMISSION_DENIED
else -> ErrorCode.INSTALL_FAILED
}
}
try {
notificationManager.notify(sessionId, notification)
} catch (e: Exception) {
Log.w(TAG, "Failed to show notification", e)
}
/**
* Extract the confirmation dialog intent from a PENDING_USER_ACTION broadcast.
*/
private fun extractConfirmIntent(intent: Intent): Intent? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
}
}
+27 -15
View File
@@ -12,7 +12,6 @@ import 'package:purplebase/purplebase.dart';
import 'package:amber_signer/amber_signer.dart';
import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/background_update_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/router.dart';
import 'package:zapstore/services/package_manager/package_manager.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 {
_AppLifecycleObserver(this._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
void didChangeAppLifecycleState(AppLifecycleState state) {
final downloadService = _ref.read(downloadServiceProvider.notifier);
final notifier =
_ref.read(storageNotifierProvider.notifier)
as PurplebaseStorageNotifier;
final packageManager = _ref.read(packageManagerProvider.notifier);
if (state == AppLifecycleState.resumed) {
// Process any pending installations
unawaited(downloadService.setAppForeground(true));
// Sync installed packages to detect installs that completed while backgrounded
unawaited(packageManager.syncInstalledPackages());
// Re-sync installed packages from Android system
unawaited(
_ref.read(packageManagerProvider.notifier).syncInstalledPackages(),
);
// Note: UpdateNotifier automatically triggers checkForUpdates() when
// packageManagerProvider state changes, so we don't need to call it explicitly
// Check for permission grants that happened in settings
unawaited(_checkPermissionGrants());
// Force immediate health check on storage/relay connections
// This detects stale connections after system sleep and triggers reconnection
// Reconnect storage/relay connections
notifier.connect();
} else if (state == AppLifecycleState.paused) {
// Note: paused corresponds to Android's `onStop`
unawaited(downloadService.setAppForeground(false));
notifier.disconnect();
}
}
+6 -6
View File
@@ -140,10 +140,9 @@ class _AppDetailContent extends HookConsumerWidget {
final latestMetadata = app.latestFileMetadata;
// Check if app is installed for menu options
final installedPackage = ref
.watch(packageManagerProvider)
.where((p) => p.appId == app.identifier)
.firstOrNull;
final installedPackage = ref.watch(
installedPackageProvider(app.identifier),
);
final isInstalled = installedPackage != null;
// Show skeleton while relationships are loading
@@ -634,7 +633,9 @@ class _AppDetailContent extends HookConsumerWidget {
// Save to local storage and publish to relays
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) {
context.showInfo(
@@ -675,7 +676,6 @@ class _AppDetailContent extends HookConsumerWidget {
Future<void> _openApp(BuildContext context, WidgetRef ref, App app) async {
try {
final packageManager = ref.read(packageManagerProvider.notifier);
context.showInfo('Launching ${app.name ?? app.identifier}...');
await packageManager.launchApp(app.identifier);
} catch (e) {
if (!context.mounted) return;
+7 -17
View File
@@ -218,10 +218,8 @@ class _AuthenticationSection extends ConsumerWidget {
}
Widget _buildSignInOptions(BuildContext context, WidgetRef ref) {
final packageManager = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any(
(p) => p.appId == kAmberPackageId,
);
final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1248,23 +1246,18 @@ class _SavedAppsList extends ConsumerWidget {
class _AboutSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final manager = ref.watch(packageManagerProvider);
final zsPackage = manager.firstWhereOrNull(
(i) => i.appId == kZapstoreAppIdentifier,
);
final pmState = ref.watch(packageManagerProvider);
final zsPackage = pmState.installed[kZapstoreAppIdentifier];
if (zsPackage == null) {
final isLoading = manager.isEmpty;
final isLoading = pmState.installed.isEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About',
style: Theme.of(context).textTheme.titleMedium,
),
Text('About', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
if (isLoading) ...[
Row(
@@ -1318,10 +1311,7 @@ class _AboutSection extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About',
style: Theme.of(context).textTheme.titleMedium,
),
Text('About', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
ListTile(
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:hooks_riverpod/hooks_riverpod.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/theme.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -18,9 +18,7 @@ class UpdatesScreen extends HookConsumerWidget {
final categorized = ref.watch(categorizedAppsProvider);
if (categorized.isLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
@@ -43,13 +41,16 @@ enum _UpdatesItemType {
manualApp,
upToDateHeader,
upToDateApp,
uncatalogedHeader,
uncatalogedApp,
}
class _UpdatesItem {
final _UpdatesItemType type;
final App? app;
final PackageInfo? packageInfo;
const _UpdatesItem(this.type, [this.app]);
const _UpdatesItem(this.type, {this.app, this.packageInfo});
}
class _UpdatesContent extends HookConsumerWidget {
@@ -62,26 +63,33 @@ class _UpdatesContent extends HookConsumerWidget {
final automaticUpdates = categorized.automaticUpdates;
final manualUpdates = categorized.manualUpdates;
final upToDateApps = categorized.upToDateApps;
final uncatalogedApps = categorized.uncatalogedApps;
final downloads = ref.watch(downloadServiceProvider);
final installingAppIds = downloads.entries
.where((entry) => entry.value.isActiveOrInstalling)
// Watch operations from PackageManager
final operations = ref.watch(
packageManagerProvider.select((s) => s.operations),
);
final activeAppIds = operations.entries
.where((entry) => entry.value.isActive)
.map((entry) => entry.key)
.toSet();
if (installingAppIds.isEmpty) {
if (activeAppIds.isEmpty) {
return _UpdatesListBody(
automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates,
installingApps: const [],
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
);
}
return _UpdatesListBodyWithInstallingAppIds(
installingAppIds: installingAppIds,
installingAppIds: activeAppIds,
automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates,
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
);
}
}
@@ -92,12 +100,14 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
required this.automaticUpdates,
required this.manualUpdates,
required this.upToDateApps,
required this.uncatalogedApps,
});
final Set<String> installingAppIds;
final List<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> upToDateApps;
final List<PackageInfo> uncatalogedApps;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -128,6 +138,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
manualUpdates: manualUpdates,
installingApps: installingApps,
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
);
}
}
@@ -138,19 +149,22 @@ class _UpdatesListBody extends StatelessWidget {
required this.manualUpdates,
required this.installingApps,
required this.upToDateApps,
required this.uncatalogedApps,
});
final List<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> installingApps;
final List<App> upToDateApps;
final List<PackageInfo> uncatalogedApps;
@override
Widget build(BuildContext context) {
if (automaticUpdates.isEmpty &&
manualUpdates.isEmpty &&
installingApps.isEmpty &&
upToDateApps.isEmpty) {
upToDateApps.isEmpty &&
uncatalogedApps.isEmpty) {
final theme = Theme.of(context);
return Center(
@@ -209,14 +223,14 @@ class _UpdatesListBody extends StatelessWidget {
if (installingApps.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.installingHeader));
for (final app in installingApps) {
items.add(_UpdatesItem(_UpdatesItemType.installingApp, app));
items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app));
}
}
if (automaticUpdates.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader));
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.manualInfoBox));
for (final app in manualUpdates) {
items.add(_UpdatesItem(_UpdatesItemType.manualApp, app));
items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app));
}
}
if (upToDateApps.isNotEmpty) {
items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader));
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(
key: const PageStorageKey<String>('updates_list'),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -244,6 +268,7 @@ class _UpdatesListBody extends StatelessWidget {
return _InstallingHeader(count: installingApps.length);
case _UpdatesItemType.installingApp:
return AppCard(
key: ValueKey('installing_${item.app?.identifier}'),
app: item.app,
showUpdateArrow: false,
showUpdateButton: true,
@@ -259,6 +284,7 @@ class _UpdatesListBody extends StatelessWidget {
);
case _UpdatesItemType.automaticApp:
return AppCard(
key: ValueKey('automatic_${item.app?.identifier}'),
app: item.app,
showUpdateArrow: true,
showUpdateButton: true,
@@ -276,6 +302,7 @@ class _UpdatesListBody extends StatelessWidget {
return _ManualUpdatesInfoBox();
case _UpdatesItemType.manualApp:
return AppCard(
key: ValueKey('manual_${item.app?.identifier}'),
app: item.app,
showUpdateArrow: true,
showUpdateButton: true,
@@ -286,10 +313,18 @@ class _UpdatesListBody extends StatelessWidget {
return _UpToDateHeader(count: upToDateApps.length);
case _UpdatesItemType.upToDateApp:
return AppCard(
key: ValueKey('uptodate_${item.app?.identifier}'),
app: item.app,
showUpdateArrow: 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: [
Icon(Icons.downloading, size: 20, color: AppColors.darkActionPrimary),
const SizedBox(width: 8),
Text(
'Installing',
style: context.textTheme.titleMedium,
),
Text('Installing', style: context.textTheme.titleMedium),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground),
],
@@ -350,10 +382,7 @@ class _UpdatesSectionHeader extends ConsumerWidget {
Icon(icon, size: 20, color: AppColors.darkActionPrimary),
const SizedBox(width: 8),
Flexible(
child: Text(
title,
style: context.textTheme.titleMedium,
),
child: Text(title, style: context.textTheme.titleMedium),
),
const SizedBox(width: 8),
CountBadge(count: count, color: AppColors.darkPillBackground),
@@ -361,19 +390,18 @@ class _UpdatesSectionHeader extends ConsumerWidget {
),
AsyncButtonBuilder(
onPressed: () async {
final downloadService = ref.read(
downloadServiceProvider.notifier,
);
for (final app in apps) {
final release = app.latestRelease.value;
if (release != null) {
try {
await downloadService.downloadApp(app, release);
} catch (_) {
// Ignore download start failures per-app to keep others going
}
}
}
final pm = ref.read(packageManagerProvider.notifier);
final items = apps
.where((app) => app.latestFileMetadata != null)
.map(
(app) => (
appId: app.identifier,
target: app.latestFileMetadata!,
displayName: app.name,
),
)
.toList();
await pm.queueDownloads(items);
},
builder: (context, child, callback, buttonState) {
const pillBg = AppColors.darkPillBackground;
@@ -475,13 +503,14 @@ class _UpToDateHeader extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.check_circle, size: 20, color: AppColors.darkActionPrimary),
const SizedBox(width: 8),
Text(
'Up to date',
style: context.textTheme.titleMedium,
Icon(
Icons.check_circle,
size: 20,
color: AppColors.darkActionPrimary,
),
const SizedBox(width: 8),
Text('Up to date', style: context.textTheme.titleMedium),
const SizedBox(width: 8),
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);
await packageManager.syncInstalledPackages();
final packages = container.read(packageManagerProvider);
if (packages.isEmpty) {
final pmState = container.read(packageManagerProvider);
if (pmState.installed.isEmpty) {
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
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 'package:flutter/foundation.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';
/// Android implementation of PackageManager with integrated installation.
final class AndroidPackageManager extends PackageManager {
AndroidPackageManager(super.ref);
/// Install status values from native side.
///
/// 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;
int _syncGeneration = 0;
StreamSubscription<dynamic>? _eventSubscription;
@override
String get platform => 'android-arm64-v8a';
@@ -19,80 +73,404 @@ final class AndroidPackageManager extends PackageManager {
@override
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
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
bool skipVerification = false,
required FileMetadata target,
}) async {
await _ensureInstallPermission();
// Permission is already checked by _proceedToInstall in base class
// No need for redundant _ensureInstallPermission() call here
final file = File(filePath);
if (!file.existsSync()) {
throw Exception('APK file not found: $filePath');
}
final result = await _channel
// Native side is the source of truth for install state; it will emit
// VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events.
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('install', {
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'skipVerification': skipVerification,
});
})
.timeout(const Duration(seconds: 30), onTimeout: () => null);
final resultMap = Map<String, dynamic>.from(result ?? {});
// If installation is already in progress, do NOT treat this call as success.
// 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) {
throw Exception('INSTALL_ALREADY_IN_PROGRESS');
// Native is stuck thinking an install is 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['isSuccess'] == true)) {
final error = resultMap['errorMessage'] ?? 'Installation failed';
throw Exception(error);
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
}
// Refresh installed packages state
await syncInstalledPackages();
}
@override
Future<void> uninstall(String appId) async {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'uninstall',
{'packageName': appId},
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('uninstall', {
'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 ?? {});
if (!(resultMap['isSuccess'] == true)) {
final wasCancelled = resultMap['cancelled'] == true;
if (wasCancelled) {
if (resultMap['isSuccess'] != true) {
if (resultMap['cancelled'] == true) {
throw Exception('Uninstall was cancelled');
}
throw Exception('Uninstallation failed');
throw Exception(resultMap['errorMessage'] ?? 'Uninstallation failed');
}
// Refresh installed packages state
await syncInstalledPackages();
// Remove from installed immediately (don't wait for sync)
state = state.copyWith(installed: Map.from(state.installed)..remove(appId));
// Background sync for consistency
syncInstalledPackages();
}
// ═══════════════════════════════════════════════════════════════════════════
// PERMISSIONS
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> requestPermission() async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'requestInstallPermission',
final result = await _methodChannel
.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 ?? {});
if (!(resultMap['success'] == true)) {
final error = resultMap['message'] ?? 'Failed to request permission';
throw Exception(error);
if (resultMap['success'] != true) {
throw Exception(resultMap['message'] ?? 'Failed to request permission');
}
} catch (e) {
throw Exception(
@@ -109,47 +487,23 @@ final class AndroidPackageManager extends PackageManager {
@override
Future<bool> hasPermission() async {
try {
final hasUnknownSources =
await _channel.invokeMethod<bool>('hasUnknownSourcesPermission') ??
return await _methodChannel
.invokeMethod<bool>('hasUnknownSourcesPermission')
.timeout(const Duration(seconds: 5), onTimeout: () => false) ??
false;
return hasUnknownSources;
} catch (e) {
} catch (_) {
return false;
}
}
@override
bool get supportsSilentInstall {
// Cached capability determined asynchronously via plugin
return _supportsSilentInstall;
}
// ═══════════════════════════════════════════════════════════════════════════
// APP LAUNCH
// ═══════════════════════════════════════════════════════════════════════════
/// 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
Future<void> launchApp(String appId) async {
try {
final result = await _channel
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('launchApp', {
'packageName': appId,
})
@@ -157,143 +511,141 @@ final class AndroidPackageManager extends PackageManager {
const Duration(seconds: 10),
onTimeout: () => {
'isSuccess': false,
'errorMessage': 'App launch timed out after 10 seconds',
'errorMessage': 'App launch timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
if (!(resultMap['isSuccess'] == true)) {
final error = resultMap['errorMessage'] ?? 'Failed to launch app';
throw Exception(error);
if (resultMap['isSuccess'] != true) {
throw Exception(resultMap['errorMessage'] ?? 'Failed to launch app');
}
} catch (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.
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
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC INSTALLED PACKAGES
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> syncInstalledPackages() async {
final syncGen = ++_syncGeneration;
try {
// Check and cache silent install capability (general check)
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
// Single native call - getInstalledApps already returns canInstallSilently per-app
final installedApps =
await _channel.invokeMethod<List<Object?>>('getInstalledApps', {
await _methodChannel
.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) {
final app = Map<String, dynamic>.from(appObj as Map<Object?, Object?>);
final appId =
app['bundleId'] as String? ?? app['packageName'] as String? ?? '';
final name = app['name'] as String?;
final version = app['versionName'] as String? ?? '0.0.0';
final versionCode = app['versionCode'] as int?;
final signatureHash = app['signatureHash'] as String? ?? '';
final canInstallSilently = app['canInstallSilently'] as bool? ?? false;
if (canInstallSilently) anyCanInstallSilently = true;
if (appId.isNotEmpty) {
packages.add(
PackageInfo(
packages[appId] = PackageInfo(
appId: appId,
name: name,
version: version,
versionCode: versionCode,
installTime: null, // Method channel doesn't provide install time
signatureHash: signatureHash,
installTime: null,
canInstallSilently: canInstallSilently,
),
);
}
}
state = packages;
} catch (e) {
// Fallback to empty state on error
state = [];
}
}
if (syncGen != _syncGeneration) return;
Future<void> _ensureInstallPermission() async {
if (!Platform.isAndroid) return;
// Derive general silent install capability from per-app data
_supportsSilentInstall = anyCanInstallSilently;
try {
final status = await Permission.requestInstallPackages.status;
if (status.isGranted) {
return;
}
final newStatus = await Permission.requestInstallPackages.request();
if (!newStatus.isGranted) {
throw Exception(
'Install permission required. Please enable "Install unknown apps" '
'for Zapstore and try again.',
// Preserve any packages we've directly updated (they have installTime set)
// This prevents race conditions where sync returns stale data
final preserved = <String, PackageInfo>{};
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,
);
}
} catch (e) {
throw Exception('Unable to request install permission: $e');
}
}
}
final class RetryPendingInstallResult {
const RetryPendingInstallResult({
required this.hasPending,
required this.relaunched,
required this.sessionPending,
required this.promptAlreadyShown,
});
state = state.copyWith(installed: {...packages, ...preserved});
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,
// Clear operations for apps where the installed version matches the target version
// This catches installs that succeeded but we missed the event
// 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)',
);
}
final bool hasPending;
final bool relaunched;
final bool sessionPending;
final bool promptAlreadyShown;
}
} catch (_) {
// Don't clobber state on transient errors
}
}
}
@@ -1,32 +1,35 @@
import 'package:models/models.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 {
DummyPackageManager(super.ref) {
// Initialize state with mock packages
state = [
PackageInfo(
state = PackageManagerState(
installed: {
'com.example.test': const PackageInfo(
appId: 'com.example.test',
name: 'Test App',
version: '1.0.0',
versionCode: 1,
signatureHash: 'dummy_signature_1',
installTime: DateTime.now().subtract(const Duration(days: 1)),
),
PackageInfo(
'dev.zapstore.alpha': const PackageInfo(
appId: 'dev.zapstore.alpha',
name: 'Zapstore',
version: '1.0.0',
versionCode: 1,
signatureHash: 'dummy_signature_2',
installTime: DateTime.now().subtract(const Duration(hours: 12)),
),
PackageInfo(
'com.dummy.browser': const PackageInfo(
appId: 'com.dummy.browser',
name: 'Dummy Browser',
version: '2.1.0',
versionCode: 210,
signatureHash: 'dummy_signature_3',
installTime: DateTime.now().subtract(const Duration(days: 5)),
),
];
},
operations: const {},
);
}
@override
@@ -35,57 +38,58 @@ final class DummyPackageManager extends PackageManager {
@override
String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => false;
@override
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
bool skipVerification = false,
required FileMetadata target,
}) async {
// Mock implementation - just add to list if not already present
if (!state.any((p) => p.appId == appId)) {
state = [
...state,
PackageInfo(
// Mock: simulate install delay then add to installed
await Future.delayed(const Duration(milliseconds: 500));
final newInstalled = Map<String, PackageInfo>.from(state.installed);
newInstalled[appId] = PackageInfo(
appId: appId,
version: '1.0.0',
versionCode: 1,
version: target.version,
versionCode: target.versionCode,
signatureHash: 'mock_signature',
installTime: DateTime.now(),
),
];
}
);
state = state.copyWith(installed: newInstalled);
// Clear the operation since install completed
clearOperation(appId);
}
@override
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
Future<void> launchApp(String appId) async {
// Mock implementation - just simulate launching
if (!state.any((p) => p.appId == appId)) {
if (!state.installed.containsKey(appId)) {
throw Exception('App not installed: $appId');
}
// In a real implementation, this would launch the app
await Future.delayed(const Duration(milliseconds: 500));
}
@override
Future<void> requestPermission() async {
// Mock implementation - always succeeds
// Mock: always succeeds
}
@override
Future<bool> hasPermission() async {
// Mock implementation - always has permission
return true;
}
@override
bool get supportsSilentInstall => false;
Future<bool> hasPermission() async => true;
@override
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 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/version_utils.dart';
/// Categorized apps state
class CategorizedApps {
@@ -13,24 +13,30 @@ class CategorizedApps {
required this.automaticUpdates,
required this.manualUpdates,
required this.upToDateApps,
required this.uncatalogedApps,
this.isLoading = false,
});
final List<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> upToDateApps;
/// Apps installed on device but not found in any relay catalog
final List<PackageInfo> uncatalogedApps;
final bool isLoading;
CategorizedApps copyWith({
List<App>? automaticUpdates,
List<App>? manualUpdates,
List<App>? upToDateApps,
List<PackageInfo>? uncatalogedApps,
bool? isLoading,
}) {
return CategorizedApps(
automaticUpdates: automaticUpdates ?? this.automaticUpdates,
manualUpdates: manualUpdates ?? this.manualUpdates,
upToDateApps: upToDateApps ?? this.upToDateApps,
uncatalogedApps: uncatalogedApps ?? this.uncatalogedApps,
isLoading: isLoading ?? this.isLoading,
);
}
@@ -39,6 +45,7 @@ class CategorizedApps {
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
isLoading: true,
);
}
@@ -55,8 +62,9 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
}
// Watch installed packages - this is the source of truth
final packages = ref.watch(packageManagerProvider);
final installedIds = packages.map((p) => p.appId).toSet();
final pmState = ref.watch(packageManagerProvider);
final installedPackages = pmState.installed.values.toList();
final installedIds = pmState.installed.keys.toSet();
if (installedIds.isEmpty) {
_hasLoadedOnce = true;
@@ -64,6 +72,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
automaticUpdates: [],
manualUpdates: [],
upToDateApps: [],
uncatalogedApps: [],
isLoading: false,
);
}
@@ -96,34 +105,56 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
isLoading: !_hasLoadedOnce,
),
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;
final automaticUpdates = <App>[];
final manualUpdates = <App>[];
final upToDateApps = <App>[];
// Only process apps that are actually installed
final installedApps = apps.where((a) => a.installedPackage != null);
// Build lookup map from passed-in data (avoids separate provider reads)
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) {
if (app.hasUpdate) {
// Look up silent install status from package info
final pkg = packages.firstWhereOrNull((p) => p.appId == app.identifier);
if (pkg?.canInstallSilently ?? false) {
final pkg = installedMap[app.identifier]!;
final latest = app.latestFileMetadata;
// Determine if update available using local data
final hasUpdate = latest != null && _hasUpdate(pkg, latest);
if (hasUpdate) {
if (pkg.canInstallSilently) {
automaticUpdates.add(app);
} else {
manualUpdates.add(app);
}
} else if (app.isUpdated) {
} else {
// No update available - app is up to date
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)
.toLowerCase()
.compareTo((b.name ?? b.identifier).toLowerCase());
@@ -139,10 +170,19 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates,
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
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) {
final authorPubkeys = apps.map((a) => a.event.pubkey).toSet();
if (authorPubkeys.isEmpty) return;
+1 -1
View File
@@ -28,7 +28,7 @@ extension AppExt on App {
/// Returns PackageInfo if installed, otherwise null
PackageInfo? get installedPackage =>
ref.read(packageManagerProvider.notifier).getInfo(identifier);
ref.read(packageManagerProvider).installed[identifier];
/// Whether the app is installed on the device
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:zapstore/utils/extensions.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 'common/profile_avatar.dart';
@@ -446,9 +446,9 @@ class _AppCardUpdateButtonSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadInfo = ref.watch(downloadInfoProvider(app.identifier));
final hasDownload = downloadInfo != null;
final shouldShow = app.hasUpdate || hasDownload;
final operation = ref.watch(installOperationProvider(app.identifier));
final hasOperation = operation != null;
final shouldShow = app.hasUpdate || hasOperation;
if (!shouldShow) return const SizedBox.shrink();
@@ -478,9 +478,9 @@ class _AppCardZapEncouragementSection extends ConsumerWidget {
@override
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 canZap = publisher != null && hasLud16;
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:url_launcher/url_launcher.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/theme.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -244,7 +244,9 @@ class SocialActionsRow extends HookConsumerWidget {
// Save to local storage and publish to relays
await ref.storage.save({signedStack});
ref.storage.publish({signedStack}, source: RemoteSource(relays: 'social'));
ref.storage.publish({
signedStack,
}, source: RemoteSource(relays: 'social'));
if (context.mounted) {
context.showInfo(
@@ -909,12 +911,8 @@ class DebugVersionsSection extends HookConsumerWidget {
Release release,
FileMetadata metadata,
) async {
// Use the download service with specific metadata
final downloadService = ref.read(downloadServiceProvider.notifier);
await downloadService.downloadAppWithMetadata(
app.identifier,
app.name ?? app.identifier,
metadata,
);
// Use PackageManager to start download
final pm = ref.read(packageManagerProvider.notifier);
await pm.startDownload(app.identifier, metadata, displayName: app.name);
}
}
+6 -5
View File
@@ -19,10 +19,8 @@ class SignInPrompt extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final packageManager = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any(
(p) => p.appId == kAmberPackageId,
);
final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
final isLoading = useState(false);
Future<void> handleSignIn() async {
@@ -32,7 +30,10 @@ class SignInPrompt extends HookConsumerWidget {
context.showInfo(
'Install Amber to sign in with your Nostr identity',
actions: [
('Open Amber', () async => context.push('/search/app/$kAmberNaddr')),
(
'Open Amber',
() async => context.push('/search/app/$kAmberNaddr'),
),
],
);
} 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
Widget build(BuildContext context, WidgetRef ref) {
final packageManager = ref.watch(packageManagerProvider);
final isAmberInstalled = packageManager.any(
(p) => p.appId == kAmberPackageId,
);
final pmState = ref.watch(packageManagerProvider);
final isAmberInstalled = pmState.installed.containsKey(kAmberPackageId);
return AsyncButtonBuilder(
onPressed: () async {
@@ -33,7 +31,10 @@ class SignInButton extends ConsumerWidget {
context.showInfo(
'Install Amber to sign in with your Nostr identity',
actions: [
('Open Amber', () async => context.push('/search/app/$kAmberNaddr')),
(
'Open Amber',
() async => context.push('/search/app/$kAmberNaddr'),
),
],
);
} else {
+8 -25
View File
@@ -23,7 +23,9 @@ class VersionPillWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 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
final installedVersion = app.installedPackage?.version;
@@ -38,8 +40,6 @@ class VersionPillWidget extends HookConsumerWidget {
availableVersion != null) {
return _buildDualVersionPills(
context,
ref,
installedPackages,
installedVersion,
availableVersion,
isDowngrade: downgradeAvailable,
@@ -55,8 +55,6 @@ class VersionPillWidget extends HookConsumerWidget {
return _buildVersionPill(
context,
ref,
installedPackages,
version,
AppColors.darkPillBackground,
Colors.white,
@@ -65,8 +63,6 @@ class VersionPillWidget extends HookConsumerWidget {
Widget _buildDualVersionPills(
BuildContext context,
WidgetRef ref,
List<PackageInfo> installedPackages,
String installedVersion,
String availableVersion, {
bool isDowngrade = false,
@@ -76,7 +72,8 @@ class VersionPillWidget extends HookConsumerWidget {
final availableVersionCode = app.latestFileMetadata?.versionCode;
// When version strings are equal but version codes differ, show version codes in parentheses
final showVersionCodes = installedVersion == availableVersion &&
final showVersionCodes =
installedVersion == availableVersion &&
installedVersionCode != null &&
availableVersionCode != null &&
installedVersionCode != availableVersionCode;
@@ -88,8 +85,6 @@ class VersionPillWidget extends HookConsumerWidget {
Flexible(
child: _buildVersionPill(
context,
ref,
installedPackages,
installedVersion,
Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
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(
Icons.arrow_right,
size: 16,
@@ -109,8 +104,6 @@ class VersionPillWidget extends HookConsumerWidget {
Flexible(
child: _buildVersionPill(
context,
ref,
installedPackages,
availableVersion,
isDowngrade
? Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)
@@ -128,8 +121,6 @@ class VersionPillWidget extends HookConsumerWidget {
Widget _buildVersionPill(
BuildContext context,
WidgetRef ref,
List<PackageInfo> installedPackages,
String version,
Color backgroundColor,
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;
return Container(
padding: EdgeInsets.symmetric(horizontal: 9, vertical: verticalPadding),
decoration: BoxDecoration(
color: finalBackgroundColor,
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -190,7 +177,7 @@ class VersionPillWidget extends HookConsumerWidget {
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
color: finalTextColor,
color: textColor,
),
),
),
@@ -207,13 +194,9 @@ class VersionPillWidget extends HookConsumerWidget {
}
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) {
return '$version ($versionCode)';
}
return version;
}
// No width enforcement; both pills share the same text style
}