mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Revamped package manager
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
+728
-853
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)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
val notificationManager = context.getSystemService<NotificationManager>()
|
||||
if (notificationManager?.areNotificationsEnabled() == true) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
sessionId,
|
||||
confirmIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
.invokeMethod<Map<Object?, Object?>>('install', {
|
||||
'filePath': filePath,
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
'skipVerification': skipVerification,
|
||||
});
|
||||
// Native side is the source of truth for install state; it will emit
|
||||
// VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events.
|
||||
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
try {
|
||||
final result = await _methodChannel
|
||||
.invokeMethod<Map<Object?, Object?>>('install', {
|
||||
'filePath': filePath,
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
})
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => null);
|
||||
|
||||
// If installation is already in progress, do NOT treat this call as success.
|
||||
// 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');
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
|
||||
if (resultMap['alreadyInProgress'] == true) {
|
||||
// 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['started'] != true) {
|
||||
final error =
|
||||
resultMap['error'] as String? ?? 'Failed to start install';
|
||||
final errorCode = resultMap['errorCode'] as String?;
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: target,
|
||||
type: _errorCodeToFailureType(errorCode, error),
|
||||
message: error,
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
// Don't auto-advance after failure
|
||||
}
|
||||
// If started, wait for events via EventChannel
|
||||
} catch (e) {
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: target,
|
||||
type: FailureType.installFailed,
|
||||
message: e.toString(),
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
// Don't auto-advance after failure
|
||||
}
|
||||
|
||||
if (!(resultMap['isSuccess'] == true)) {
|
||||
final error = resultMap['errorMessage'] ?? 'Installation failed';
|
||||
throw Exception(error);
|
||||
}
|
||||
|
||||
// Refresh installed packages state
|
||||
await syncInstalledPackages();
|
||||
}
|
||||
|
||||
@override
|
||||
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', {
|
||||
'includeSystemApps': false,
|
||||
}) ??
|
||||
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(
|
||||
appId: appId,
|
||||
version: version,
|
||||
versionCode: versionCode,
|
||||
installTime: null, // Method channel doesn't provide install time
|
||||
signatureHash: signatureHash,
|
||||
canInstallSilently: canInstallSilently,
|
||||
),
|
||||
packages[appId] = PackageInfo(
|
||||
appId: appId,
|
||||
name: name,
|
||||
version: version,
|
||||
versionCode: versionCode,
|
||||
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;
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final newStatus = await Permission.requestInstallPackages.request();
|
||||
if (!newStatus.isGranted) {
|
||||
throw Exception(
|
||||
'Install permission required. Please enable "Install unknown apps" '
|
||||
'for Zapstore and try again.',
|
||||
);
|
||||
state = state.copyWith(installed: {...packages, ...preserved});
|
||||
|
||||
// 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)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unable to request install permission: $e');
|
||||
} catch (_) {
|
||||
// Don't clobber state on transient errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RetryPendingInstallResult {
|
||||
const RetryPendingInstallResult({
|
||||
required this.hasPending,
|
||||
required this.relaunched,
|
||||
required this.sessionPending,
|
||||
required this.promptAlreadyShown,
|
||||
});
|
||||
|
||||
factory RetryPendingInstallResult.fromMap(Map<String, dynamic> map) {
|
||||
return RetryPendingInstallResult(
|
||||
hasPending: map['hasPending'] == true,
|
||||
relaunched: map['relaunched'] == true,
|
||||
sessionPending: map['sessionPending'] == true,
|
||||
promptAlreadyShown: map['promptAlreadyShown'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
final bool hasPending;
|
||||
final bool relaunched;
|
||||
final bool sessionPending;
|
||||
final bool promptAlreadyShown;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
|
||||
/// 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(
|
||||
appId: 'com.example.test',
|
||||
version: '1.0.0',
|
||||
versionCode: 1,
|
||||
signatureHash: 'dummy_signature_1',
|
||||
installTime: DateTime.now().subtract(const Duration(days: 1)),
|
||||
),
|
||||
PackageInfo(
|
||||
appId: 'dev.zapstore.alpha',
|
||||
version: '1.0.0',
|
||||
versionCode: 1,
|
||||
signatureHash: 'dummy_signature_2',
|
||||
installTime: DateTime.now().subtract(const Duration(hours: 12)),
|
||||
),
|
||||
PackageInfo(
|
||||
appId: 'com.dummy.browser',
|
||||
version: '2.1.0',
|
||||
versionCode: 210,
|
||||
signatureHash: 'dummy_signature_3',
|
||||
installTime: DateTime.now().subtract(const Duration(days: 5)),
|
||||
),
|
||||
];
|
||||
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',
|
||||
),
|
||||
'dev.zapstore.alpha': const PackageInfo(
|
||||
appId: 'dev.zapstore.alpha',
|
||||
name: 'Zapstore',
|
||||
version: '1.0.0',
|
||||
versionCode: 1,
|
||||
signatureHash: 'dummy_signature_2',
|
||||
),
|
||||
'com.dummy.browser': const PackageInfo(
|
||||
appId: 'com.dummy.browser',
|
||||
name: 'Dummy Browser',
|
||||
version: '2.1.0',
|
||||
versionCode: 210,
|
||||
signatureHash: 'dummy_signature_3',
|
||||
),
|
||||
},
|
||||
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(
|
||||
appId: appId,
|
||||
version: '1.0.0',
|
||||
versionCode: 1,
|
||||
signatureHash: 'mock_signature',
|
||||
installTime: DateTime.now(),
|
||||
),
|
||||
];
|
||||
}
|
||||
// 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: 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
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+481
-576
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -74,9 +70,10 @@ class VersionPillWidget extends HookConsumerWidget {
|
||||
// Get version codes
|
||||
final installedVersionCode = app.installedPackage?.versionCode;
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user