Files
zapstore/lib/services/package_manager/android_package_manager.dart
T
alltheseasandClaude Opus 4.5 ca965581e2 Address code review feedback
- Remove unused originalMessage parameter from _getUserFriendlyMessage
- Add rate limiting to user-initiated error reports
- Fix empty description showing "Technical details" toggle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:46:28 -06:00

739 lines
27 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
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';
/// Install status values from native side.
///
/// IMPORTANT: The native side sends these as strings via EventChannel, so we
/// parse them into this enum for type-safe switching.
enum InstallStatus {
started,
verifying,
verifyingProgress, // Verification progress update
pendingUserAction,
installing, // User accepted, system is now installing
systemProcessing, // Committed, cannot cancel
alreadyInProgress,
success,
failed,
cancelled,
}
extension InstallStatusX on InstallStatus {
static InstallStatus? tryParse(String? raw) {
return switch (raw) {
'started' => InstallStatus.started,
'verifying' => InstallStatus.verifying,
'verifyingProgress' => InstallStatus.verifyingProgress,
'pendingUserAction' => InstallStatus.pendingUserAction,
'installing' => InstallStatus.installing,
'systemProcessing' => InstallStatus.systemProcessing,
'alreadyInProgress' => InstallStatus.alreadyInProgress,
'success' => InstallStatus.success,
'failed' => InstallStatus.failed,
'cancelled' => InstallStatus.cancelled,
_ => null,
};
}
}
/// Error codes from native side (structured, reliable)
class NativeErrorCode {
static const downloadFailed = 'downloadFailed';
static const hashMismatch = 'hashMismatch';
static const invalidFile = 'invalidFile';
static const installFailed = 'installFailed';
static const certMismatch = 'certMismatch';
static const permissionDenied = 'permissionDenied';
static const insufficientStorage = 'insufficientStorage';
static const incompatible = 'incompatible';
static const blocked = 'blocked';
static const alreadyInProgress = 'alreadyInProgress';
static const installTimeout = 'installTimeout';
}
/// Android implementation of PackageManager using event-driven architecture.
///
/// The native side streams install status events via EventChannel.
/// Foreground state is auto-detected by ProcessLifecycleOwner on the native side.
/// No polling, no probing, no hanging awaits.
final class AndroidPackageManager extends PackageManager {
AndroidPackageManager(super.ref) {
_setupEventStream();
}
static const _methodChannel = MethodChannel('android_package_manager');
static const _eventChannel = EventChannel('android_package_manager/events');
bool _supportsSilentInstall = false;
int _syncGeneration = 0;
StreamSubscription<dynamic>? _eventSubscription;
/// Tracks appIds where we've already attempted to abort orphaned sessions.
/// Prevents spamming abort calls when native keeps sending events.
final Set<String> _abortedOrphans = {};
@override
String get platform => 'android-arm64-v8a';
@override
String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => _supportsSilentInstall;
// ═══════════════════════════════════════════════════════════════════════════
// EVENT STREAM HANDLING
// ═══════════════════════════════════════════════════════════════════════════
/// Whether we're currently attempting to reconnect the event stream
bool _isReconnecting = false;
void _setupEventStream() {
_eventSubscription?.cancel();
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleInstallEvent,
onError: (e) {
debugPrint('[PackageManager] EventChannel error: $e');
_attemptEventStreamReconnect();
},
onDone: () {
debugPrint('[PackageManager] EventChannel closed unexpectedly');
_attemptEventStreamReconnect();
},
);
}
/// Attempt to reconnect the event stream after a failure.
/// Uses exponential backoff to avoid hammering the system.
void _attemptEventStreamReconnect() {
if (_isReconnecting) return;
_isReconnecting = true;
// Reconnect after a short delay
Future.delayed(const Duration(seconds: 2), () {
_isReconnecting = false;
if (mounted) {
debugPrint('[PackageManager] Attempting EventChannel reconnect');
_setupEventStream();
}
});
}
/// Handle install status events from native side.
/// Events arrive sequentially (Dart is single-threaded), no lock needed.
void _handleInstallEvent(dynamic event) {
if (event is! Map) {
debugPrint('[PackageManager] Ignoring non-map event: $event');
return;
}
final appId = event['appId'] as String?;
final statusRaw = event['status'] as String?;
final message = event['message'] as String?;
final errorCode = event['errorCode'] as String?;
final description = event['description'] as String?;
final progress = event['progress'] as double?;
final status = InstallStatusX.tryParse(statusRaw);
if (appId == null || statusRaw == null) {
debugPrint('[PackageManager] Ignoring event with null appId or status');
return;
}
if (status == null) {
debugPrint(
'[PackageManager] Ignoring event with unknown status: $statusRaw',
);
return;
}
// Get target from existing operation state - no separate tracking needed
final existingOp = getOperation(appId);
if (existingOp == null) {
// INVARIANT: No hanging states (FEAT-001).
// Orphaned native sessions can occur when the app is killed during install.
// Abort the native session to clean up and allow user to retry from clean state.
// Only attempt abort once per appId to prevent spam when native keeps sending events.
if (_abortedOrphans.add(appId)) {
debugPrint(
'[PackageManager] No tracked operation for appId=$appId, aborting orphaned native session',
);
unawaited(abortInstall(appId));
}
return;
}
// INVARIANT: Don't process terminal events for already terminal operations.
// This prevents stale or duplicate events from corrupting state.
// For example, a stale 'cancelled' event arriving after 'success' would
// clear the Completed operation (since Completed has no filePath).
if (existingOp.isTerminal &&
(status == InstallStatus.success ||
status == InstallStatus.failed ||
status == InstallStatus.cancelled)) {
debugPrint(
'[PackageManager] Ignoring terminal event $status for already terminal operation ${existingOp.runtimeType}',
);
return;
}
final target = existingOp.target;
final filePath = existingOp.filePath;
switch (status) {
case InstallStatus.verifying:
// Kotlin started hash verification - show Verifying state
if (filePath != null) {
setOperation(appId, Verifying(target: target, filePath: filePath));
}
break;
case InstallStatus.verifyingProgress:
// Update verification progress
final existingVerify = existingOp;
if (existingVerify is Verifying && progress != null) {
setOperation(appId, existingVerify.copyWith(progress: progress));
}
break;
case InstallStatus.started:
// Install session started - transition to Installing
if (filePath != null) {
final pkg = state.installed[appId];
final isSilent = pkg?.canInstallSilently ?? false;
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: isSilent),
);
}
break;
case InstallStatus.pendingUserAction:
// User action required. Ensure we don't get stuck in Verifying if the
// STARTED event was missed; show Installing state.
if (filePath != null && existingOp is! Installing) {
final pkg = state.installed[appId];
final isSilent = pkg?.canInstallSilently ?? false;
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: isSilent),
);
}
break;
case InstallStatus.installing:
// User accepted the install dialog, system is now installing.
// Transition to Installing with isSilent=true to show "Installing..."
if (filePath != null) {
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: true),
);
}
break;
case InstallStatus.systemProcessing:
// Install session is committed and taking longer than expected.
// Cannot be cancelled - system will eventually complete or fail.
if (filePath != null) {
setOperation(
appId,
SystemProcessing(target: target, filePath: filePath),
);
}
break;
case InstallStatus.alreadyInProgress:
// Real pending dialog exists - Kotlin will also send pendingUserAction event
// which transitions to Installing. Nothing to do here.
break;
case InstallStatus.success:
_deleteFile(filePath);
// CRITICAL: Update installed package info DIRECTLY from target metadata.
// We cannot rely on syncInstalledPackages() here because Android's package
// database may not have committed yet, causing a race condition where
// we get stale data and show "Update" instead of "Open".
_updateInstalledPackage(appId, target);
// Transition to Completed state (stays in map for batch progress tracking)
setOperation(appId, Completed(target: target));
// Sync in background to get accurate info (signature hash, etc.)
// but our state machine doesn't depend on it.
unawaited(syncInstalledPackages());
// Advance to next queued install
clearInstallSlot(appId);
break;
case InstallStatus.failed:
final failureType = _errorCodeToFailureType(errorCode, message);
final userMessage = _getUserFriendlyMessage(failureType);
// Preserve technical details: combine original message and description
final technicalDetails = [
if (message != null) message,
if (description != null) description,
].join('\n\n');
setOperation(
appId,
OperationFailed(
target: target,
type: failureType,
message: userMessage,
description: technicalDetails.isNotEmpty ? technicalDetails : null,
filePath: filePath,
),
);
// Advance to next queued install
clearInstallSlot(appId);
break;
case InstallStatus.cancelled:
if (filePath != null) {
setOperation(
appId,
InstallCancelled(target: target, filePath: filePath),
);
} else {
clearOperation(appId);
}
// Advance to next queued install
clearInstallSlot(appId);
break;
}
}
@override
void setOperation(String appId, InstallOperation op) {
// Clear from aborted orphans when a new operation is set,
// so we can abort again if it becomes orphaned in a future session.
_abortedOrphans.remove(appId);
super.setOperation(appId, op);
}
@override
void onInstallReady(String appId) {
// Use base class queue processing
super.onInstallReady(appId);
}
/// Convert native error code to FailureType.
/// Uses structured error code when available, falls back to message parsing.
FailureType _errorCodeToFailureType(String? errorCode, String? message) {
// Use structured error code when available (reliable)
if (errorCode != null) {
return switch (errorCode) {
NativeErrorCode.downloadFailed => FailureType.downloadFailed,
NativeErrorCode.hashMismatch => FailureType.hashMismatch,
NativeErrorCode.invalidFile => FailureType.invalidFile,
NativeErrorCode.installFailed => FailureType.installFailed,
NativeErrorCode.certMismatch => FailureType.certMismatch,
NativeErrorCode.permissionDenied => FailureType.permissionDenied,
NativeErrorCode.insufficientStorage => FailureType.insufficientStorage,
NativeErrorCode.incompatible => FailureType.incompatible,
NativeErrorCode.blocked => FailureType.permissionDenied,
NativeErrorCode.installTimeout => FailureType.installFailed,
_ => FailureType.installFailed,
};
}
// Fallback: categorize by message content (legacy, less reliable)
if (message == null) return FailureType.installFailed;
final lower = message.toLowerCase();
if (lower.contains('signature') ||
lower.contains('certificate') ||
lower.contains('update_incompatible')) {
return FailureType.certMismatch;
}
if (lower.contains('storage') || lower.contains('space')) {
return FailureType.insufficientStorage;
}
if (lower.contains('hash') || lower.contains('verification')) {
return FailureType.hashMismatch;
}
if (lower.contains('invalid') || lower.contains('corrupt')) {
return FailureType.invalidFile;
}
if (lower.contains('permission') || lower.contains('denied')) {
return FailureType.permissionDenied;
}
if (lower.contains('incompatible') ||
lower.contains('architecture') ||
lower.contains('api level') ||
lower.contains('sdk version')) {
return FailureType.incompatible;
}
return FailureType.installFailed;
}
/// Convert failure type to user-friendly message.
/// Technical details are preserved in the description field.
String _getUserFriendlyMessage(FailureType type) {
return switch (type) {
FailureType.downloadFailed => 'Download failed. Please try again.',
FailureType.hashMismatch =>
'File verification failed. Please try downloading again.',
FailureType.invalidFile =>
'Invalid app file. Please try downloading again.',
FailureType.certMismatch =>
'Update signed by different developer. Uninstall current version to update.',
FailureType.permissionDenied =>
'Permission required. Please grant install permission and try again.',
FailureType.insufficientStorage =>
'Not enough storage space. Please free up space and try again.',
FailureType.incompatible =>
'This app is not compatible with your device.',
FailureType.installFailed => 'Installation failed. Please try again.',
};
}
void _deleteFile(String? path) {
if (path == null) return;
try {
final file = File(path);
if (file.existsSync()) file.deleteSync();
} catch (_) {}
}
/// Directly update installed package from target metadata.
/// This ensures we don't depend on Android's package DB timing.
void _updateInstalledPackage(String appId, FileMetadata target) {
final existingPkg = state.installed[appId];
final newPkg = PackageInfo(
appId: appId,
name: existingPkg?.name,
version: target.version,
versionCode: target.versionCode,
// Keep existing signature hash if available, will be updated by sync
signatureHash: existingPkg?.signatureHash ?? '',
installTime: DateTime.now(),
canInstallSilently: existingPkg?.canInstallSilently ?? false,
);
state = state.copyWith(installed: {...state.installed, appId: newPkg});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
// ═══════════════════════════════════════════════════════════════════════════
// INSTALLATION (Fire-and-forget - results come via events)
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
}) async {
// Permission is already checked by _proceedToInstall in base class
// No need for redundant _ensureInstallPermission() call here
final file = File(filePath);
if (!file.existsSync()) {
throw Exception('APK file not found: $filePath');
}
// Native side is the source of truth for install state; it will emit
// VERIFYING/STARTED/PENDING_USER_ACTION/SUCCESS/FAILED/CANCELLED events.
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('install', {
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
})
.timeout(const Duration(seconds: 30), onTimeout: () => null);
final resultMap = Map<String, dynamic>.from(result ?? {});
if (resultMap['alreadyInProgress'] == true) {
// Real pending dialog exists - Kotlin re-launched it and sent pendingUserAction
// event which transitions to Installing. Nothing to do here.
return;
}
if (resultMap['started'] != true) {
final error =
resultMap['error'] as String? ?? 'Failed to start install';
final errorCode = resultMap['errorCode'] as String?;
setOperation(
appId,
OperationFailed(
target: target,
type: _errorCodeToFailureType(errorCode, error),
message: error,
filePath: filePath,
),
);
// IMPORTANT: Clear activeInstall and advance queue on fail-to-start.
// Otherwise the install queue is stuck indefinitely (hanging state).
clearInstallSlot(appId);
}
// If started, wait for events via EventChannel
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.installFailed,
message: e.toString(),
filePath: filePath,
),
);
// IMPORTANT: Clear activeInstall and advance queue on exception.
// Otherwise the install queue is stuck indefinitely (hanging state).
clearInstallSlot(appId);
}
}
@override
Future<void> uninstall(String appId) async {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('uninstall', {
'packageName': appId,
})
.timeout(
const Duration(seconds: 60), // Uninstall needs user confirmation
onTimeout: () => <Object?, Object?>{
'isSuccess': false,
'errorMessage': 'Uninstall timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
if (resultMap['isSuccess'] != true) {
if (resultMap['cancelled'] == true) {
throw Exception('Uninstall was cancelled');
}
throw Exception(resultMap['errorMessage'] ?? 'Uninstallation failed');
}
// Remove from installed immediately (don't wait for sync)
state = state.copyWith(installed: Map.from(state.installed)..remove(appId));
// Background sync for consistency
syncInstalledPackages();
}
/// Explicitly abort an install operation.
/// This is best-effort - if the session is already committed, Android may still complete it.
Future<void> abortInstall(String appId) async {
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('abortInstall', {
'packageName': appId,
})
.timeout(
const Duration(seconds: 5),
onTimeout: () => <Object?, Object?>{'success': false},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
final wasCommitted = resultMap['wasCommitted'] as bool? ?? false;
if (wasCommitted) {
debugPrint(
'[PackageManager] abortInstall: Session was committed, Android may still complete install for $appId',
);
}
} catch (e) {
debugPrint('[PackageManager] abortInstall failed for $appId: $e');
}
// Clear operation on Dart side regardless of native result
clearOperation(appId);
}
// ═══════════════════════════════════════════════════════════════════════════
// PERMISSIONS
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> requestPermission() async {
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('requestInstallPermission')
.timeout(
const Duration(seconds: 5),
onTimeout: () => <Object?, Object?>{
'success': false,
'message': 'Request timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
if (resultMap['success'] != true) {
throw Exception(resultMap['message'] ?? 'Failed to request permission');
}
} catch (e) {
throw Exception(
'CRITICAL: "Install unknown apps" permission is required.\n\n'
'Please:\n'
'1. Go to Android Settings\n'
'2. Apps > ZapStore > Install unknown apps\n'
'3. Enable "Allow from this source"\n'
'4. Try installation again',
);
}
}
@override
Future<bool> hasPermission() async {
try {
return await _methodChannel
.invokeMethod<bool>('hasUnknownSourcesPermission')
.timeout(const Duration(seconds: 5), onTimeout: () => false) ??
false;
} catch (_) {
return false;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// APP LAUNCH
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> launchApp(String appId) async {
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('launchApp', {
'packageName': appId,
})
.timeout(
const Duration(seconds: 10),
onTimeout: () => {
'isSuccess': false,
'errorMessage': 'App launch timed out',
},
);
final resultMap = Map<String, dynamic>.from(result ?? {});
if (resultMap['isSuccess'] != true) {
throw Exception(resultMap['errorMessage'] ?? 'Failed to launch app');
}
} catch (e) {
throw Exception('Failed to launch app: $e');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC INSTALLED PACKAGES
// ═══════════════════════════════════════════════════════════════════════════
@override
Future<void> syncInstalledPackages() async {
final syncGen = ++_syncGeneration;
state = state.copyWith(isScanning: true);
try {
// Single native call - getInstalledApps already returns canInstallSilently per-app
final installedApps =
await _methodChannel
.invokeMethod<List<Object?>>('getInstalledApps', {
'includeSystemApps': false,
})
.timeout(
const Duration(seconds: 10),
onTimeout: () => <Object?>[],
) ??
[];
if (syncGen != _syncGeneration) return;
final packages = <String, PackageInfo>{};
var anyCanInstallSilently = false;
for (final appObj in installedApps) {
final app = Map<String, dynamic>.from(appObj as Map<Object?, Object?>);
final appId =
app['bundleId'] as String? ?? app['packageName'] as String? ?? '';
final name = app['name'] as String?;
final version = app['versionName'] as String? ?? '0.0.0';
final versionCode = app['versionCode'] as int?;
final signatureHash = app['signatureHash'] as String? ?? '';
final canInstallSilently = app['canInstallSilently'] as bool? ?? false;
if (canInstallSilently) anyCanInstallSilently = true;
if (appId.isNotEmpty) {
packages[appId] = PackageInfo(
appId: appId,
name: name,
version: version,
versionCode: versionCode,
signatureHash: signatureHash,
installTime: null,
canInstallSilently: canInstallSilently,
);
}
}
if (syncGen != _syncGeneration) return;
// Derive general silent install capability from per-app data
_supportsSilentInstall = anyCanInstallSilently;
// Trust the native source as the single source of truth.
// Android's SUCCESS broadcast only fires after the package is committed,
// so there's no race condition with queryable state.
state = state.copyWith(installed: packages);
await InstalledPackagesSnapshot.save(state.installed);
// Clear operations for apps where the installed version matches the target version
// This catches installs that succeeded but we missed the event
// IMPORTANT: Don't clear operations where we're updating to a NEWER version
for (final appId in packages.keys) {
final op = getOperation(appId);
if (op == null) continue;
if (op is! Installing && op is! Verifying && op is! InstallCancelled) {
continue;
}
final installedPkg = packages[appId];
final targetVc = op.target.versionCode;
final installedVc = installedPkg?.versionCode;
final targetV = op.target.version;
final installedV = installedPkg?.version;
final completed =
(targetVc != null &&
installedVc != null &&
installedVc >= targetVc) ||
(installedV != null && installedV == targetV);
// Only clear if we can establish completion reliably.
if (completed) {
debugPrint(
'[PackageManager] Sync: clearing completed operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)',
);
clearOperation(appId);
} else {
debugPrint(
'[PackageManager] Sync: keeping operation for $appId '
'(installedVc=$installedVc, targetVc=$targetVc, installedV=$installedV, targetV=$targetV)',
);
}
}
} catch (_) {
// Don't clobber state on transient errors
} finally {
state = state.copyWith(isScanning: false);
}
}
}