Improved package manager install APIs, better error handling, new certificate mismatch flow

This commit is contained in:
franzap
2025-12-12 11:56:45 -03:00
parent 75e54aec81
commit 1551463b68
7 changed files with 534 additions and 342 deletions
@@ -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<Int, Result>()
fun notifyInstallResult(result: Map<String, Any?>) {
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<String, Any?>) {
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) }
}
}
@@ -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
))
}
}
+57 -55
View File
@@ -96,7 +96,6 @@ class DownloadService extends StateNotifier<Map<String, DownloadInfo>> {
@override
void dispose() {
_downloader.unregisterCallbacks();
ref.read(packageManagerProvider.notifier).onInstallResult = null;
super.dispose();
}
@@ -154,10 +153,6 @@ class DownloadService extends StateNotifier<Map<String, DownloadInfo>> {
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<Map<String, DownloadInfo>> {
_processInstallQueue();
}
/// Process installation queue sequentially to prevent concurrent installations
/// Process installation queue sequentially to prevent concurrent installations.
Future<void> _processInstallQueue() async {
// Don't start processing if already processing
if (_isInstallingFromQueue) return;
@@ -502,7 +497,6 @@ class DownloadService extends StateNotifier<Map<String, DownloadInfo>> {
// 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<Map<String, DownloadInfo>> {
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<String, dynamic> 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<Map<String, DownloadInfo>> {
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];
@@ -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<void> _handleMethodCall(MethodCall call) async {
if (call.method == 'onInstallResult') {
final result = Map<String, dynamic>.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<Map<Object?, Object?>>('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<Map<Object?, Object?>>('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<String, dynamic>.from(result ?? {});
final resultMap = Map<String, dynamic>.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<void> uninstall(String appId) async {
try {
// Call uninstall method via method channel
final result = await _channel
.invokeMethod<Map<Object?, Object?>>('uninstall', {
'packageName': appId,
})
.timeout(
const Duration(seconds: 15),
onTimeout: () => {
'isSuccess': false,
'errorMessage': 'Uninstallation timed out after 15 seconds',
},
);
final result = await _channel.invokeMethod<Map<Object?, Object?>>('uninstall', {
'packageName': appId,
});
final resultMap = Map<String, dynamic>.from(result ?? {});
final resultMap = Map<String, dynamic>.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,
),
);
}
@@ -41,9 +41,6 @@ class PackageInfo extends Equatable {
];
}
/// Callback type for installation results
typedef InstallResultCallback = void Function(Map<String, dynamic> result);
/// Package management interface
abstract class PackageManager extends StateNotifier<List<PackageInfo>> {
PackageManager(this.ref) : super([]);
@@ -51,10 +48,9 @@ abstract class PackageManager extends StateNotifier<List<PackageInfo>> {
/// 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<void> install(
String appId,
String filePath, {
+259 -134
View File
@@ -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<void> _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<bool> _showZapstoreUninstallDialog(BuildContext context) async {
final result = await showBaseDialog<bool>(
Future<void> _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<bool>(
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<List<PackageInfo>> 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');
},
),
],
);
}
}
+9
View File
@@ -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);
}