diff --git a/app/src/main/java/app/morphe/manager/ui/model/SelectedApp.kt b/app/src/main/java/app/morphe/manager/ui/model/SelectedApp.kt index 35d5b3610..4322f64f7 100644 --- a/app/src/main/java/app/morphe/manager/ui/model/SelectedApp.kt +++ b/app/src/main/java/app/morphe/manager/ui/model/SelectedApp.kt @@ -14,7 +14,8 @@ sealed interface SelectedApp : Parcelable { override val version: String, val file: File, val temporary: Boolean, - val resolved: Boolean = true + val resolved: Boolean = true, + val fromInstalledDevice: Boolean = false ) : SelectedApp @Parcelize diff --git a/app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt b/app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt index 74d88e482..7a29089c6 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt @@ -17,9 +17,6 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBarsPadding @@ -40,6 +37,7 @@ import app.morphe.manager.domain.installer.InstallerManager import app.morphe.manager.domain.manager.PreferencesManager import app.morphe.manager.ui.model.State import app.morphe.manager.ui.screen.patcher.* +import app.morphe.manager.ui.screen.shared.MorpheAnimations import app.morphe.manager.ui.screen.settings.advanced.NotificationPermissionDialog import app.morphe.manager.ui.screen.settings.system.InstallerSelectionDialog import app.morphe.manager.ui.viewmodel.InstallViewModel @@ -242,15 +240,37 @@ fun PatcherScreen( val isInstalling by remember { derivedStateOf { installViewModel.installState is InstallViewModel.InstallState.Installing } } val isInstalled by remember { derivedStateOf { installViewModel.installState is InstallViewModel.InstallState.Installed } } val isError by remember { derivedStateOf { installViewModel.installState is InstallViewModel.InstallState.Error } } - val isConflict by remember { derivedStateOf { installViewModel.installState is InstallViewModel.InstallState.Conflict } } + // Conflict is expected when patching from installed (non-root): handled via dialog instead of UI state + val autoHandleConflict = patcherViewModel.patchedFromInstalledDevice && !usingMountInstall + val isConflict by remember { derivedStateOf { + installViewModel.installState is InstallViewModel.InstallState.Conflict && !autoHandleConflict + } } val installedPackageName by remember { derivedStateOf { installViewModel.installedPackageName } } val conflictPackageName by remember { derivedStateOf { (installViewModel.installState as? InstallViewModel.InstallState.Conflict)?.packageName } } val errorMessage by remember { derivedStateOf { (installViewModel.installState as? InstallViewModel.InstallState.Error)?.message } } + val showInstalledSourceConflictDialog = remember { mutableStateOf(false) } + LaunchedEffect(installState) { if (installState is InstallViewModel.InstallState.Installed) { patcherViewModel.triggerNotificationPromptIfNeeded() } + if (installState is InstallViewModel.InstallState.Conflict && autoHandleConflict) { + showInstalledSourceConflictDialog.value = true + } + } + + if (showInstalledSourceConflictDialog.value) { + InstalledSourceConflictDialog( + onUninstall = { + showInstalledSourceConflictDialog.value = false + conflictPackageName?.let { installViewModel.requestUninstall(it) } + }, + onDismiss = { + showInstalledSourceConflictDialog.value = false + installViewModel.resetInstallState() + } + ) } // Notification prompt dialog @@ -404,10 +424,7 @@ fun PatcherScreen( AnimatedContent( targetState = if (showSuccessScreen) state.currentPatcherState else PatcherState.IN_PROGRESS, - transitionSpec = { - fadeIn(animationSpec = tween(800)) togetherWith - fadeOut(animationSpec = tween(800)) - }, + transitionSpec = MorpheAnimations.fadeCrossfade(800), label = "patcher_state_animation" ) { patcherState -> when (patcherState) { diff --git a/app/src/main/java/app/morphe/manager/ui/screen/home/ExpertModeDialog.kt b/app/src/main/java/app/morphe/manager/ui/screen/home/ExpertModeDialog.kt index d5c5f4cd4..40eaa9427 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/home/ExpertModeDialog.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/home/ExpertModeDialog.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController diff --git a/app/src/main/java/app/morphe/manager/ui/screen/home/HomeDialogs.kt b/app/src/main/java/app/morphe/manager/ui/screen/home/HomeDialogs.kt index 5421812e6..5007a6846 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/home/HomeDialogs.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/home/HomeDialogs.kt @@ -44,10 +44,7 @@ import app.morphe.manager.domain.bundles.RemotePatchBundle import app.morphe.manager.domain.repository.PatchBundleRepository import app.morphe.manager.ui.model.HomeAppItem import app.morphe.manager.ui.screen.shared.* -import app.morphe.manager.ui.viewmodel.BundledAppTarget -import app.morphe.manager.ui.viewmodel.HomeViewModel -import app.morphe.manager.ui.viewmodel.InstalledAppInfoViewModel -import app.morphe.manager.ui.viewmodel.SavedApkInfo +import app.morphe.manager.ui.viewmodel.* import app.morphe.manager.util.* import app.morphe.patcher.patch.AppTarget import kotlinx.coroutines.delay @@ -87,6 +84,8 @@ fun HomeDialogs( val usingMountInstall = homeViewModel.usingMountInstall val isExpertMode = homeViewModel.prefs.useExpertMode.getBlocking() val savedApkInfo = homeViewModel.pendingSavedApkInfo + val installedApkInfo = homeViewModel.pendingInstalledApkInfo + val targetAppInstalled = homeViewModel.pendingTargetAppInstalled == true ApkAvailabilityDialog( appName = appName, @@ -96,8 +95,10 @@ fun HomeDialogs( selectedDownloadVersion = selectedDownloadVersion, onVersionSelect = { homeViewModel.pendingSelectedDownloadVersion = it }, usingMountInstall = usingMountInstall, + targetAppInstalled = targetAppInstalled, isExpertMode = isExpertMode, savedApkInfo = savedApkInfo, + installedApkInfo = installedApkInfo, onDismiss = { homeViewModel.showApkAvailabilityDialog = false homeViewModel.cleanupPendingData() @@ -116,6 +117,9 @@ fun HomeDialogs( }, onUseSaved = { homeViewModel.handleSavedApkSelection() + }, + onUseInstalled = { + homeViewModel.handleInstalledApkSelection() } ) } @@ -146,6 +150,7 @@ fun HomeDialogs( DownloadInstructionsDialog( usingMountInstall = usingMountInstall, + targetAppInstalled = homeViewModel.pendingTargetAppInstalled == true, downloadColor = downloadColor, isApkBundle = isApkBundle, onDismiss = { @@ -543,12 +548,15 @@ private fun ApkAvailabilityDialog( selectedDownloadVersion: AppTarget?, onVersionSelect: (AppTarget) -> Unit, usingMountInstall: Boolean, + targetAppInstalled: Boolean, isExpertMode: Boolean, savedApkInfo: SavedApkInfo?, + installedApkInfo: InstalledApkInfo?, onDismiss: () -> Unit, onHaveApk: () -> Unit, onNeedApk: () -> Unit, - onUseSaved: () -> Unit + onUseSaved: () -> Unit, + onUseInstalled: () -> Unit ) { val deviceSdk = Build.VERSION.SDK_INT @@ -582,8 +590,15 @@ private fun ApkAvailabilityDialog( layout = DialogButtonLayout.Vertical ) - // Saved APK button (if available) - if (savedApkInfo != null) { + // When the installed app uses split APKs and the saved original covers the + // same version, prefer the saved merged mono-APK. + // Hide the installed button in that case + val preferSavedOverInstalled = installedApkInfo?.isSplit == true && + savedApkInfo != null && savedApkInfo.version == installedApkInfo.version + + // Saved APK button - hidden when a single-APK install covers the same version + if (savedApkInfo != null && + (preferSavedOverInstalled || installedApkInfo == null || savedApkInfo.version != installedApkInfo.version)) { MorpheDialogOutlinedButton( text = stringResource( R.string.home_apk_use_saved_with_version, @@ -594,6 +609,19 @@ private fun ApkAvailabilityDialog( modifier = Modifier.fillMaxWidth() ) } + + // Installed APK button - hidden when saved mono-APK covers the same split version + if (installedApkInfo != null && !preferSavedOverInstalled) { + MorpheDialogOutlinedButton( + text = stringResource( + R.string.home_apk_use_installed_with_version, + installedApkInfo.version + ), + onClick = onUseInstalled, + icon = Icons.Outlined.PhoneAndroid, + modifier = Modifier.fillMaxWidth() + ) + } } } ) { @@ -658,8 +686,8 @@ private fun ApkAvailabilityDialog( ) } - // Root mode warning - if (usingMountInstall) { + // Root mode warning - only when app is not yet installed + if (usingMountInstall && !targetAppInstalled) { InfoBadge( text = stringResource(R.string.root_install_apk_required), style = InfoBadgeStyle.Warning, @@ -679,6 +707,7 @@ private fun ApkAvailabilityDialog( @Composable private fun DownloadInstructionsDialog( usingMountInstall: Boolean, + targetAppInstalled: Boolean, downloadColor: Color, isApkBundle: Boolean, onDismiss: () -> Unit, @@ -771,11 +800,13 @@ private fun DownloadInstructionsDialog( } } + val mountInstallRequired = usingMountInstall && !targetAppInstalled + InstructionStep( number = "3", text = htmlAnnotatedString( stringResource( - if (usingMountInstall) { + if (mountInstallRequired) { R.string.home_download_instructions_step3_mount } else { R.string.home_download_instructions_step3 @@ -789,7 +820,7 @@ private fun DownloadInstructionsDialog( InstructionStep( number = "4", text = stringResource( - if (usingMountInstall) R.string.home_download_instructions_step4_mount + if (mountInstallRequired) R.string.home_download_instructions_step4_mount else R.string.home_download_instructions_step4 ), textColor = textColor, diff --git a/app/src/main/java/app/morphe/manager/ui/screen/home/InstalledAppInfoDialog.kt b/app/src/main/java/app/morphe/manager/ui/screen/home/InstalledAppInfoDialog.kt index 929c39b23..9e98b6882 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/home/InstalledAppInfoDialog.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/home/InstalledAppInfoDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.automirrored.outlined.List import androidx.compose.material.icons.outlined.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -329,6 +330,7 @@ fun InstalledAppInfoDialog( appliedPatches = appliedPatches, bundlesUsedSummary = bundlesUsedSummary, onShowPatches = { showAppliedPatchesDialog.value = true }, + accentColor = appAccentColor ) } } @@ -360,6 +362,7 @@ fun InstalledAppInfoDialog( onClick = { onTriggerPatchFlow(installedApp.originalPackageName) }, + accentColor = appAccentColor, isError = true ) } @@ -379,6 +382,7 @@ fun InstalledAppInfoDialog( onClick = { onTriggerPatchFlow(installedApp.originalPackageName) }, + accentColor = appAccentColor, isError = false ) } @@ -460,6 +464,7 @@ fun InstalledAppInfoDialog( onClick = { onTriggerPatchFlow(installedApp.originalPackageName) }, + accentColor = appAccentColor, isError = true, modifier = Modifier.padding(horizontal = 20.dp) ) @@ -483,6 +488,7 @@ fun InstalledAppInfoDialog( onClick = { onTriggerPatchFlow(installedApp.originalPackageName) }, + accentColor = appAccentColor, isError = false, modifier = Modifier.padding(horizontal = 20.dp) ) @@ -502,6 +508,7 @@ fun InstalledAppInfoDialog( appliedPatches = appliedPatches, bundlesUsedSummary = bundlesUsedSummary, onShowPatches = { showAppliedPatchesDialog.value = true }, + accentColor = appAccentColor ) } } @@ -574,19 +581,22 @@ private fun WarningBanner( buttonText: String, buttonIcon: ImageVector, onClick: () -> Unit, + accentColor: Color, modifier: Modifier = Modifier, isError: Boolean = false ) { - val containerColor = if (isError) { - MaterialTheme.colorScheme.errorContainer + val baseColor = if (isError) MaterialTheme.colorScheme.error else accentColor + val isExtremeAccent = baseColor.luminance() !in 0.04f..0.92f + val containerColor = if (isExtremeAccent) { + MaterialTheme.colorScheme.surfaceVariant } else { - MaterialTheme.colorScheme.primaryContainer + baseColor.copy(alpha = 0.15f) } - - val contentColor = if (isError) { - MaterialTheme.colorScheme.onErrorContainer + val contentColor = if (isExtremeAccent) { + MaterialTheme.colorScheme.onSurfaceVariant } else { - MaterialTheme.colorScheme.onPrimaryContainer + if (baseColor.compositeOver(MaterialTheme.colorScheme.surface, 0.15f) + .requiresLightContent()) Color.White else Color.Black } Column( @@ -630,7 +640,8 @@ private fun WarningBanner( // Action button PrimaryActionButton( action = ActionItem(text = buttonText, icon = buttonIcon, onClick = onClick), - accentColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + accentColor = baseColor, + contentColorOverride = contentColor, modifier = Modifier.fillMaxWidth() ) } @@ -887,6 +898,7 @@ private fun InfoSection( appliedPatches: Map>?, bundlesUsedSummary: String, onShowPatches: () -> Unit, + accentColor: Color, modifier: Modifier = Modifier ) { val totalPatches = appliedPatches?.values?.sumOf { it.size } ?: 0 @@ -939,6 +951,7 @@ private fun InfoSection( icon = Icons.Outlined.DoneAll, label = stringResource(R.string.home_app_info_applied_patches), value = pluralStringResource(R.plurals.patch_count, totalPatches, totalPatches), + accentColor = accentColor, onAction = onShowPatches ) } @@ -996,6 +1009,7 @@ private fun InfoRowWithAction( icon: ImageVector, label: String, value: String, + accentColor: Color, onAction: () -> Unit, ) { Row( @@ -1030,7 +1044,12 @@ private fun InfoRowWithAction( ActionPillButton( onClick = onAction, icon = Icons.AutoMirrored.Outlined.List, - contentDescription = stringResource(R.string.view) + contentDescription = stringResource(R.string.view), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = accentColor.copy(alpha = 0.18f), + contentColor = if (accentColor.compositeOver(MaterialTheme.colorScheme.surface, 0.18f) + .requiresLightContent()) Color.White else Color.Black + ) ) } } @@ -1279,7 +1298,8 @@ private fun LoadingOrIcon(isLoading: Boolean, action: ActionItem, tint: Color) { private fun PrimaryActionButton( action: ActionItem, accentColor: Color, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + contentColorOverride: Color? = null ) { // Match the header background level: tinted surface instead of full accent val isExtremeAccent = accentColor.luminance() !in 0.04f..0.92f @@ -1288,7 +1308,12 @@ private fun PrimaryActionButton( } else { accentColor.copy(alpha = 0.18f) } - val contentColor = MaterialTheme.colorScheme.onSurface + val contentColor = contentColorOverride ?: if (isExtremeAccent) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + if (accentColor.compositeOver(MaterialTheme.colorScheme.surface, 0.18f) + .requiresLightContent()) Color.White else Color.Black + } Surface( onClick = action.onClick, enabled = action.enabled && !action.isLoading, diff --git a/app/src/main/java/app/morphe/manager/ui/screen/home/SectionsLayout.kt b/app/src/main/java/app/morphe/manager/ui/screen/home/SectionsLayout.kt index fe9dfc70e..34f8f5917 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/home/SectionsLayout.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/home/SectionsLayout.kt @@ -294,6 +294,7 @@ private fun AdaptiveContent( searchQuery = searchQuery, onSearchQueryChange = onSearchQueryChange, onBundlesClick = onBundlesClick, + showFadeOverlay = false, modifier = Modifier.weight(1f) ) @@ -755,7 +756,8 @@ fun MainAppsSection( searchVisible: Boolean = false, searchQuery: String = "", onSearchQueryChange: (String) -> Unit = {}, - onBundlesClick: () -> Unit = {} + onBundlesClick: () -> Unit = {}, + showFadeOverlay: Boolean = true ) { // Multi-select state - set of packageNames chosen for bulk hide var selectedPackages by remember { mutableStateOf(emptySet()) } @@ -1051,7 +1053,7 @@ fun MainAppsSection( animationSpec = tween(150), label = "fade_bottom_alpha" ) - if (topAlpha > 0f || bottomAlpha > 0f) { + if (showFadeOverlay && (topAlpha > 0f || bottomAlpha > 0f)) { val bgColor = MaterialTheme.colorScheme.background val fadePx = with(LocalDensity.current) { 8.dp.toPx() } // Fade size Box( diff --git a/app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt b/app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt index a63b74817..5f7f357e1 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/home/SourceManagementDialogs.kt @@ -165,9 +165,7 @@ fun AddSourceDialog( // Tab content AnimatedContent( targetState = selectedTab, - transitionSpec = { - MorpheAnimations.fadeIn.togetherWith(MorpheAnimations.fadeOut) - } + transitionSpec = MorpheAnimations.fadeCrossfade() ) { tab -> when (tab) { 0 -> RemoteTabContent( @@ -1252,15 +1250,22 @@ fun BundleChangelogDialog( } } ) { - when (val current = state) { - BundleChangelogState.Loading -> ChangelogSectionLoading() - is BundleChangelogState.Error -> BundleChangelogError(error = current.throwable) - is BundleChangelogState.Entries -> ChangelogEntriesList( - entries = current.entries, - headerIcon = Icons.Outlined.History, - emptyText = stringResource(R.string.changelog_empty), - textColor = LocalDialogTextColor.current - ) + AnimatedContent( + targetState = state, + transitionSpec = MorpheAnimations.fadeCrossfade(), + contentKey = { it::class }, + label = "changelog_content" + ) { current -> + when (current) { + BundleChangelogState.Loading -> ChangelogSectionLoading() + is BundleChangelogState.Error -> BundleChangelogError(error = current.throwable) + is BundleChangelogState.Entries -> ChangelogEntriesList( + entries = current.entries, + headerIcon = Icons.Outlined.History, + emptyText = stringResource(R.string.changelog_empty), + textColor = LocalDialogTextColor.current + ) + } } } } diff --git a/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherDialogs.kt b/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherDialogs.kt index 7b3b1f058..b1f447690 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherDialogs.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherDialogs.kt @@ -105,6 +105,36 @@ fun IncompatiblePatcherVersionDialog( } } +/** + * Shown when a conflict is detected after patching from the installed app (non-root). + * Explains why uninstall is needed and warns about data loss, then triggers system uninstall. + */ +@Composable +fun InstalledSourceConflictDialog( + onUninstall: () -> Unit, + onDismiss: () -> Unit +) { + MorpheDialog( + onDismissRequest = onDismiss, + title = stringResource(R.string.patcher_installed_conflict_title), + footer = { + MorpheDialogButtonRow( + primaryText = stringResource(R.string.uninstall), + onPrimaryClick = onUninstall, + isPrimaryDestructive = true, + secondaryText = stringResource(android.R.string.cancel), + onSecondaryClick = onDismiss + ) + } + ) { + Text( + text = stringResource(R.string.patcher_installed_conflict_body), + style = MaterialTheme.typography.bodyLarge, + color = LocalDialogSecondaryTextColor.current + ) + } +} + /** * Cancel patching confirmation dialog. * Warns user about stopping patching process. diff --git a/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherStates.kt b/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherStates.kt index 3722e3710..1b8859b7b 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherStates.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/patcher/PatcherStates.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.InstallMobile +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Link import androidx.compose.material3.* import androidx.compose.runtime.* @@ -294,6 +295,8 @@ private fun AdaptiveSuccessContent( isError = isError ) + SuccessConflictHint(isConflict = isConflict) + SuccessRootWarning( usingMountInstall = usingMountInstall, isReady = !isInstalling && !isInstalled && !isError && !isConflict @@ -353,6 +356,8 @@ private fun AdaptiveSuccessContent( isError = isError ) + SuccessConflictHint(isConflict = isConflict) + SuccessRootWarning( usingMountInstall = usingMountInstall, isReady = !isInstalling && !isInstalled && !isError && !isConflict @@ -499,6 +504,20 @@ private fun SuccessErrorMessage( } } +/** + * Success screen conflict hint. + */ +@Composable +private fun SuccessConflictHint(isConflict: Boolean) { + SuccessHint( + visible = isConflict, + text = stringResource(R.string.patcher_conflict_hint), + icon = Icons.Outlined.Warning, + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + iconTint = MaterialTheme.colorScheme.error + ) +} + /** * Success screen root warning. */ @@ -507,17 +526,51 @@ private fun SuccessRootWarning( usingMountInstall: Boolean, isReady: Boolean ) { - AnimatedVisibility( + SuccessHint( visible = usingMountInstall && isReady, + text = stringResource(R.string.root_gmscore_excluded), + icon = Icons.Outlined.Info, + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + iconTint = MaterialTheme.colorScheme.primary + ) +} + +@Composable +private fun SuccessHint( + visible: Boolean, + text: String, + icon: ImageVector, + containerColor: Color, + iconTint: Color +) { + AnimatedVisibility( + visible = visible, enter = MorpheAnimations.fadeIn, exit = MorpheAnimations.fadeOut ) { - InfoBadge( - text = stringResource(R.string.root_gmscore_excluded), - style = InfoBadgeStyle.Primary, - icon = Icons.Outlined.Info, - isCentered = true - ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = containerColor + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(20.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } } } @@ -537,16 +590,13 @@ private fun InstallActionButton( onOpen: () -> Unit, modifier: Modifier = Modifier ) { - val buttonColors = when { - isInstalled -> ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - isConflict || isError -> ButtonDefaults.buttonColors( + val buttonColors = if (isConflict || isError) { + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError ) - else -> ButtonDefaults.buttonColors( + } else { + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary ) @@ -557,7 +607,6 @@ private fun InstallActionButton( when { isInstalled -> onOpen() isConflict -> conflictPackageName?.let { onUninstall(it) } - isError -> onInstall() else -> onInstall() } }, @@ -587,7 +636,6 @@ private fun InstallActionButton( imageVector = when { isInstalled -> Icons.AutoMirrored.Outlined.Launch isConflict -> Icons.Default.DeleteForever - isError -> Icons.Outlined.InstallMobile usingMountInstall -> Icons.Outlined.Link else -> Icons.Outlined.InstallMobile }, @@ -600,7 +648,6 @@ private fun InstallActionButton( when { isInstalled -> R.string.open isConflict -> R.string.uninstall - isError -> R.string.install usingMountInstall -> R.string.mount else -> R.string.install } diff --git a/app/src/main/java/app/morphe/manager/ui/screen/shared/ChangelogSection.kt b/app/src/main/java/app/morphe/manager/ui/screen/shared/ChangelogSection.kt index 10310fbdc..e927d8e05 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/shared/ChangelogSection.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/shared/ChangelogSection.kt @@ -214,6 +214,7 @@ fun Changelog( ) { Markdown( content = markdown.trimIndent(), + immediate = true, retainState = true, colors = markdownColor( text = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/app/morphe/manager/ui/screen/shared/MorpheDialog.kt b/app/src/main/java/app/morphe/manager/ui/screen/shared/MorpheDialog.kt index 40930a379..5683ca3ae 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/shared/MorpheDialog.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/shared/MorpheDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.* @@ -150,7 +151,8 @@ private fun DialogContent( if (noPadding) { CompositionLocalProvider( LocalDialogTextColor provides textColor, - LocalDialogSecondaryTextColor provides secondaryTextColor + LocalDialogSecondaryTextColor provides secondaryTextColor, + LocalContentColor provides textColor ) { Column( modifier = Modifier @@ -218,7 +220,8 @@ private fun DialogContent( // Content area with conditional scrolling CompositionLocalProvider( LocalDialogTextColor provides textColor, - LocalDialogSecondaryTextColor provides secondaryTextColor + LocalDialogSecondaryTextColor provides secondaryTextColor, + LocalContentColor provides textColor ) { if (scrollable) { // Automatic scroll for regular content @@ -249,7 +252,8 @@ private fun DialogContent( ) { CompositionLocalProvider( LocalDialogTextColor provides textColor, - LocalDialogSecondaryTextColor provides secondaryTextColor + LocalDialogSecondaryTextColor provides secondaryTextColor, + LocalContentColor provides textColor ) { footer() } diff --git a/app/src/main/java/app/morphe/manager/ui/viewmodel/HomeViewModel.kt b/app/src/main/java/app/morphe/manager/ui/viewmodel/HomeViewModel.kt index 5608cfc70..cbf627b47 100644 --- a/app/src/main/java/app/morphe/manager/ui/viewmodel/HomeViewModel.kt +++ b/app/src/main/java/app/morphe/manager/ui/viewmodel/HomeViewModel.kt @@ -10,14 +10,12 @@ import android.app.Application import android.content.ContentResolver import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.StatFs import android.provider.OpenableColumns import android.util.Log import android.widget.Toast -import androidx.annotation.RequiresApi import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -58,9 +56,12 @@ import kotlinx.coroutines.flow.* import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant import java.io.File +import java.io.FileInputStream import java.io.FileNotFoundException import java.net.URLEncoder.encode -import java.security.MessageDigest +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import kotlin.time.Clock /** Bundle update status for snackbar display. */ @@ -121,6 +122,15 @@ data class SavedApkInfo( val version: String ) +/** Installed APK information for display in APK selection dialog. */ +data class InstalledApkInfo( + val version: String, + val apkPath: String, + val splitPaths: List = emptyList() +) { + val isSplit: Boolean get() = splitPaths.isNotEmpty() +} + /** * Combined home screen app state - emitted atomically so visible and hidden lists * are always in sync and never cause a transient empty-state flash. @@ -267,6 +277,9 @@ class HomeViewModel( var pendingSelectedApp by mutableStateOf(null) var resolvedDownloadUrl by mutableStateOf(null) var pendingSavedApkInfo by mutableStateOf(null) + var pendingInstalledApkInfo by mutableStateOf(null) + // null = not yet loaded, true/false = loaded result + var pendingTargetAppInstalled by mutableStateOf(null) // Bundle update snackbar state var showBundleUpdateSnackbar by mutableStateOf(false) @@ -513,17 +526,21 @@ class HomeViewModel( } } + private fun clearPendingApp() { + val app = pendingSelectedApp + pendingSelectedApp = null + if (app is SelectedApp.Local && app.temporary) { + app.file.delete() + } + } + /** * User dismissed the split APK warning without proceeding. * Cleans up the temporary file if needed. */ fun dismissSplitApkWarning() { - val app = pendingSelectedApp showSplitApkWarningDialog = false - pendingSelectedApp = null - if (app is SelectedApp.Local && app.temporary) { - app.file.delete() - } + clearPendingApp() } /** @@ -532,42 +549,38 @@ class HomeViewModel( */ fun dismissUnsupportedVersionDialog() { showUnsupportedVersionDialog = null - val app = pendingSelectedApp - pendingSelectedApp = null - if (app is SelectedApp.Local && app.temporary) { - app.file.delete() - } + clearPendingApp() } - /** - * User chose to proceed patching with an unsupported app version. - * Starts patching with allowIncompatible=true so version-incompatible patches are included. - */ - fun proceedWithUnsupportedVersion() { - showUnsupportedVersionDialog = null + private fun proceedWithPendingApp(allowIncompatible: Boolean) { val app = pendingSelectedApp ?: return pendingSelectedApp = null viewModelScope.launch { if (rootInstaller.isDeviceRooted()) { - requestPrePatchInstallerSelection(app, allowIncompatible = true) + requestPrePatchInstallerSelection(app, allowIncompatible = allowIncompatible) } else { usingMountInstall = false - startPatchingWithApp(app, allowIncompatible = true) + startPatchingWithApp(app, allowIncompatible = allowIncompatible) } } } + /** + * User chose to proceed patching with an unsupported app version. + * Starts patching with allowIncompatible=true so version-incompatible patches are included. + */ + fun proceedWithUnsupportedVersion() { + showUnsupportedVersionDialog = null + proceedWithPendingApp(allowIncompatible = true) + } + /** * User dismissed the experimental version warning dialog. * Discards the pending selection and cleans up the temporary file if needed. */ fun dismissExperimentalVersionDialog() { showExperimentalVersionDialog = null - val app = pendingSelectedApp - pendingSelectedApp = null - if (app is SelectedApp.Local && app.temporary) { - app.file.delete() - } + clearPendingApp() } /** @@ -577,16 +590,7 @@ class HomeViewModel( */ fun proceedWithExperimentalVersion() { showExperimentalVersionDialog = null - val app = pendingSelectedApp ?: return - pendingSelectedApp = null - viewModelScope.launch { - if (rootInstaller.isDeviceRooted()) { - requestPrePatchInstallerSelection(app, allowIncompatible = false) - } else { - usingMountInstall = false - startPatchingWithApp(app, allowIncompatible = false) - } - } + proceedWithPendingApp(allowIncompatible = false) } /** @@ -602,11 +606,7 @@ class HomeViewModel( */ fun dismissInvalidSignatureDialog() { showInvalidSignatureDialog = null - val app = pendingSelectedApp - pendingSelectedApp = null - if (app is SelectedApp.Local && app.temporary) { - app.file.delete() - } + clearPendingApp() } /** @@ -1342,6 +1342,10 @@ class HomeViewModel( pendingCompatibleVersions = compatibleVersions[packageName] ?: emptyList() pendingRecommendedBundleVersions = recommendedBundleVersions[packageName] ?: emptyMap() pendingSelectedDownloadVersion = pendingRecommendedVersion + // Reset per-package cached state so a new flow always loads fresh data + pendingSavedApkInfo = null + pendingInstalledApkInfo = null + pendingTargetAppInstalled = null // Guard: if there is a pending bundle update on metered data, show the outdated-patches // dialog before proceeding with the actual APK selection flow. @@ -1401,14 +1405,27 @@ class HomeViewModel( * open the APK availability dialog. */ private suspend fun continueApkSelectionFlow(packageName: String) { - // Reload saved APK info in case it wasn't loaded yet (called from proceedWithSelectedBundle) - if (pendingSavedApkInfo == null) { - pendingSavedApkInfo = withContext(Dispatchers.IO) { loadSavedApkInfo(packageName) } + // Load saved APK (Room + AppDataResolver) and, in expert mode only, installed APK + // (PackageManager) in parallel. In simple mode the installed-APK button is hidden, + // so we skip the PM lookup entirely to keep simple-mode behavior unchanged + val expertMode = isExpertMode() + coroutineScope { + val savedJob = if (pendingSavedApkInfo == null) { + async(Dispatchers.IO) { loadSavedApkInfo(packageName) } + } else null + val installedJob = if (expertMode && pendingTargetAppInstalled == null) { + async(Dispatchers.IO) { loadInstalledInfo(packageName) } + } else null + savedJob?.await()?.let { pendingSavedApkInfo = it } + installedJob?.await()?.let { (installed, info) -> + pendingTargetAppInstalled = installed + pendingInstalledApkInfo = info?.takeIf { isInstalledVersionCompatible(it.version) } + } } val recommendedVersion = pendingRecommendedVersion - val shouldAutoUseSaved = !isExpertMode() && + val shouldAutoUseSaved = !expertMode && pendingSavedApkInfo != null && recommendedVersion != null && pendingSavedApkInfo!!.version == recommendedVersion.version @@ -1452,6 +1469,164 @@ class HomeViewModel( } } + /** + * Returns true if [installedVersion] is listed in [pendingCompatibleVersions], + * or if the compatible list is empty / contains an "any version" target. + */ + private fun isInstalledVersionCompatible(installedVersion: String): Boolean { + val compatible = pendingCompatibleVersions + if (compatible.isEmpty() || compatible.any { it.target.version == null }) return true + return compatible.any { it.target.version == installedVersion } + } + + /** + * Returns whether the target app is installed and, if it is a single unpatched APK, its info. + * First element: true if the package is installed at all (regardless of splits/version). + * Second element: non-null only for single-APK installs that appear to be the original app. + * + * The "Use installed APK" button is suppressed when: + * - Morphe tracks this package as a patched install, or + * - The installed signing certificate doesn't match the bundle's expected original signatures. + */ + private suspend fun loadInstalledInfo(packageName: String): Pair { + return try { + val pkgInfo = pm.getPackageInfo(packageName) + ?: return false to null + + // Determine if the installed app is patched, in priority order: + // 1. Saved original APK (most reliable - direct signature comparison) + // 2. Bundle-declared expected signatures (fallback) + // 3. DB tracking (last resort - version match only) + val isPatched: Boolean = run { + val savedOriginal = originalApkRepository.get(packageName) + val savedFile = savedOriginal?.let { File(it.filePath) } + if (savedFile?.exists() == true) { + val savedHashes = pm.getApkFileSignatureHashes(savedFile) + if (savedHashes.isNotEmpty()) { + val installedHashes = pm.getInstalledSignatureHashes(packageName) + if (installedHashes.isNotEmpty()) { + return@run installedHashes.none { it in savedHashes } + } + // Can't read installed signatures → fall through to other checks + } + // Can't read signatures from file → fall through to other checks + } + val expectedSignatures = bundleAppMetadataFlow.value[packageName]?.signatures + if (!expectedSignatures.isNullOrEmpty()) { + pm.getInstalledSignatureHashes(packageName).none { it in expectedSignatures } + } else { + val trackedPatch = installedAppRepository.get(packageName) + trackedPatch != null && pkgInfo.versionName == trackedPatch.version + } + } + if (isPatched) return true to null + + val appInfo = pkgInfo.applicationInfo + ?: return true to null + val sourceDir = appInfo.sourceDir ?: return true to null + if (!File(sourceDir).exists()) return true to null + val version = pkgInfo.versionName?.takeUnless { it.isBlank() } + ?: return true to null + val splitPaths = appInfo.splitSourceDirs + ?.filter { File(it).exists() } + ?: emptyList() + true to InstalledApkInfo(version = version, apkPath = sourceDir, splitPaths = splitPaths) + } catch (e: Exception) { + Log.e(tag, "Failed to load installed app info", e) + false to null + } + } + + /** + * Handle selection of the currently installed APK from the APK availability dialog. + * For single APKs: copies to a temp file. For split APKs: packs into a temp .apks archive. + * The source is NOT saved to the original-APK repository. + */ + fun handleInstalledApkSelection() { + val installedInfo = pendingInstalledApkInfo + val packageName = pendingPackageName + + if (installedInfo == null || packageName == null) { + cleanupPendingData() + return + } + + viewModelScope.launch { + showApkAvailabilityDialog = false + + val selectedApp = withContext(Dispatchers.IO) { + try { + if (installedInfo.isSplit) { + val archive = File(filesystem.uiTempDir, "${packageName}_installed.apks") + createApksArchive(installedInfo, archive) + SelectedApp.Local( + packageName = packageName, + version = installedInfo.version, + file = archive, + temporary = true, + fromInstalledDevice = true + ) + } else { + val source = File(installedInfo.apkPath) + if (!source.exists()) return@withContext null + val tempFile = File(filesystem.uiTempDir, "${packageName}_installed.apk") + source.copyTo(tempFile, overwrite = true) + SelectedApp.Local( + packageName = packageName, + version = installedInfo.version, + file = tempFile, + temporary = true, + fromInstalledDevice = true + ) + } + } catch (e: Exception) { + Log.e(tag, "Failed to prepare installed APK", e) + null + } + } + + if (selectedApp != null) { + // Skip signature check: the installed APK may already be patched with our key + processSelectedAppIgnoringSignature(selectedApp) + } else { + app.toast(app.getString(R.string.home_invalid_apk_io_error)) + cleanupPendingData() + } + } + } + + /** + * Packs [info]'s base APK and all split APKs into an APKS archive (ZIP). + * Entry names preserve the original filenames so [SplitApkPreparer] can + * identify the base entry by the "base" substring and filter ABI/density splits. + */ + private fun createApksArchive(info: InstalledApkInfo, output: File) { + output.parentFile?.mkdirs() + ZipOutputStream(output.outputStream().buffered()).use { zip -> + fun addEntry(file: File) { + // APKs are already compressed ZIPs - use STORED to avoid wasting CPU on deflate. + // STORED requires CRC32 and size known upfront, so we read the file twice. + val crc = CRC32() + val buf = ByteArray(65536) + FileInputStream(file).use { input -> + var n: Int + while (input.read(buf).also { n = it } >= 0) crc.update(buf, 0, n) + } + val entry = ZipEntry(file.name).apply { + method = ZipEntry.STORED + size = file.length() + compressedSize = file.length() + this.crc = crc.value + } + zip.putNextEntry(entry) + FileInputStream(file).use { it.copyTo(zip) } + zip.closeEntry() + } + addEntry(File(info.apkPath)) + info.splitPaths.forEach { addEntry(File(it)) } + } + } + /** * Handle APK file selection. */ @@ -1582,13 +1757,13 @@ class HomeViewModel( true } else { try { - verifyApkSignature(extracted.file.absolutePath, expectedSignatures) + pm.getApkFileSignatureHashes(extracted.file).any { it in expectedSignatures } } finally { extracted.cleanup() } } } else { - verifyApkSignature(selectedApp.file.absolutePath, expectedSignatures) + pm.getApkFileSignatureHashes(selectedApp.file).any { it in expectedSignatures } } } if (!signatureMatch) { @@ -1696,17 +1871,13 @@ class HomeViewModel( // because it affects which patches are included (GmsCore is excluded for mount install). // Show the pre-patching installer dialog so the user can choose. // For non-root devices, just proceed - installer selection happens after patching. - if (rootInstaller.isDeviceRooted()) { - requestPrePatchInstallerSelection(selectedApp, allowIncompatible) - } else { - usingMountInstall = false - startPatchingWithApp(selectedApp, allowIncompatible) - } + processSelectedAppIgnoringSignature(selectedApp) } /** - * Called when the user confirms proceeding despite an APK signature mismatch. - * Skips the signature verification step and continues with the normal flow. + * Skips all preliminary checks (signature, version, bundle) and routes directly to patching. + * Used when the user confirms proceeding despite a signature mismatch, or when patching + * from the installed app where checks are not applicable. */ suspend fun processSelectedAppIgnoringSignature(selectedApp: SelectedApp) { val allowIncompatible = prefs.disablePatchVersionCompatCheck.getBlocking() @@ -2098,6 +2269,16 @@ class HomeViewModel( repatchPackageName = null } + private suspend fun saveSeenPatchesForBundles(packageName: String) { + expertModeBundles.forEach { bundle -> + patchSelectionRepository.saveSeenPatches( + packageName = packageName, + bundleUid = bundle.uid, + patchNames = bundle.patches.map { it.name }.toSet() + ) + } + } + /** * Called when the user confirms the ExpertModeDialog. * Routes to the repatch flow (via [onRepatchProceed]) or the normal patching flow @@ -2123,13 +2304,7 @@ class HomeViewModel( // Snapshot seen patches before cleanup clears expertModeBundles. val pkgName = repatchPackageName if (pkgName != null) { - expertModeBundles.forEach { bundle -> - patchSelectionRepository.saveSeenPatches( - packageName = pkgName, - bundleUid = bundle.uid, - patchNames = bundle.patches.map { it.name }.toSet() - ) - } + saveSeenPatchesForBundles(pkgName) } withContext(Dispatchers.Main) { repatchCallback(finalPatches, patcherOptions) @@ -2143,13 +2318,7 @@ class HomeViewModel( ) saveOptions(selectedApp.packageName, finalOptions) // Snapshot all bundle patch names so next open can detect genuinely new patches. - expertModeBundles.forEach { bundle -> - patchSelectionRepository.saveSeenPatches( - packageName = selectedApp.packageName, - bundleUid = bundle.uid, - patchNames = bundle.patches.map { it.name }.toSet() - ) - } + saveSeenPatchesForBundles(selectedApp.packageName) withContext(Dispatchers.Main) { proceedWithPatching(selectedApp, finalPatches, patcherOptions) cleanupExpertModeData() @@ -2245,6 +2414,8 @@ class HomeViewModel( if (!keepBundleUid) pendingSelectedBundleUid = null resolvedDownloadUrl = null pendingSavedApkInfo = null + pendingInstalledApkInfo = null + pendingTargetAppInstalled = null if (!keepSelectedApp) { pendingSelectedApp?.let { app -> if (app is SelectedApp.Local && app.temporary) { @@ -2325,46 +2496,6 @@ class HomeViewModel( .filterValues { it.isNotEmpty() } } - /** - * Verify that the APK at [apkPath] is signed with one of the [expectedSha256Signatures]. - * - * Returns true if at least one certificate fingerprint matches. - * An empty [expectedSha256Signatures] is treated as "no verification required" → true. - */ - @RequiresApi(Build.VERSION_CODES.R) - private fun verifyApkSignature(apkPath: String, expectedSha256Signatures: Set): Boolean { - if (expectedSha256Signatures.isEmpty()) return true - return try { - val info = app.packageManager.getPackageArchiveInfo( - apkPath, - PackageManager.GET_SIGNING_CERTIFICATES - ) ?: return false - - info.applicationInfo?.apply { - sourceDir = apkPath - publicSourceDir = apkPath - } - - val signingInfo = info.signingInfo ?: return false - val signatures = if (signingInfo.hasMultipleSigners()) - signingInfo.apkContentsSigners - else - signingInfo.signingCertificateHistory - - val digest = MessageDigest.getInstance("SHA-256") - signatures.any { sig -> - // Reset before each use - MessageDigest is stateful - digest.reset() - val fingerprint = digest.digest(sig.toByteArray()) - .joinToString("") { b -> "%02x".format(b) } - fingerprint in expectedSha256Signatures - } - } catch (e: Exception) { - Log.e(tag, "Failed to verify APK signature for $apkPath", e) - false - } - } - /** * Clean up any pending temporary APK when the ViewModel is destroyed. * This handles the edge case where the user navigates away or the system destroys diff --git a/app/src/main/java/app/morphe/manager/ui/viewmodel/PatcherViewModel.kt b/app/src/main/java/app/morphe/manager/ui/viewmodel/PatcherViewModel.kt index 349957cc0..256a634fb 100644 --- a/app/src/main/java/app/morphe/manager/ui/viewmodel/PatcherViewModel.kt +++ b/app/src/main/java/app/morphe/manager/ui/viewmodel/PatcherViewModel.kt @@ -92,6 +92,8 @@ class PatcherViewModel( private var appliedSelection: PatchSelection = input.selectedPatches.mapValues { it.value.toSet() } private var appliedOptions: Options = input.options val currentSelectedApp: SelectedApp get() = selectedApp + val patchedFromInstalledDevice: Boolean + get() = (selectedApp as? SelectedApp.Local)?.fromInstalledDevice == true private var currentActivityRequest: Pair, String>? by mutableStateOf( null @@ -842,11 +844,7 @@ class PatcherViewModel( } finally { withContext(Dispatchers.Main) { // Delete temporary input file after saving - if (input.selectedApp is SelectedApp.Local && input.selectedApp.temporary) { - inputFile?.takeIf { it.exists() }?.delete() - inputFile = null - updateSplitStepRequirement(null) - } + cleanupTemporaryInput() refreshExportMetadata() _patcherSucceeded.value = true } @@ -1022,15 +1020,18 @@ class PatcherViewModel( patcherWorkerId?.uuid?.let(workManager::cancelWorkById) } - override fun onCleared() { - super.onCleared() - patcherWorkerId?.uuid?.let(workManager::cancelWorkById) - + private fun cleanupTemporaryInput() { if (input.selectedApp is SelectedApp.Local && input.selectedApp.temporary) { inputFile?.takeIf { it.exists() }?.delete() inputFile = null updateSplitStepRequirement(null) } + } + + override fun onCleared() { + super.onCleared() + patcherWorkerId?.uuid?.let(workManager::cancelWorkById) + cleanupTemporaryInput() // Clean up the installer temp directory (contains output.apk and any intermediate files). // This covers the case where the user navigates away before installing/exporting, diff --git a/app/src/main/java/app/morphe/manager/util/ColorUtils.kt b/app/src/main/java/app/morphe/manager/util/ColorUtils.kt index f365feffa..72b48fab4 100644 --- a/app/src/main/java/app/morphe/manager/util/ColorUtils.kt +++ b/app/src/main/java/app/morphe/manager/util/ColorUtils.kt @@ -1,23 +1,17 @@ package app.morphe.manager.util import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.toArgb import androidx.core.graphics.toColorInt /** - * Determine if a color represents a dark background + * Determine if a color represents a dark background. */ fun Color.isDarkBackground(): Boolean = luminance() < 0.5f /** - * Get luminance from a Color - */ -fun Color.luminance(): Float { - return 0.299f * red + 0.587f * green + 0.114f * blue -} - -/** - * Lighten a color by mixing with white + * Lighten a color by mixing with white. */ fun Color.lighten(factor: Float): Color { return Color( @@ -29,7 +23,7 @@ fun Color.lighten(factor: Float): Color { } /** - * Darken a color by mixing with black + * Darken a color by mixing with black. */ fun Color.darken(factor: Float): Color { return Color( @@ -66,6 +60,21 @@ fun Color.ensureContrast( else lighten((minLuminanceDiff - diff + 0.05f).coerceIn(0f, 0.8f)) } +/** + * Composites this color at [alpha] over [background] and returns the opaque result. + */ +fun Color.compositeOver(background: Color, alpha: Float = this.alpha): Color = Color( + red = background.red * (1f - alpha) + red * alpha, + green = background.green * (1f - alpha) + green * alpha, + blue = background.blue * (1f - alpha) + blue * alpha, +) + +/** + * Returns true if this color, when used as a background, requires light (white) content for contrast. + * Uses WCAG relative luminance threshold. + */ +fun Color.requiresLightContent(): Boolean = luminance() <= 0.179f + fun String?.toColorOrNull(): Color? { val value = this?.trim().orEmpty() if (value.isEmpty()) return null @@ -76,7 +85,7 @@ fun String?.toColorOrNull(): Color? { } /** - * Parse color string to RGB float values (0-1 range) + * Parse color string to RGB float values (0-1 range). */ fun parseColorToRgb(color: String): Triple { return color.toColorOrNull()?.let { @@ -85,8 +94,8 @@ fun parseColorToRgb(color: String): Triple { } /** - * Parse hex color string to RGB float values - * Supports both #RRGGBB and #AARRGGBB formats + * Parse hex color string to RGB float values. + * Supports both #RRGGBB and #AARRGGBB formats. */ fun parseHexToRgb(hex: String): Triple? { return hex.toColorOrNull()?.let { @@ -95,7 +104,7 @@ fun parseHexToRgb(hex: String): Triple? { } /** - * Convert RGB float values to hex string + * Convert RGB float values to hex string. */ fun rgbToHex(r: Float, g: Float, b: Float): String { return Color(r, g, b).toHexString(includeAlpha = false) diff --git a/app/src/main/java/app/morphe/manager/util/PM.kt b/app/src/main/java/app/morphe/manager/util/PM.kt index b455448e5..516542110 100644 --- a/app/src/main/java/app/morphe/manager/util/PM.kt +++ b/app/src/main/java/app/morphe/manager/util/PM.kt @@ -2,6 +2,7 @@ package app.morphe.manager.util import android.annotation.SuppressLint import android.app.Application +import android.util.Log import android.content.Context import android.content.Intent import android.content.pm.PackageInfo @@ -20,6 +21,7 @@ import kotlinx.parcelize.Parcelize import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File +import java.security.MessageDigest @Immutable @Parcelize @@ -33,6 +35,10 @@ data class AppInfo( class PM( private val app: Application ) { + private companion object { + const val TAG = "Morphe PM" + } + val application: Application get() = app fun getPackageInfo(packageName: String, flags: Int = 0): PackageInfo? = @@ -121,6 +127,66 @@ class PM( return !installed.contentEquals(archive) } + /** + * Extracts SHA-256 certificate fingerprints from the installed [packageName]. + * Returns an empty set if the package is not found or signatures cannot be read. + * Uses full signing history to handle apps with certificate rotation. + */ + fun getInstalledSignatureHashes(packageName: String): Set { + return try { + val pkgInfo = getPackageInfo(packageName, signingFlags()) ?: return emptySet() + pkgInfo.extractSignatures()?.toSha256Hashes() ?: emptySet() + } catch (e: Exception) { + Log.e(TAG, "Failed to read installed signatures for $packageName", e) + emptySet() + } + } + + /** + * Extracts SHA-256 certificate fingerprints from an APK file. + * Returns an empty set if the file cannot be read or has no signatures. + * Uses full signing history to handle apps with certificate rotation. + */ + fun getApkFileSignatureHashes(file: File): Set { + return try { + val info = app.packageManager.getPackageArchiveInfo(file.absolutePath, signingFlags()) + ?: return emptySet() + info.applicationInfo?.apply { + sourceDir = file.absolutePath + publicSourceDir = file.absolutePath + } + info.extractSignatures()?.toSha256Hashes() ?: emptySet() + } catch (e: Exception) { + Log.e(TAG, "Failed to read APK file signatures", e) + emptySet() + } + } + + @Suppress("DEPRECATION") + private fun signingFlags() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + PackageManager.GET_SIGNING_CERTIFICATES + else + PackageManager.GET_SIGNATURES + + @Suppress("DEPRECATION") + private fun PackageInfo.extractSignatures(): Array? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = signingInfo ?: return null + if (signingInfo.hasMultipleSigners()) signingInfo.apkContentsSigners + else signingInfo.signingCertificateHistory + } else { + signatures + } + } + + private fun Array.toSha256Hashes(): Set { + val digest = MessageDigest.getInstance("SHA-256") + return mapTo(mutableSetOf()) { sig -> + digest.reset() + digest.digest(sig.toByteArray()).joinToString("") { b -> "%02x".format(b) } + } + } + fun isAppDeleted(packageName: String, hasSavedCopy: Boolean, wasInstalledOnDevice: Boolean): Boolean { val currentlyInstalled = getPackageInfo(packageName) != null return !currentlyInstalled && wasInstalledOnDevice && hasSavedCopy diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18aa9ab65..bf1d41b25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Yes, help me find an APK No, I already have an APK Use saved APK (v%s) + Use installed APK (v%s) No saved APK. Patching will require selecting the APK file again Requires Android %s+ Not supported on this device @@ -282,6 +283,11 @@ For the best results, this app recommends patching a <b>full APK</b> Unknown error occurred Package conflict You need to uninstall the existing version + The patched app uses a different certificate than the installed version. Uninstalling will erase all app data + Uninstall required + "Morphe signs patched apps with its own certificate, which differs from the original. Android requires removing the existing version before the patched app can be installed. + +Uninstalling will permanently erase all app data" Installation error Try uninstalling and installing again Press the button below to install