Files
zapstore/lib/services/package_manager/android_package_manager.dart
T

722 lines
26 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;
@override
String get platform => 'android-arm64-v8a';
@override
String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => _supportsSilentInstall;
// ═══════════════════════════════════════════════════════════════════════════
// EVENT STREAM HANDLING
// ═══════════════════════════════════════════════════════════════════════════
void _setupEventStream() {
_eventSubscription = _eventChannel.receiveBroadcastStream().listen(
_handleInstallEvent,
onError: (_) {}, // Events will resume when stream reconnects
);
}
/// 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);
debugPrint(
'[PackageManager] Received event: appId=$appId, status=$status, msg=$message, errorCode=$errorCode, desc=$description',
);
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) {
debugPrint(
'[PackageManager] WARNING: No tracked operation for appId=$appId, ignoring $status event',
);
debugPrint(
'[PackageManager] Current operations: ${state.operations.keys.toList()}',
);
return;
}
debugPrint(
'[PackageManager] Processing $status for $appId (current state: ${existingOp.runtimeType})',
);
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 and check if batch is complete
_onInstallComplete(appId);
_checkBatchComplete();
break;
case InstallStatus.failed:
setOperation(
appId,
OperationFailed(
target: target,
type: _errorCodeToFailureType(errorCode, message),
message: message ?? 'Installation failed',
description: description,
filePath: filePath,
),
);
// Advance to next queued install
_onInstallComplete(appId);
break;
case InstallStatus.cancelled:
if (filePath != null) {
setOperation(
appId,
InstallCancelled(target: target, filePath: filePath),
);
} else {
clearOperation(appId);
}
// Advance to next queued install
_onInstallComplete(appId);
break;
}
}
@override
void onInstallReady(String appId) {
// Use base class queue processing
super.onInstallReady(appId);
}
/// Clear active install tracking and advance queue.
void _onInstallComplete(String appId) {
if (activeInstall == appId) {
activeInstall = null;
}
installQueue.remove(appId);
scheduleProcessQueue();
}
/// Check if all operations are complete (terminal states).
/// If so, schedule clearing of completed operations after a delay.
void _checkBatchComplete() {
final ops = state.operations.values;
if (ops.isEmpty) return;
// Check if any operation is still in progress
final hasInProgress = ops.any((op) => op.isInProgress);
if (hasInProgress) return;
// All operations are terminal (Completed or Failed)
// Wait a moment to show "X of X updated", then clear completed ones
Future.delayed(const Duration(seconds: 3), _clearCompletedOperations);
}
/// Clear all Completed operations from the map.
/// Failed operations stay so user can see/dismiss them.
void _clearCompletedOperations() {
final completedIds = state.operations.entries
.where((e) => e.value is Completed)
.map((e) => e.key)
.toList();
if (completedIds.isEmpty) return;
final newOps = Map<String, InstallOperation>.from(state.operations);
for (final id in completedIds) {
newOps.remove(id);
}
state = state.copyWith(operations: newOps);
}
/// 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;
}
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,
),
);
// Don't auto-advance after failure
}
// If started, wait for events via EventChannel
} catch (e) {
setOperation(
appId,
OperationFailed(
target: target,
type: FailureType.installFailed,
message: e.toString(),
filePath: filePath,
),
);
// Don't auto-advance after failure
}
}
@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;
// Preserve any packages we've directly updated (they have installTime set)
// This prevents race conditions where sync returns stale data
final preserved = <String, PackageInfo>{};
for (final entry in state.installed.entries) {
if (entry.value.installTime != null &&
!packages.containsKey(entry.key)) {
// We directly updated this but sync doesn't have it yet - keep ours
preserved[entry.key] = entry.value;
} else if (entry.value.installTime != null &&
packages.containsKey(entry.key)) {
// Both have it - use sync data but preserve installTime marker
final syncPkg = packages[entry.key]!;
preserved[entry.key] = PackageInfo(
appId: syncPkg.appId,
name: syncPkg.name,
version: syncPkg.version,
versionCode: syncPkg.versionCode,
signatureHash: syncPkg.signatureHash,
installTime: entry.value.installTime,
canInstallSilently: syncPkg.canInstallSilently,
);
}
}
state = state.copyWith(installed: {...packages, ...preserved});
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);
}
}
}