diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 126e95e..f555166 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -47,10 +47,28 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { private lateinit var context: Context companion object { - private var staticChannel: MethodChannel? = null + // Store pending install results keyed by sessionId - completed when broadcast arrives + private val pendingInstallResults = mutableMapOf() - fun notifyInstallResult(result: Map) { - staticChannel?.invokeMethod("onInstallResult", result) + /** + * Called by InstallResultReceiver when installation completes/fails. + * Completes the pending method channel result so Dart await finishes. + */ + fun completeInstallResult(sessionId: Int, resultMap: Map) { + val result = pendingInstallResults.remove(sessionId) + if (result != null) { + Log.d(TAG, "Completing install result for session $sessionId: $resultMap") + result.success(resultMap) + } else { + Log.w(TAG, "No pending result found for session $sessionId") + } + } + + /** + * Check if we have a pending result for this session (used by receiver) + */ + fun hasPendingResult(sessionId: Int): Boolean { + return pendingInstallResults.containsKey(sessionId) } } @@ -58,14 +76,14 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_package_manager") channel.setMethodCallHandler(this) context = flutterPluginBinding.applicationContext - staticChannel = channel Log.d(TAG, "AndroidPackageManagerPlugin initialized") } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) - staticChannel = null + // Clear any pending results on detach + pendingInstallResults.clear() } override fun onMethodCall(call: MethodCall, result: Result) { @@ -454,27 +472,30 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { sessionStream.close() } + // Store pending result BEFORE committing - will be completed by InstallResultReceiver + pendingInstallResults[sessionId] = result + Log.d(TAG, "Stored pending result for session $sessionId, awaiting broadcast...") + // Create pending intent for installation result - val intent = Intent(context.applicationContext, InstallResultReceiver::class.java) + val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { + putExtra("sessionId", sessionId) + putExtra("packageName", packageName) + putExtra("isUpdate", isUpdate) + } val pendingIntent = PendingIntent.getBroadcast( context.applicationContext, - 0, + sessionId, // Use sessionId as requestCode for uniqueness intent, - PendingIntent.FLAG_MUTABLE + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) - // Commit the session + // Commit the session - result will be delivered via broadcast to InstallResultReceiver + // which will call completeInstallResult() to finish the method channel call session.commit(pendingIntent.intentSender) session.close() - // Return success - actual result will come via broadcast - result.success(mapOf( - "isSuccess" to true, - "errorMessage" to "", - "isUpdate" to isUpdate, - "packageName" to packageName, - "sessionId" to sessionId - )) + // DO NOT call result.success() here - wait for InstallResultReceiver broadcast + // The Dart side will block on await until completeInstallResult() is called } private fun launchSystemInstaller(filePath: String, result: Result) { @@ -522,11 +543,55 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { context.startActivity(intent) - result.success(mapOf( - "isSuccess" to true, - "errorMessage" to "", - "packageName" to packageName - )) + // Poll for package removal since ACTION_DELETE has no callback + // This runs on a background thread to avoid blocking the main thread + Thread { + val timeoutMs = 120_000L // 2 minute timeout for user to confirm + val pollIntervalMs = 500L + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < timeoutMs) { + try { + Thread.sleep(pollIntervalMs) + + // Check if package is still installed + try { + pm.getPackageInfo(packageName, 0) + // Package still exists, keep polling + } catch (e: PackageManager.NameNotFoundException) { + // Package removed! Success + Log.d(TAG, "Package $packageName successfully uninstalled") + android.os.Handler(context.mainLooper).post { + result.success(mapOf( + "isSuccess" to true, + "packageName" to packageName + )) + } + return@Thread + } + } catch (e: InterruptedException) { + break + } + } + + // Timeout - check one more time then treat as cancelled + val stillInstalled = try { + pm.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + + android.os.Handler(context.mainLooper).post { + result.success(mapOf( + "isSuccess" to !stillInstalled, + "packageName" to packageName, + "cancelled" to stillInstalled + )) + } + }.start() + + // DO NOT call result.success() here - wait for polling to complete } catch (e: Exception) { Log.e(TAG, "Uninstall failed for $packageName", e) @@ -604,14 +669,56 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { pkg.versionCode.toLong() } + // Get signature hash + val signatureHash = try { + getSignatureHash(bundleId) + } catch (e: Exception) { + "" + } + out.add(mapOf( "name" to name, "bundleId" to bundleId, "versionName" to versionName, "versionCode" to versionCode, + "signatureHash" to signatureHash, )) } return out } + + private fun getSignatureHash(packageName: String): String { + val pm = context.packageManager + val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo( + packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()) + ) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES) + } + + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.signingInfo?.let { signingInfo -> + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } ?: emptyArray() + } else { + @Suppress("DEPRECATION") + packageInfo.signatures ?: emptyArray() + } + + if (signatures.isEmpty()) return "" + + // Get SHA-256 hash of first signature + val signature = signatures[0] + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(signature.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } + } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt index dba1063..95205bd 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt @@ -14,24 +14,33 @@ import androidx.core.content.getSystemService private const val TAG = "InstallResultReceiver" /** - * BroadcastReceiver to handle APK installation results - * Enhanced with Accrescent-inspired silent install handling + * BroadcastReceiver to handle APK installation results. + * + * This receiver completes the pending method channel Result stored in + * AndroidPackageManagerPlugin, allowing Dart's await to finish. + * + * For STATUS_PENDING_USER_ACTION (installer takeover prompt), we launch the + * confirmation dialog but DON'T complete the Result yet - we wait for the + * actual success/failure that follows after user confirms/cancels. */ class InstallResultReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) - val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) ?: "unknown" + val packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + ?: intent.getStringExtra("packageName") + ?: "unknown" val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1) + val isUpdate = intent.getBooleanExtra("isUpdate", false) - Log.d(TAG, "Install result: status=$status, package=$packageName, message=$message") + Log.d(TAG, "Install result: status=$status, package=$packageName, sessionId=$sessionId, message=$message") when (status) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { Log.d(TAG, "User confirmation required for $packageName (installer takeover)") - // Launch confirmation dialog + // Launch confirmation dialog - user needs to approve val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) } else { @@ -48,52 +57,47 @@ class InstallResultReceiver : BroadcastReceiver() { } } - AndroidPackageManagerPlugin.notifyInstallResult(mapOf( - "success" to false, - "packageName" to packageName, - "message" to "User action required", - "status" to status, - "requiresUserAction" to 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 -> { - Log.d(TAG, "Installation successful: $packageName") - AndroidPackageManagerPlugin.notifyInstallResult(mapOf( - "success" to true, + Log.d(TAG, "Installation successful: $packageName (sessionId=$sessionId)") + // Complete the pending method channel result + AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( + "isSuccess" to true, + "errorMessage" to "", "packageName" to packageName, - "message" to (message ?: "Installation completed successfully") + "isUpdate" to isUpdate )) } PackageInstaller.STATUS_FAILURE -> { - Log.w(TAG, "Installation failed: $packageName - $message") - AndroidPackageManagerPlugin.notifyInstallResult(mapOf( - "success" to false, - "packageName" to packageName, - "message" to (message ?: "Installation failed"), - "status" to status + Log.w(TAG, "Installation failed: $packageName - $message (sessionId=$sessionId)") + AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( + "isSuccess" to false, + "errorMessage" to (message ?: "Installation failed"), + "packageName" to packageName )) } PackageInstaller.STATUS_FAILURE_ABORTED -> { - Log.d(TAG, "Installation cancelled: $packageName") - AndroidPackageManagerPlugin.notifyInstallResult(mapOf( - "success" to false, + Log.d(TAG, "Installation cancelled: $packageName (sessionId=$sessionId)") + AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( + "isSuccess" to false, + "errorMessage" to "Installation was cancelled by user", "packageName" to packageName, - "message" to "Installation was cancelled", - "status" to status, - "aborted" to true + "cancelled" to true )) } else -> { - Log.w(TAG, "Unknown installation status $status: $packageName") - AndroidPackageManagerPlugin.notifyInstallResult(mapOf( - "success" to false, - "packageName" to packageName, - "message" to (message ?: "Installation failed with unknown status"), - "status" to status + Log.w(TAG, "Unknown installation status $status: $packageName (sessionId=$sessionId)") + AndroidPackageManagerPlugin.completeInstallResult(sessionId, mapOf( + "isSuccess" to false, + "errorMessage" to (message ?: "Installation failed with status $status"), + "packageName" to packageName )) } } diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 4cf98f2..7b35658 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -96,7 +96,6 @@ class DownloadService extends StateNotifier> { @override void dispose() { _downloader.unregisterCallbacks(); - ref.read(packageManagerProvider.notifier).onInstallResult = null; super.dispose(); } @@ -154,10 +153,6 @@ class DownloadService extends StateNotifier> { taskProgressCallback: _handleTaskUpdate, ); - // Set up install result callback via the abstract interface - ref.read(packageManagerProvider.notifier).onInstallResult = - _handleInstallResult; - // Clear any existing downloads on startup (no resume) await _clearExistingDownloads(); } @@ -472,7 +467,7 @@ class DownloadService extends StateNotifier> { _processInstallQueue(); } - /// Process installation queue sequentially to prevent concurrent installations + /// Process installation queue sequentially to prevent concurrent installations. Future _processInstallQueue() async { // Don't start processing if already processing if (_isInstallingFromQueue) return; @@ -502,7 +497,6 @@ class DownloadService extends StateNotifier> { // Use the stored file metadata from the download info final fileMetadata = downloadInfo.fileMetadata; - // Trigger installation with verification data final packageManager = ref.read(packageManagerProvider.notifier); await packageManager.install( appId, @@ -511,67 +505,61 @@ class DownloadService extends StateNotifier> { expectedSize: fileMetadata.size ?? 0, ); - // Remove from queue after successful installation initiation + // Installation succeeded - clean up downloaded file + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // Continue even if deletion fails + } + + // Remove from state (installation complete) + state = Map.from(state)..remove(appId); + + // Remove from queue _installQueue.removeAt(0); - // Add a small delay between installations to ensure Android processes them sequentially + // Add a small delay between installations if (_installQueue.isNotEmpty) { await Future.delayed(const Duration(milliseconds: 500)); } } catch (e) { - // Remove from queue and state on error + // Installation failed _installQueue.removeAt(0); - state = Map.from(state)..remove(appId); + + final errorMessage = e.toString(); + + // Detect certificate mismatch + final isCertificateMismatch = + errorMessage.contains('signatures do not match') || + errorMessage.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') || + errorMessage.contains('UPDATE_INCOMPATIBLE'); + + // Detect user cancellation + final wasCancelled = errorMessage.contains('cancelled'); + + if (wasCancelled) { + // User cancelled - just remove from state + state = Map.from(state)..remove(appId); + } else { + // Keep download info but mark as ready to install (can retry) + state = Map.from(state) + ..[appId] = downloadInfo.copyWith( + isInstalling: false, + isReadyToInstall: true, + errorDetails: isCertificateMismatch + ? 'CERTIFICATE_MISMATCH' + : errorMessage.replaceFirst('Exception: ', ''), + ); + } } } _isInstallingFromQueue = false; } - /// Handle installation result from BroadcastReceiver - void _handleInstallResult(Map result) { - if (!mounted) return; // Safety check - notifier may be disposed - - final packageName = result['packageName'] as String?; - final success = result['success'] as bool? ?? false; - - if (packageName == null) return; // Safety check - - // Find and remove the download - final downloadInfo = state[packageName]; - // ignore: unnecessary_null_comparison - if (downloadInfo != null) { - // Check if download exists - if (success) { - // Clean up downloaded file - ALWAYS delete after successful install - // Fire-and-forget: no state modifications in callback - downloadInfo.task.filePath().then((filePath) async { - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Continue even if deletion fails - } - }); - - // Remove from state (synchronous, before any async gaps) - state = Map.from(state)..remove(packageName); - - // Refresh package list to show installed app - ref.read(packageManagerProvider.notifier).syncInstalledPackages(); - } else { - // Keep download info but mark as ready to install (don't remove file) - state = Map.from(state) - ..[packageName] = downloadInfo.copyWith( - isInstalling: false, - isReadyToInstall: true, - ); - } - } - } - /// Pause a download /// /// The background_downloader package handles pause state persistence, @@ -661,6 +649,20 @@ class DownloadService extends StateNotifier> { info.status == TaskStatus.enqueued); } + /// Clear any error and prepare for retry + void clearError(String appId) { + final downloadInfo = state[appId]; + if (downloadInfo != null && downloadInfo.errorDetails != null) { + state = { + ...state, + appId: downloadInfo.copyWith( + errorDetails: null, + // Keep isReadyToInstall: true so installFromDownloaded() can proceed + ), + }; + } + } + /// Mark download as installing (for reckless mode retry) void markInstalling(String appId) { final downloadInfo = state[appId]; diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 32676d7..6713605 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -1,39 +1,18 @@ import 'dart:io'; -import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -/// Android implementation of PackageManager with integrated installation +/// Android implementation of PackageManager with integrated installation. final class AndroidPackageManager extends PackageManager { - AndroidPackageManager(super.ref) { - // Set up method channel handler for callbacks from native side - // NOTE: Do not call any plugins in the constructor. Plugins may not yet be - // registered at this point, leading to MissingPluginException. - // However, setting the method call handler is safe. - _channel.setMethodCallHandler(_handleMethodCall); - } + AndroidPackageManager(super.ref); static const MethodChannel _channel = MethodChannel( 'android_package_manager', ); bool _supportsSilentInstall = false; - @override - void dispose() { - _channel.setMethodCallHandler(null); - super.dispose(); - } - - /// Handle method calls from native side - Future _handleMethodCall(MethodCall call) async { - if (call.method == 'onInstallResult') { - final result = Map.from(call.arguments as Map); - // Use the callback from the base class - onInstallResult?.call(result); - } - } - @override String get platform => 'android-arm64-v8a'; @@ -55,81 +34,51 @@ final class AndroidPackageManager extends PackageManager { throw Exception('APK file not found: $filePath'); } - try { - // Attempt installation via method channel with verification - final result = await _channel - .invokeMethod>('install', { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'skipVerification': skipVerification, - }) - .timeout( - Duration(seconds: 20), - onTimeout: () => { - 'isSuccess': false, - 'errorMessage': 'Installation timed out after 20 seconds', - }, - ); + final result = await _channel + .invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'skipVerification': skipVerification, + }) + .timeout( + const Duration(minutes: 5), + onTimeout: () => { + 'isSuccess': false, + 'errorMessage': 'Installation timed out - user did not respond', + }, + ); - final resultMap = Map.from(result ?? {}); + final resultMap = Map.from(result ?? {}); - if (!(resultMap['isSuccess'] == true)) { - final error = resultMap['errorMessage'] ?? 'Installation failed'; - - // Provide more helpful error messages based on silent install capability - if (_supportsSilentInstall && error.contains('User action required')) { - throw Exception( - 'Silent installation failed: $error\n\n' - 'This may indicate:\n' - '• The app requires special permissions\n' - '• System policy prevents silent installation\n' - '• The APK signature is not trusted\n\n' - 'The installation dialog should appear automatically.', - ); - } - - throw Exception(error); - } - - // Refresh installed packages state - await syncInstalledPackages(); - } catch (e) { - throw Exception('Installation failed: $e'); + if (!(resultMap['isSuccess'] == true)) { + final error = resultMap['errorMessage'] ?? 'Installation failed'; + throw Exception(error); } + + // Refresh installed packages state + await syncInstalledPackages(); } @override Future uninstall(String appId) async { - try { - // Call uninstall method via method channel - final result = await _channel - .invokeMethod>('uninstall', { - 'packageName': appId, - }) - .timeout( - const Duration(seconds: 15), - onTimeout: () => { - 'isSuccess': false, - 'errorMessage': 'Uninstallation timed out after 15 seconds', - }, - ); + final result = await _channel.invokeMethod>('uninstall', { + 'packageName': appId, + }); - final resultMap = Map.from(result ?? {}); + final resultMap = Map.from(result ?? {}); - if (!(resultMap['isSuccess'] == true)) { - final error = resultMap['errorMessage'] ?? 'Uninstallation failed'; - throw Exception(error); + if (!(resultMap['isSuccess'] == true)) { + final wasCancelled = resultMap['cancelled'] == true; + if (wasCancelled) { + throw Exception('Uninstall was cancelled'); } - - // Refresh installed packages state after a brief delay - // to allow the system uninstaller to complete - await Future.delayed(const Duration(milliseconds: 500)); - await syncInstalledPackages(); - } catch (e) { - throw Exception('Uninstallation failed: $e'); + throw Exception('Uninstallation failed'); } + + // Refresh installed packages state + await syncInstalledPackages(); } @override @@ -260,6 +209,7 @@ final class AndroidPackageManager extends PackageManager { app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; final version = app['versionName'] as String? ?? '0.0.0'; final versionCode = app['versionCode'] as int?; + final signatureHash = app['signatureHash'] as String? ?? ''; if (appId.isNotEmpty) { packages.add( @@ -268,8 +218,7 @@ final class AndroidPackageManager extends PackageManager { version: version, versionCode: versionCode, installTime: null, // Method channel doesn't provide install time - signatureHash: - '', // Method channel doesn't provide signature hash + signatureHash: signatureHash, ), ); } diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index f0a8228..f91f1a2 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -41,9 +41,6 @@ class PackageInfo extends Equatable { ]; } -/// Callback type for installation results -typedef InstallResultCallback = void Function(Map result); - /// Package management interface abstract class PackageManager extends StateNotifier> { PackageManager(this.ref) : super([]); @@ -51,10 +48,9 @@ abstract class PackageManager extends StateNotifier> { /// Riverpod ref for accessing storage and other dependencies. final Ref ref; - /// Optional callback for installation results - InstallResultCallback? onInstallResult; - - /// Install an APK or package from the given file path + /// Install an APK or package from the given file path. + /// Returns when installation completes (success or failure). + /// Throws on failure. Future install( String appId, String filePath, { diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 0f3dc39..ec8308b 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -1,14 +1,14 @@ -import 'dart:async'; - import 'package:async_button_builder/async_button_builder.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/download_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/trust_service.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/install_alert_dialog.dart'; import 'package:zapstore/widgets/install_button_state.dart'; @@ -35,6 +35,22 @@ class InstallButton extends ConsumerWidget { .where((p) => p.appId == app.identifier) .firstOrNull; + // Listen for installation failures and show errors + ref.listen(downloadInfoProvider(app.identifier), (previous, next) { + // Installation just failed - show error + if (next != null && + next.isReadyToInstall && + next.errorDetails != null && + previous?.errorDetails != next.errorDetails) { + if (context.mounted) { + final errorMessage = next.errorDetails == 'CERTIFICATE_MISMATCH' + ? 'Installation failed: Certificate mismatch detected' + : 'Installation failed: ${next.errorDetails}'; + context.showError(errorMessage); + } + } + }); + // Determine current state from inputs using the extracted function final state = determineInstallButtonState( app: app, @@ -98,57 +114,57 @@ class InstallButton extends ConsumerWidget { return switch (state) { // Not installed, ready to install ReadyToInstall(:final hasRelease) => _buildAsyncButton( - context, - ref, - text: 'Install', - onPressed: hasRelease ? () => _startDownload(ref) : null, - isPrimary: true, - fontSize: fontSize, - needsTrustCheck: true, - ), + context, + ref, + text: 'Install', + onPressed: hasRelease ? () => _startDownload(ref) : null, + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: true, + ), // Installed and up to date InstalledUpToDate() => _buildAsyncButton( - context, - ref, - text: 'Open', - onPressed: () => _openApp(context, ref), - isPrimary: true, - fontSize: fontSize, - needsTrustCheck: false, - ), + context, + ref, + text: 'Open', + onPressed: () => _openApp(context, ref), + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: false, + ), // Update available UpdateAvailable(:final hasRelease) => _buildAsyncButton( - context, - ref, - text: 'Update', - onPressed: hasRelease ? () => _startDownload(ref) : null, - isPrimary: true, - fontSize: fontSize, - needsTrustCheck: false, - ), + context, + ref, + text: 'Update', + onPressed: hasRelease ? () => _startDownload(ref) : null, + isPrimary: true, + fontSize: fontSize, + needsTrustCheck: false, + ), // Downgrade blocked DowngradeBlocked() => _buildSimpleButton( - context, - "Can't downgrade", - null, - isPrimary: false, - showSpinner: false, - fontSize: fontSize, - isDowngrade: true, - ), + context, + "Can't downgrade", + null, + isPrimary: false, + showSpinner: false, + fontSize: fontSize, + isDowngrade: true, + ), // Download in progress Downloading(:final progress, :final totalSizeMb) => _buildProgressButton( - context, - ref, - progress: progress, - text: _formatDownloadProgress(progress, totalSizeMb), - fontSize: fontSize, - onTap: () => _pauseDownload(ref), - ), + context, + ref, + progress: progress, + text: _formatDownloadProgress(progress, totalSizeMb), + fontSize: fontSize, + onTap: () => _pauseDownload(ref), + ), // Download paused DownloadPaused(:final progress, :final totalSizeMb) => @@ -163,55 +179,62 @@ class InstallButton extends ConsumerWidget { // Download enqueued DownloadEnqueued(:final isUpdate) => _buildSimpleButton( - context, - isUpdate ? 'Update' : 'Install', - () => _cancelAndRestart(ref), - isPrimary: true, - fontSize: fontSize, - ), + context, + isUpdate ? 'Update' : 'Install', + () => _cancelAndRestart(ref), + isPrimary: true, + fontSize: fontSize, + ), // Downloaded, ready to install DownloadedReadyToInstall(:final isUpdate) => AsyncButtonBuilder( - onPressed: () => _installFromDownloaded(ref), - builder: (context, child, callback, buttonState) { - return _buildSimpleButton( - context, - buttonState.maybeWhen( - loading: () => 'Installing...', - orElse: () => isUpdate ? 'Update' : 'Install', - ), - buttonState.maybeWhen( - loading: () => null, - orElse: () => callback, - ), - isPrimary: true, - showSpinner: buttonState.maybeWhen( - loading: () => true, - orElse: () => false, - ), - fontSize: fontSize, - ); - }, - child: Text( - isUpdate ? 'Update' : 'Install', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - onError: () { - if (context.mounted) { - context.showError('Installation failed. Please try again.'); - } - }, + onPressed: () => _installFromDownloaded(ref), + builder: (context, child, callback, buttonState) { + return _buildSimpleButton( + context, + buttonState.maybeWhen( + loading: () => 'Installing...', + orElse: () => isUpdate ? 'Update' : 'Install', + ), + buttonState.maybeWhen(loading: () => null, orElse: () => callback), + isPrimary: true, + showSpinner: buttonState.maybeWhen( + loading: () => true, + orElse: () => false, + ), + fontSize: fontSize, + ); + }, + child: Text( + isUpdate ? 'Update' : 'Install', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), + onError: () { + if (context.mounted) { + context.showError('Installation failed. Please try again.'); + } + }, + ), + + // Certificate mismatch - requires force update + ForceUpdateRequired() => _buildSimpleButton( + context, + 'Force update', + () => _showForceUpdateDialog(ref, context), + isPrimary: false, + fontSize: fontSize, + isError: true, + ), // Installing Installing() => _buildSimpleButton( - context, - 'Requesting installation', - null, - isPrimary: true, - showSpinner: true, - fontSize: fontSize, - ), + context, + 'Requesting installation', + null, + isPrimary: true, + showSpinner: true, + fontSize: fontSize, + ), // Failed Failed(:final canRetryReckless, :final downloadInfo) => @@ -273,10 +296,11 @@ class InstallButton extends ConsumerWidget { if (shouldShowDialog) { if (!context.mounted) return; - final result = await showBaseDialog<({bool trustPermanently})>( - context: context, - dialog: InstallAlertDialog(app: app), - ); + final result = + await showBaseDialog<({bool trustPermanently})>( + context: context, + dialog: InstallAlertDialog(app: app), + ); if (result == null) return; if (result.trustPermanently && signerPubkey != null) { try { @@ -631,46 +655,91 @@ class InstallButton extends ConsumerWidget { } Future _uninstallApp(WidgetRef ref, BuildContext context) async { - // Special handling for Zapstore app itself - if (app.identifier == 'dev.zapstore.alpha') { - final shouldProceed = await _showZapstoreUninstallDialog(context); - if (!shouldProceed) return; - } - try { final packageManager = ref.read(packageManagerProvider.notifier); await packageManager.uninstall(app.identifier); + // Only reaches here after successful uninstall if (context.mounted) { - _monitorUninstallCompletion(ref, context); + context.showInfo('${app.name ?? app.identifier} has been uninstalled'); } } catch (e) { if (context.mounted) { - context.showError('Uninstall failed: $e'); + // Don't show error for user cancellation + final message = e.toString(); + if (!message.contains('cancelled')) { + context.showError('Uninstall failed: $e'); + } } } } - Future _showZapstoreUninstallDialog(BuildContext context) async { - final result = await showBaseDialog( + Future _showForceUpdateDialog( + WidgetRef ref, + BuildContext context, + ) async { + // Get version and certificate info + final installedPackage = ref + .read(packageManagerProvider) + .where((p) => p.appId == app.identifier) + .firstOrNull; + final updateVersion = app.latestFileMetadata?.version ?? 'Unknown'; + final currentVersion = installedPackage?.version ?? 'Unknown'; + final currentCertHash = installedPackage?.signatureHash ?? 'Unknown'; + final updateCertHash = + app.latestFileMetadata?.apkSignatureHash ?? 'Unknown'; + + // Get author profile + final author = app.author.value; + + final shouldProceed = await showBaseDialog( context: context, dialog: Builder( builder: (dialogContext) => BaseDialog( - title: const BaseDialogTitle('Uninstall Zapstore'), - content: const BaseDialogContent( + titleIcon: Icon( + Icons.security, + color: Theme.of(dialogContext).colorScheme.error, + ), + title: const BaseDialogTitle('Certificate Mismatch'), + content: BaseDialogContent( children: [ - Text( - 'This will uninstall Zapstore from your device.', - style: TextStyle(fontSize: 16), + Row( + children: [ + Expanded( + child: AuthorContainer( + profile: author!, + beforeText: 'This update was published by', + afterText: ' but signed with a different certificate.', + oneLine: false, + size: 14, + ), + ), + ], ), - SizedBox(height: 12), - Text( - 'To download the previous stable Zapstore version, visit:', - style: TextStyle(fontSize: 16), + const SizedBox(height: 12), + + // Compact version and certificate comparison + _buildCompactHashRow( + dialogContext, + 'Current ($currentVersion)', + currentCertHash, + 'Installed version certificate', ), - SizedBox(height: 8), + const SizedBox(height: 8), + _buildCompactHashRow( + dialogContext, + 'Update ($updateVersion)', + updateCertHash, + 'New version certificate', + ), + const SizedBox(height: 12), Text( - 'zapstore.dev/download', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + 'Android security prevents updating apps signed by different certificates. Contact the publisher for details.', + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 8), + Text( + 'To proceed anyway, uninstall the current version and install the new one. ALL APP DATA WILL BE LOST.', + style: TextStyle(fontSize: 14), ), ], ), @@ -684,40 +753,96 @@ class InstallButton extends ConsumerWidget { style: FilledButton.styleFrom( backgroundColor: Theme.of(dialogContext).colorScheme.error, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), ), - child: const Text('Uninstall'), + child: const Text('Uninstall & Install'), ), ], ), ), ); - return result ?? false; + if (shouldProceed == true && context.mounted) { + try { + final downloadService = ref.read(downloadServiceProvider.notifier); + final packageManager = ref.read(packageManagerProvider.notifier); + + final downloadInfo = downloadService.getDownloadInfo(app.identifier); + if (downloadInfo == null) { + throw Exception('Download not found'); + } + + final filePath = await downloadInfo.task.filePath(); + final fileMetadata = downloadInfo.fileMetadata; + + downloadService.clearError(app.identifier); + + await packageManager.uninstall(app.identifier); + + downloadService.markInstalling(app.identifier); + + await packageManager.install( + app.identifier, + filePath, + expectedHash: fileMetadata.hash, + expectedSize: fileMetadata.size ?? 0, + ); + + downloadService.cancelDownload(app.identifier); + } catch (e) { + final message = e.toString(); + if (context.mounted && !message.contains('cancelled')) { + context.showError('Force update failed: $e'); + } + } + } } - void _monitorUninstallCompletion(WidgetRef ref, BuildContext context) { - bool notificationShown = false; - late final ProviderSubscription> subscription; + String _abbr(String v) { + final t = v.trim(); + if (t.length <= 12) return t; + return '${t.substring(0, 6)}...${t.substring(t.length - 6)}'; + } - // Listen to package manager changes instead of polling - subscription = ref.listenManual(packageManagerProvider, (previous, next) { - final isStillInstalled = next.any((p) => p.appId == app.identifier); - - // Only show notification once when app becomes uninstalled - if (!isStillInstalled && !notificationShown && context.mounted) { - notificationShown = true; - context.showInfo('${app.name ?? app.identifier} has been uninstalled'); - // Close subscription after showing notification - subscription.close(); - } - }); - - // Timeout after 30 seconds - Future.delayed(const Duration(seconds: 30), () { - if (!notificationShown) { - subscription.close(); - } - }); + Widget _buildCompactHashRow( + BuildContext context, + String versionLabel, + String hash, + String tooltipText, + ) { + return Row( + children: [ + Expanded( + child: RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + TextSpan( + text: '$versionLabel → ', + style: TextStyle(fontWeight: FontWeight.w600), + ), + TextSpan( + text: _abbr(hash), + style: TextStyle(fontFamily: 'monospace'), + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.zero, + ), + tooltip: 'Copy certificate hash', + onPressed: () { + Clipboard.setData(ClipboardData(text: hash)); + context.showInfo('Copied $tooltipText'); + }, + ), + ], + ); } } diff --git a/lib/widgets/install_button_state.dart b/lib/widgets/install_button_state.dart index 9e3c2d8..f80bc27 100644 --- a/lib/widgets/install_button_state.dart +++ b/lib/widgets/install_button_state.dart @@ -58,6 +58,11 @@ class DownloadedReadyToInstall extends InstallButtonState { 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 { const Installing(); @@ -93,6 +98,10 @@ InstallButtonState determineInstallButtonState({ 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); }