mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improved package manager install APIs, better error handling, new certificate mismatch flow
This commit is contained in:
+129
-22
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user