mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Improve certificate mismatch logic and UI, remove force update option
This commit is contained in:
+77
-3
@@ -72,6 +72,7 @@ object ErrorCode {
|
||||
const val INVALID_FILE = "invalidFile"
|
||||
const val INSTALL_FAILED = "installFailed"
|
||||
const val CERT_MISMATCH = "certMismatch"
|
||||
const val CERT_METADATA_MISMATCH = "certMetadataMismatch"
|
||||
const val PERMISSION_DENIED = "permissionDenied"
|
||||
const val INSUFFICIENT_STORAGE = "insufficientStorage"
|
||||
const val INCOMPATIBLE = "incompatible"
|
||||
@@ -645,12 +646,14 @@ class AndroidPackageManagerPlugin :
|
||||
val packageName = call.argument<String>("packageName")
|
||||
val expectedHash = call.argument<String>("expectedHash")
|
||||
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
|
||||
val expectedCertHashes =
|
||||
call.argument<List<String>>("expectedCertHashes") ?: emptyList()
|
||||
|
||||
if (filePath == null || packageName == null) {
|
||||
result.error("MISSING_ARGUMENT", "filePath and packageName required", null)
|
||||
return
|
||||
}
|
||||
installApk(filePath, packageName, expectedHash, expectedSize, result)
|
||||
installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, result)
|
||||
}
|
||||
"canInstallSilently" -> {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
@@ -699,6 +702,7 @@ class AndroidPackageManagerPlugin :
|
||||
packageName: String,
|
||||
expectedHash: String?,
|
||||
expectedSize: Long?,
|
||||
expectedCertHashes: List<String>,
|
||||
result: Result
|
||||
) {
|
||||
val file = File(filePath)
|
||||
@@ -770,7 +774,7 @@ class AndroidPackageManagerPlugin :
|
||||
|
||||
// All heavy I/O work runs on background thread to prevent ANRs
|
||||
val t = Thread {
|
||||
verifyAndInstall(file, packageName, expectedHash, expectedSize)
|
||||
verifyAndInstall(file, packageName, expectedHash, expectedSize, expectedCertHashes)
|
||||
verificationThreads.remove(packageName)
|
||||
}
|
||||
verificationThreads[packageName] = t
|
||||
@@ -797,7 +801,8 @@ class AndroidPackageManagerPlugin :
|
||||
apkFile: File,
|
||||
packageName: String,
|
||||
expectedHash: String?,
|
||||
expectedSize: Long?
|
||||
expectedSize: Long?,
|
||||
expectedCertHashes: List<String>
|
||||
) {
|
||||
// Step 1: Validate APK format before doing any heavy work
|
||||
if (!isValidApkFormat(apkFile)) {
|
||||
@@ -832,6 +837,40 @@ class AndroidPackageManagerPlugin :
|
||||
Log.w(TAG, "APK package name '$apkPackageName' differs from event identifier '$packageName'")
|
||||
}
|
||||
|
||||
// Step 2b: Verify APK signing certificate against Nostr metadata (apk_certificate_hash)
|
||||
if (expectedCertHashes.isNotEmpty()) {
|
||||
val actualCertHashes = extractApkCertHashes(apkFile.absolutePath)
|
||||
if (actualCertHashes == null) {
|
||||
mainHandler.post {
|
||||
emitInstallStatus(
|
||||
packageName,
|
||||
InstallStatus.FAILED,
|
||||
"Could not extract signing certificate from APK",
|
||||
ErrorCode.CERT_METADATA_MISMATCH
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val matched = actualCertHashes.any { actual ->
|
||||
expectedCertHashes.any { expected -> actual.equals(expected, ignoreCase = true) }
|
||||
}
|
||||
if (!matched) {
|
||||
val actualHex = actualCertHashes.joinToString(", ")
|
||||
val expectedHex = expectedCertHashes.joinToString(", ")
|
||||
Log.w(TAG, "Cert mismatch for $packageName. Expected: $expectedHex Actual: $actualHex")
|
||||
mainHandler.post {
|
||||
emitInstallStatus(
|
||||
packageName,
|
||||
InstallStatus.FAILED,
|
||||
"Certificate mismatch",
|
||||
ErrorCode.CERT_METADATA_MISMATCH,
|
||||
"APK signing certificate does not match what the publisher declared.\n\nExpected: $expectedHex\nActual: $actualHex"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Check if this is an update
|
||||
val isUpdate =
|
||||
try {
|
||||
@@ -1008,6 +1047,41 @@ class AndroidPackageManagerPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts signing certificate hashes from an APK file.
|
||||
*
|
||||
* Returns SHA-256 hashes of the DER-encoded X.509 certificates (lowercase hex), matching the
|
||||
* algorithm used by zsp to produce apk_certificate_hash in NIP-82 SoftwareAsset events.
|
||||
*
|
||||
* Uses SigningInfo.apkContentsSigners (API 28+) which prefers v3 key-rotation signers over
|
||||
* v2/v1, consistent with apkverifier.PickBestApkCert used in zsp.
|
||||
*
|
||||
* Returns null if extraction fails (treat as verification error, not skip).
|
||||
*/
|
||||
private fun extractApkCertHashes(apkPath: String): List<String>? {
|
||||
return try {
|
||||
val packageInfo =
|
||||
context.packageManager.getPackageArchiveInfo(
|
||||
apkPath,
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
)
|
||||
?: return null
|
||||
|
||||
val signers = packageInfo.signingInfo?.apkContentsSigners ?: return null
|
||||
if (signers.isEmpty()) return null
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
signers.map { sig ->
|
||||
digest.reset()
|
||||
digest.update(sig.toByteArray())
|
||||
digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to extract APK cert hashes from $apkPath: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SESSION MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -264,25 +264,38 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-query apps from local to ensure relationships are loaded
|
||||
final appsWithRelations = await storage.query(
|
||||
RequestFilter<App>(
|
||||
tags: {
|
||||
'#d': installedIds,
|
||||
'#f': {platform},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
|
||||
// Collect apps with updates
|
||||
final updatableApps = appsWithRelations
|
||||
.where((app) => app.hasUpdate)
|
||||
.toList();
|
||||
// Determine which apps have updates directly from the installables we
|
||||
// already fetched — avoids a relationship-loading re-query that would
|
||||
// always produce null installables (no `and:` in background context).
|
||||
final updatableInstallables = <String, Installable>{};
|
||||
for (final asset in assets) {
|
||||
if (packageManager.hasUpdate(asset.appIdentifier, asset)) {
|
||||
updatableInstallables[asset.appIdentifier] = asset;
|
||||
}
|
||||
}
|
||||
for (final meta in metadatas) {
|
||||
if (!updatableInstallables.containsKey(meta.appIdentifier) &&
|
||||
packageManager.hasUpdate(meta.appIdentifier, meta)) {
|
||||
updatableInstallables[meta.appIdentifier] = meta;
|
||||
}
|
||||
}
|
||||
|
||||
// Show notification if updates found (throttled to once per 72h)
|
||||
if (updatableApps.isNotEmpty) {
|
||||
await _showUpdateNotificationIfNeeded(updatableApps);
|
||||
if (updatableInstallables.isNotEmpty) {
|
||||
// Fetch App objects from local DB for display names only
|
||||
final updatableApps = await storage.query(
|
||||
RequestFilter<App>(
|
||||
tags: {
|
||||
'#d': updatableInstallables.keys.toSet(),
|
||||
'#f': {platform},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
await _showUpdateNotificationIfNeeded(
|
||||
updatableApps,
|
||||
updatableInstallables,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -299,9 +312,17 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
/// Show a local notification for available updates.
|
||||
/// Only notifies if:
|
||||
/// 1. User hasn't opened app in 24+ hours
|
||||
/// 2. There are updates with release.createdAt > seenUntil AND > lastOpened
|
||||
/// 2. There are updates with installable.createdAt > seenUntil AND > lastOpened
|
||||
/// (new since both last notification AND last time user saw the app)
|
||||
Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
|
||||
///
|
||||
/// [installables] maps app identifier → the installable (SoftwareAsset or
|
||||
/// FileMetadata) that represents the available update. Its `createdAt` is used
|
||||
/// for freshness checks instead of `app.latestRelease.value` which is not
|
||||
/// populated in the background isolate context.
|
||||
Future<void> _showUpdateNotificationIfNeeded(
|
||||
List<App> updates,
|
||||
Map<String, Installable> installables,
|
||||
) async {
|
||||
final secureStorage = SecureStorageService();
|
||||
|
||||
// Skip if user recently opened the app
|
||||
@@ -311,16 +332,17 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the "seen until" timestamp - updates with release.createdAt > this are new
|
||||
// Get the "seen until" timestamp - updates with createdAt > this are new
|
||||
final seenUntil = await secureStorage.getSeenUntil();
|
||||
|
||||
// Filter to only updates that are genuinely new:
|
||||
// - release.createdAt > seenUntil (not already notified via background)
|
||||
// - release.createdAt > lastOpened (not already seen when user opened app)
|
||||
// - installable.createdAt > seenUntil (not already notified via background)
|
||||
// - installable.createdAt > lastOpened (not already seen when user opened app)
|
||||
// This prevents nagging about updates user saw in the app but chose to ignore
|
||||
final newUpdates = updates.where((app) {
|
||||
final releaseTime = app.latestRelease.value?.event.createdAt;
|
||||
if (releaseTime == null) return false;
|
||||
final installable = installables[app.identifier];
|
||||
if (installable == null) return false;
|
||||
final releaseTime = installable.createdAt;
|
||||
|
||||
// Must be newer than last notification (if any)
|
||||
if (seenUntil != null && !releaseTime.isAfter(seenUntil)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
/// Install status values from native side.
|
||||
///
|
||||
@@ -49,6 +50,7 @@ class NativeErrorCode {
|
||||
static const invalidFile = 'invalidFile';
|
||||
static const installFailed = 'installFailed';
|
||||
static const certMismatch = 'certMismatch';
|
||||
static const certMetadataMismatch = 'certMetadataMismatch';
|
||||
static const permissionDenied = 'permissionDenied';
|
||||
static const insufficientStorage = 'insufficientStorage';
|
||||
static const incompatible = 'incompatible';
|
||||
@@ -392,6 +394,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
NativeErrorCode.invalidFile => FailureType.invalidFile,
|
||||
NativeErrorCode.installFailed => FailureType.installFailed,
|
||||
NativeErrorCode.certMismatch => FailureType.certMismatch,
|
||||
NativeErrorCode.certMetadataMismatch => FailureType.certMetadataMismatch,
|
||||
NativeErrorCode.permissionDenied => FailureType.permissionDenied,
|
||||
NativeErrorCode.insufficientStorage => FailureType.insufficientStorage,
|
||||
NativeErrorCode.incompatible => FailureType.incompatible,
|
||||
@@ -441,6 +444,8 @@ final class AndroidPackageManager extends PackageManager {
|
||||
'Invalid app file. The download may be corrupt.',
|
||||
FailureType.certMismatch =>
|
||||
'Update signed by different developer. Uninstall current version to update.',
|
||||
FailureType.certMetadataMismatch =>
|
||||
'APK signing certificate does not match what the publisher declared. This file may have been tampered with.',
|
||||
FailureType.permissionDenied =>
|
||||
'Permission required. Please grant install permission and try again.',
|
||||
FailureType.insufficientStorage =>
|
||||
@@ -512,6 +517,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
'expectedCertHashes': target.certificateHashes.toList(),
|
||||
})
|
||||
.timeout(const Duration(seconds: 30), onTimeout: () => null);
|
||||
|
||||
|
||||
@@ -214,8 +214,6 @@ class OperationFailed extends InstallOperation {
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
/// Whether this requires force update (uninstall + install)
|
||||
bool get needsForceUpdate => type == FailureType.certMismatch;
|
||||
}
|
||||
|
||||
/// Types of failures that can occur during install operations
|
||||
@@ -235,6 +233,9 @@ enum FailureType {
|
||||
/// Certificate/signature mismatch - needs force update (uninstall + install)
|
||||
certMismatch,
|
||||
|
||||
/// APK signing certificate does not match what the publisher declared in Nostr metadata
|
||||
certMetadataMismatch,
|
||||
|
||||
/// User doesn't have install permission
|
||||
permissionDenied,
|
||||
|
||||
|
||||
@@ -609,57 +609,6 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
await _performInstall(appId, op.target, op.filePath);
|
||||
}
|
||||
|
||||
/// Force update (uninstall + install) from OperationFailed with certMismatch
|
||||
Future<void> forceUpdate(String appId) async {
|
||||
final op = getOperation(appId);
|
||||
if (op is! OperationFailed || !op.needsForceUpdate) return;
|
||||
|
||||
final filePath = op.filePath;
|
||||
if (filePath == null || !await File(filePath).exists()) {
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.downloadFailed,
|
||||
message: 'Downloaded file not found. Please download again.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOperation(appId, Uninstalling(target: op.target, filePath: filePath));
|
||||
|
||||
try {
|
||||
await uninstall(appId);
|
||||
await _performInstall(appId, op.target, filePath);
|
||||
} catch (e) {
|
||||
final errorMessage = e.toString();
|
||||
if (!errorMessage.contains('cancelled')) {
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.installFailed,
|
||||
message: 'Update failed.',
|
||||
description: errorMessage,
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
target: op.target,
|
||||
type: FailureType.certMismatch,
|
||||
message:
|
||||
'Update signed by different developer. Uninstall current version to update.',
|
||||
filePath: filePath,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismiss error and clean up
|
||||
void dismissError(String appId) {
|
||||
final op = getOperation(appId);
|
||||
|
||||
@@ -276,8 +276,6 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
|
||||
|
||||
@override
|
||||
CategorizedUpdates build() {
|
||||
ref.onDispose(() => _lastCategorization = null);
|
||||
|
||||
// Keep poller alive; only rebuild when completion status changes
|
||||
final pollerHasCompleted = ref.watch(
|
||||
updatePollerProvider.select((s) => s.lastCheckTime != null),
|
||||
|
||||
@@ -13,11 +13,7 @@ import 'package:zapstore/widgets/install_alert_dialog.dart';
|
||||
import 'package:zapstore/theme.dart';
|
||||
|
||||
class InstallButton extends ConsumerWidget {
|
||||
const InstallButton({
|
||||
super.key,
|
||||
required this.app,
|
||||
this.compact = false,
|
||||
});
|
||||
const InstallButton({super.key, required this.app, this.compact = false});
|
||||
|
||||
final App app;
|
||||
final bool compact;
|
||||
@@ -57,6 +53,7 @@ class InstallButton extends ConsumerWidget {
|
||||
hasUpdate: hasUpdate,
|
||||
hasDowngrade: hasDowngrade,
|
||||
fileMetadata: fileMetadata,
|
||||
installedPkg: installedPkg,
|
||||
fontSize: fontSize,
|
||||
);
|
||||
|
||||
@@ -100,13 +97,15 @@ class InstallButton extends ConsumerWidget {
|
||||
required bool hasUpdate,
|
||||
required bool hasDowngrade,
|
||||
required Installable? fileMetadata,
|
||||
required PackageInfo? installedPkg,
|
||||
required double fontSize,
|
||||
}) {
|
||||
// Completed is a terminal "result" state and may linger for batch progress UX.
|
||||
// If the app is no longer installed (e.g. user uninstalled right after install),
|
||||
// ignore a stale Completed op so we fall back to the normal "Install" UI.
|
||||
final effectiveOperation =
|
||||
(!isInstalled && operation is Completed) ? null : operation;
|
||||
final effectiveOperation = (!isInstalled && operation is Completed)
|
||||
? null
|
||||
: operation;
|
||||
|
||||
// Handle operation states first
|
||||
if (effectiveOperation != null) {
|
||||
@@ -205,14 +204,12 @@ class InstallButton extends ConsumerWidget {
|
||||
showSpinner: true,
|
||||
),
|
||||
|
||||
OperationFailed(:final type, :final needsForceUpdate) =>
|
||||
_buildErrorButton(
|
||||
context,
|
||||
ref,
|
||||
type: type,
|
||||
needsForceUpdate: needsForceUpdate,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
OperationFailed(:final type) => _buildErrorButton(
|
||||
context,
|
||||
ref,
|
||||
type: type,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
|
||||
Completed() => _buildAsyncButton(
|
||||
context,
|
||||
@@ -240,11 +237,33 @@ class InstallButton extends ConsumerWidget {
|
||||
if (hasUpdate) {
|
||||
if (fileMetadata == null) {
|
||||
return _buildSimpleButton(
|
||||
context, 'Update', null,
|
||||
context,
|
||||
'Update',
|
||||
null,
|
||||
fontSize: fontSize,
|
||||
isDisabled: true,
|
||||
);
|
||||
}
|
||||
final installedHash = installedPkg?.signatureHash ?? '';
|
||||
final targetHashes = fileMetadata.certificateHashes;
|
||||
final isCertMismatch =
|
||||
installedHash.isNotEmpty &&
|
||||
targetHashes.isNotEmpty &&
|
||||
!targetHashes.contains(installedHash);
|
||||
if (isCertMismatch) {
|
||||
return _buildSimpleButton(
|
||||
context,
|
||||
'Certificate mismatch',
|
||||
() => _showCertMismatchDialog(
|
||||
ref,
|
||||
context,
|
||||
installedPkg,
|
||||
fileMetadata,
|
||||
),
|
||||
fontSize: fontSize,
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
return _buildAsyncButton(
|
||||
context,
|
||||
ref,
|
||||
@@ -268,7 +287,9 @@ class InstallButton extends ConsumerWidget {
|
||||
// Not installed
|
||||
if (fileMetadata == null) {
|
||||
return _buildSimpleButton(
|
||||
context, 'Install', null,
|
||||
context,
|
||||
'Install',
|
||||
null,
|
||||
fontSize: fontSize,
|
||||
isDisabled: true,
|
||||
);
|
||||
@@ -476,19 +497,8 @@ class InstallButton extends ConsumerWidget {
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required FailureType type,
|
||||
required bool needsForceUpdate,
|
||||
required double fontSize,
|
||||
}) {
|
||||
if (needsForceUpdate) {
|
||||
return _buildSimpleButton(
|
||||
context,
|
||||
'Force update',
|
||||
() => _showForceUpdateDialog(ref, context),
|
||||
fontSize: fontSize,
|
||||
isError: true,
|
||||
);
|
||||
}
|
||||
|
||||
return _buildSimpleButton(
|
||||
context,
|
||||
'Error (tap for details)',
|
||||
@@ -675,21 +685,19 @@ class InstallButton extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showForceUpdateDialog(
|
||||
Future<void> _showCertMismatchDialog(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
PackageInfo? installedPkg,
|
||||
Installable fileMetadata,
|
||||
) async {
|
||||
final installedPkg = ref.read(installedPackageProvider(app.identifier));
|
||||
final operation = ref.read(installOperationProvider(app.identifier));
|
||||
if (operation is! OperationFailed) return;
|
||||
|
||||
final updateVersion = operation.target.version;
|
||||
final currentVersion = installedPkg?.version ?? 'Unknown';
|
||||
final currentCertHash = installedPkg?.signatureHash ?? 'Unknown';
|
||||
final updateCertHash = operation.target.apkSignatureHash ?? 'Unknown';
|
||||
final updateCertHash = fileMetadata.certificateHash ?? 'Unknown';
|
||||
final updateVersion = fileMetadata.version;
|
||||
final author = app.author.value;
|
||||
|
||||
final shouldProceed = await showBaseDialog<bool>(
|
||||
await showBaseDialog<void>(
|
||||
context: context,
|
||||
dialog: Builder(
|
||||
builder: (dialogContext) => BaseDialog(
|
||||
@@ -697,7 +705,7 @@ class InstallButton extends ConsumerWidget {
|
||||
Icons.security,
|
||||
color: Theme.of(dialogContext).colorScheme.error,
|
||||
),
|
||||
title: const BaseDialogTitle('Certificate Mismatch'),
|
||||
title: const BaseDialogTitle('Certificate mismatch'),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
if (author != null)
|
||||
@@ -731,46 +739,31 @@ class InstallButton extends ConsumerWidget {
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Android security prevents updating apps signed by different certificates. '
|
||||
'Contact the publisher for details.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'To proceed anyway, uninstall the current version and install the new one. '
|
||||
'ALL APP DATA WILL BE LOST.',
|
||||
'Contact the publisher for details. You can uninstall and install again.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_uninstallApp(ref, context);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Uninstall & Install'),
|
||||
child: const Text('Uninstall'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldProceed == true && context.mounted) {
|
||||
try {
|
||||
final pm = ref.read(packageManagerProvider.notifier);
|
||||
await pm.forceUpdate(app.identifier);
|
||||
} catch (e) {
|
||||
final errorMessage = e.toString();
|
||||
if (context.mounted && !errorMessage.contains('cancelled')) {
|
||||
context.showError('Update failed', technicalDetails: errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _abbr(String v) {
|
||||
|
||||
Reference in New Issue
Block a user