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