From 81c75a9bd4daf923953849b303304d844be1b55d Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:48:13 +0530 Subject: [PATCH 1/2] route links through patched app + uninstall from "Your Apps" Route a patched app's web links to it instead of the browser or the stock/default app. Added an "Uninstall" button to the Your Apps section's cards. Allows for better traking of an app. --- .../app/morphe/engine/util/AppLinkCommands.kt | 70 ++++ .../app/morphe/gui/data/model/AppConfig.kt | 8 + .../gui/data/repository/ConfigRepository.kt | 16 + .../gui/ui/components/SettingsButton.kt | 14 + .../gui/ui/components/SettingsDialog.kt | 32 ++ .../morphe/gui/ui/screens/home/HomeScreen.kt | 63 ++++ .../gui/ui/screens/home/HomeViewModel.kt | 45 +++ .../screens/home/components/YourAppsPane.kt | 24 ++ .../gui/ui/screens/quick/QuickPatchScreen.kt | 20 +- .../gui/ui/screens/result/ResultScreen.kt | 304 ++++++++++++++++++ .../kotlin/app/morphe/gui/util/AdbManager.kt | 148 +++++++++ .../app/morphe/engine/AppLinkCommandsTest.kt | 54 ++++ 12 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt create mode 100644 src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt diff --git a/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt new file mode 100644 index 0000000..a7d6c76 --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine.util + +/** + * Builds the `pm` shell command lines that route web links ("open with") to a + * patched app and, optionally, stop the stock app from grabbing those same + * links. + * + * Pure argv construction only — no process execution. Each frontend runs these + * through its own adb path (the GUI's `AdbManager`, the CLI's installer), so the + * "what commands to run" decision lives in one tested place while "how to exec" + * stays per-frontend. + * + * Each returned entry is one `adb shell` invocation's arguments (i.e. everything + * after `adb -s shell`). Run them in order. + * + * Background: the patched app's web intent filters are NOT `autoVerify`, so + * Android's domain-verification commands no-op on them. The *user-selection* + * state, however, can be set for any host the app declares — that's the route + * used here (and the one Morphe's own docs prescribe). + */ +object AppLinkCommands { + + /** Default Android user. Primary user is always 0; `cur` also works. */ + const val DEFAULT_USER = "0" + + /** + * Approve every web host the patched app declares so it handles those links. + * + * `set-app-links-allowed true` flips the per-app "open supported links" + * master switch on; `set-app-links-user-selection true all` then approves + * all declared hosts (verified or not). Both are needed: the master switch + * alone doesn't approve unverified hosts. + */ + fun enablePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", patchedPackage, "true"), + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "true", "all"), + ) + + /** + * Stop the stock app from handling its web links, without disabling the app + * itself. The master "open supported links" switch off is surgical and + * reversible — far lighter than `pm disable-user`. + * + * Only meaningful when a rename patch was used (stock + patched coexist as + * different packages) and the stock package is actually installed. + */ + fun disableStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "false"), + ) + + /** + * Reverse [enablePatched]: revoke the patched app's host approvals so link + * routing returns to Android's defaults. + */ + fun restorePatched(patchedPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-user-selection", "--user", user, "--package", patchedPackage, "false", "all"), + ) + + /** + * Reverse [disableStock]: hand link handling back to the stock app. + */ + fun restoreStock(stockPackage: String, user: String = DEFAULT_USER): List> = listOf( + listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "true"), + ) +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index bdbfa52..6a5ef9d 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -85,6 +85,14 @@ data class AppConfig( // Which home apps tab the user last viewed ("ALL" or "YOURS"), restored on // next launch. Stored as a string so this data layer stays free of UI enums. val homeAppListFilter: String = "ALL", + // After an ADB install, automatically route the patched app's web links to it + // ("open with"). Default OFF — it changes how the device opens links, so it's + // opt-in. See AppLinkCommands / AdbManager.setLinkHandling. + val autoRouteLinksAfterInstall: Boolean = false, + // When auto-routing links, also stop the stock app from opening those links + // (only applies when a rename patch was used and stock is installed). Default + // OFF — it reaches into a stock app's behavior. + val disableStockLinksAfterInstall: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 6ebdc20..7199476 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -204,6 +204,22 @@ class ConfigRepository { saveConfig(current.copy(autoCleanupTempFiles = enabled)) } + /** + * Update the "route links to patched app after install" setting. + */ + suspend fun setAutoRouteLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoRouteLinksAfterInstall = enabled)) + } + + /** + * Update the "also disable stock app's links" sub-setting. + */ + suspend fun setDisableStockLinksAfterInstall(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(disableStockLinksAfterInstall = enabled)) + } + /** * Update simplified mode setting. */ diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 79b273e..456b8ca 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -74,6 +74,8 @@ fun SettingsButton( var keepArchitectures by remember { mutableStateOf>(emptySet()) } var collapsibleSectionStates by remember { mutableStateOf>(emptyMap()) } var updateChannelPreference by remember { mutableStateOf(UpdateChannelPreference.STABLE) } + var autoRouteLinksAfterInstall by remember { mutableStateOf(false) } + var disableStockLinksAfterInstall by remember { mutableStateOf(false) } LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { @@ -89,6 +91,8 @@ fun SettingsButton( keystoreEntryPassword = config.keystoreEntryPassword keepArchitectures = config.keepArchitectures collapsibleSectionStates = config.collapsibleSectionStates + autoRouteLinksAfterInstall = config.autoRouteLinksAfterInstall + disableStockLinksAfterInstall = config.disableStockLinksAfterInstall // Resolve the smart-default if the user has never picked a channel // (returns DEV when the running build is dev, STABLE otherwise). updateChannelPreference = configRepository.getOrInitUpdateChannelPreference( @@ -191,6 +195,16 @@ fun SettingsButton( }, autoStartAdb = adbPreference.enabled, onAutoStartAdbChange = { adbPreference.onChange(it) }, + autoRouteLinksAfterInstall = autoRouteLinksAfterInstall, + onAutoRouteLinksChange = { enabled -> + autoRouteLinksAfterInstall = enabled + scope.launch { configRepository.setAutoRouteLinksAfterInstall(enabled) } + }, + disableStockLinksAfterInstall = disableStockLinksAfterInstall, + onDisableStockLinksChange = { enabled -> + disableStockLinksAfterInstall = enabled + scope.launch { configRepository.setDisableStockLinksAfterInstall(enabled) } + }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index 35df0b4..388bcd0 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -91,6 +91,10 @@ fun SettingsDialog( onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, autoStartAdb: Boolean = false, onAutoStartAdbChange: (Boolean) -> Unit = {}, + autoRouteLinksAfterInstall: Boolean = false, + onAutoRouteLinksChange: (Boolean) -> Unit = {}, + disableStockLinksAfterInstall: Boolean = false, + onDisableStockLinksChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -271,6 +275,34 @@ fun SettingsDialog( SettingsDivider(borderColor) + // ── Link handling ("open with") ── + SettingToggleRow( + label = "Route links to patched app", + description = "After installing via ADB, make the patched app open its supported web links instead of the browser or the stock/default app.", + checked = autoRouteLinksAfterInstall, + onCheckedChange = onAutoRouteLinksChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + AnimatedVisibility(visible = autoRouteLinksAfterInstall) { + Column { + Spacer(Modifier.height(12.dp)) + SettingToggleRow( + label = "Disable stock app's links", + description = "Also stop the original app from opening these links (only when a " + + "rename patch was used and the stock app is installed). Reversible.", + checked = disableStockLinksAfterInstall, + onCheckedChange = onDisableStockLinksChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + } + } + + SettingsDivider(borderColor) + // ── Patched App Runtime Logs ── PatchedAppRuntimeLogsSection( mono = mono, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 11c85bc..8e809cc 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -195,6 +195,57 @@ fun HomeScreenContent( } } + // "Uninstall" — removes the patched app from the connected device. The dialog + // offers the keep-history vs delete-history choice via a checkbox. + var uninstallConfirm by remember { mutableStateOf(null) } + var uninstallAlsoForget by remember { mutableStateOf(false) } + val onUninstall: (String) -> Unit = { pkg -> + uninstallAlsoForget = false + uninstallConfirm = viewModel.getPatchedRecord(pkg) + } + uninstallConfirm?.let { record -> + MorpheDialogCard( + onDismiss = { uninstallConfirm = null }, + title = "Uninstall ${record.displayName}?", + ) { + MorpheDialogText( + "This removes ${record.displayName} from the connected device. " + + "The patched APK on disk and your history are kept unless you choose otherwise below." + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(LocalMorpheCorners.current.small)) + .clickable { uninstallAlsoForget = !uninstallAlsoForget } + .padding(vertical = 4.dp), + ) { + Checkbox( + checked = uninstallAlsoForget, + onCheckedChange = { uninstallAlsoForget = it }, + colors = CheckboxDefaults.colors(checkedColor = Color(0xFFE0504D)), + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + "Also remove from Your Apps", + fontSize = 12.sp, + fontFamily = LocalMorpheFont.current, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + MorpheDialogButton("CANCEL", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) { + uninstallConfirm = null + } + MorpheDialogButton("UNINSTALL", Color(0xFFE0504D), filled = true) { + viewModel.uninstallPatchedApp(record.packageName, alsoForget = uninstallAlsoForget) + uninstallConfirm = null + } + } + } + } + repatchMissingRecord?.let { record -> MorpheDialogCard(onDismiss = { repatchMissingRecord = null }, title = "Original APK not found") { MorpheDialogText( @@ -240,7 +291,9 @@ fun HomeScreenContent( } }, onInstall = { viewModel.installPatchedApp(record.packageName) }, + onUninstall = { onUninstall(record.packageName) }, installing = uiState.installingPackage == record.packageName, + uninstalling = uiState.uninstallingPackage == record.packageName, ) } @@ -592,6 +645,8 @@ fun HomeScreenContent( onUpdate = onUpdate, onInstall = { viewModel.installPatchedApp(it) }, installingPackage = uiState.installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uiState.uninstallingPackage, onShowDetail = onShowDetail, filter = uiState.appListFilter, onFilterChange = { viewModel.setAppListFilter(it) }, @@ -1286,6 +1341,8 @@ private fun SupportedAppsListPane( onUpdate: (String) -> Unit = {}, onInstall: (String) -> Unit = {}, installingPackage: String? = null, + onUninstall: (String) -> Unit = {}, + uninstallingPackage: String? = null, onShowDetail: (PatchedAppRecord) -> Unit = {}, filter: AppListFilter = AppListFilter.ALL, onFilterChange: (AppListFilter) -> Unit = {}, @@ -1376,6 +1433,8 @@ private fun SupportedAppsListPane( onForget = onForget, onInstall = onInstall, installingPackage = installingPackage, + onUninstall = onUninstall, + uninstallingPackage = uninstallingPackage, paneMaxHeight = paneMaxHeight, showSearch = activeCount > 4, ) @@ -1527,6 +1586,8 @@ private fun YourAppsListBody( onForget: (String) -> Unit, onInstall: (String) -> Unit, installingPackage: String?, + onUninstall: (String) -> Unit, + uninstallingPackage: String?, paneMaxHeight: Dp, showSearch: Boolean, ) { @@ -1567,6 +1628,8 @@ private fun YourAppsListBody( onForget = { onForget(record.packageName) }, onInstall = { onInstall(record.packageName) }, installing = installingPackage == record.packageName, + onUninstall = { onUninstall(record.packageName) }, + uninstalling = uninstallingPackage == record.packageName, ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 896d5c2..f8e6aad 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -272,6 +272,21 @@ class HomeViewModel( // the patched app with an official update. val installer = adbManager.resolveSpoofInstaller(device.id) val result = adbManager.installApk(record.outputApkPath, device.id, installerPackage = installer) + + // Mirror ResultScreen: if the user opted into auto-routing links, + // point the patched app at its web links right after a good install. + if (result.isSuccess) { + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = record.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) record.packageName else null, + enable = true, + ) + } + } + _uiState.value = _uiState.value.copy( installingPackage = null, error = result.exceptionOrNull()?.let { "Install failed: ${it.message}" } ?: _uiState.value.error, @@ -280,6 +295,34 @@ class HomeViewModel( } } + /** + * Uninstall the patched app for [packageName] from the selected device. When + * [alsoForget] is true, the recall record is removed afterward (uninstall + + * delete history); otherwise the record is kept (uninstall + keep history) so + * the card stays as a not-installed entry the user can re-install/re-patch. + * + * Removing through Morphe (vs the launcher) keeps our device-state tracking + * accurate — [refreshDeviceInfo] runs on completion so the card flips to + * not-installed immediately. + */ + fun uninstallPatchedApp(packageName: String, alsoForget: Boolean) { + val record = patchedRecordsByPackage[packageName] ?: return + val device = DeviceMonitor.state.value.selectedDevice ?: return + if (!device.isReady || _uiState.value.uninstallingPackage != null) return + _uiState.value = _uiState.value.copy(uninstallingPackage = packageName) + screenModelScope.launch { + val result = adbManager.uninstallApk(record.installedPackageName, device.id) + if (result.isSuccess && alsoForget) { + patchedAppStore.delete(packageName) + } + _uiState.value = _uiState.value.copy( + uninstallingPackage = null, + error = result.exceptionOrNull()?.let { "Uninstall failed: ${it.message}" } ?: _uiState.value.error, + ) + refreshDeviceInfo() + } + } + /** Switch the home apps tab (ALL/YOURS) and remember it for next launch. */ fun setAppListFilter(filter: app.morphe.gui.ui.screens.home.components.AppListFilter) { if (_uiState.value.appListFilter == filter) return @@ -1193,6 +1236,8 @@ data class HomeUiState( val updatePrep: UpdatePrep? = null, /** Package currently being installed to the device from its stored output APK. */ val installingPackage: String? = null, + /** Package currently being uninstalled from the device. */ + val uninstallingPackage: String? = null, /** Per-package device install info (optional layer; empty when no device connected). */ val deviceAppInfo: Map = emptyMap(), val patchesVersion: String? = null, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt index 474cf80..e5f524d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/YourAppsPane.kt @@ -219,7 +219,9 @@ fun YourAppRow( onUpdate: () -> Unit, onForget: () -> Unit, onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, installing: Boolean = false, + uninstalling: Boolean = false, ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -363,6 +365,15 @@ fun YourAppRow( ) } DetailActionPill("RE-PATCH", Icons.Default.Refresh, accents.primary, mono, corners.small, onClick = onRepatch) + // Only offer uninstall when the app is actually on the connected device. + if (deviceInfo?.installed == true) { + DetailActionPill( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else onUninstall, + ) + } DetailActionPill( "FORGET", Icons.Default.Delete, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), mono, corners.small, @@ -389,7 +400,9 @@ fun PatchedAppDetailDialog( onForget: () -> Unit, onOpenFolder: () -> Unit, onInstall: () -> Unit = {}, + onUninstall: () -> Unit = {}, installing: Boolean = false, + uninstalling: Boolean = false, ) { val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current @@ -594,6 +607,17 @@ fun PatchedAppDetailDialog( onDismiss(); onRepatch() } } + // Uninstall from the connected device — only when it's actually installed. + if (deviceInfo?.installed == true) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + WideActionButton( + if (uninstalling) "UNINSTALLING…" else "UNINSTALL", + "remove from device", Icons.Default.Delete, + Color(0xFFE0504D), mono, corners.small, + onClick = if (uninstalling) ({}) else ({ onDismiss(); onUninstall() }), + ) + } + } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { WideActionButton("FOLDER", null, Icons.AutoMirrored.Filled.OpenInNew, accents.secondary, mono, corners.small, onClick = onOpenFolder) WideActionButton( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 1c92b6c..06377a3 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,6 +39,7 @@ import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.engine.PatchedAppStore import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp @@ -1192,6 +1193,7 @@ private fun CompletedContent( val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } + val configRepository: ConfigRepository = koinInject() val monitorState by DeviceMonitor.state.collectAsState() val adbPreference = LocalAdbPreference.current val isAdbDisabledByUser = !adbPreference.enabled @@ -1444,7 +1446,23 @@ private fun CompletedContent( deviceId = device.id ) result.fold( - onSuccess = { installSuccess = true }, + onSuccess = { + installSuccess = true + // Parity with ResultScreen: auto-route links when opted in. + val config = configRepository.loadConfig() + if (config.autoRouteLinksAfterInstall) { + val record = PatchedAppStore.shared.getAll() + .firstOrNull { it.outputApkPath == outputPath } + record?.let { + adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = it.installedPackageName, + stockPackage = if (config.disableStockLinksAfterInstall) it.packageName else null, + enable = true, + ) + } + } + }, onFailure = { installError = it.message } ) isInstalling = false diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index 52bd0fd..e2f9cd4 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -55,7 +55,9 @@ import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.DeviceStatus import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import app.morphe.engine.PatchedAppStore import app.morphe.engine.util.ApkManifestReader +import app.morphe.gui.data.model.SupportedApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.awt.Desktop @@ -112,6 +114,26 @@ fun ResultScreenContent(outputPath: String) { adbManager.listInstalledPackages(device.id).getOrNull()?.contains(pkg) == true } + // Link-handling ("open with") state. The stock package — needed only for the + // optional "stop stock from opening links" half — comes from the recall + // record for this output (which stores original + renamed package names). + var stockPackage by remember { mutableStateOf(null) } + var disableStockLinks by remember { mutableStateOf(false) } + var isApplyingLinks by remember { mutableStateOf(false) } + var linkProgress by remember { mutableStateOf("") } + var linkError by remember { mutableStateOf(null) } + var linkSuccess by remember { mutableStateOf(false) } + var autoRouteLinks by remember { mutableStateOf(false) } + LaunchedEffect(outputPath, outputPackage) { + stockPackage = withContext(Dispatchers.IO) { + runCatching { + val records = PatchedAppStore.shared.getAll() + records.firstOrNull { it.outputApkPath == outputPath }?.packageName + ?: outputPackage?.let { pkg -> records.firstOrNull { it.installedPackageName == pkg }?.packageName } + }.getOrNull() + } + } + // Cleanup state var hasTempFiles by remember { mutableStateOf(false) } var tempFilesSize by remember { mutableStateOf(0L) } @@ -121,6 +143,8 @@ fun ResultScreenContent(outputPath: String) { LaunchedEffect(Unit) { val config = configRepository.loadConfig() autoCleanupEnabled = config.autoCleanupTempFiles + autoRouteLinks = config.autoRouteLinksAfterInstall + disableStockLinks = config.disableStockLinksAfterInstall hasTempFiles = FileUtils.hasTempFiles() tempFilesSize = FileUtils.getTempDirSize() @@ -163,6 +187,46 @@ fun ResultScreenContent(outputPath: String) { } } + fun applyLinkHandling(enable: Boolean) { + val device = monitorState.selectedDevice ?: return + val patched = outputPackage ?: return + scope.launch { + isApplyingLinks = true + linkError = null + val result = adbManager.setLinkHandling( + deviceId = device.id, + patchedPackage = patched, + stockPackage = if (disableStockLinks) stockPackage else null, + enable = enable, + onProgress = { linkProgress = it }, + ) + result.fold( + onSuccess = { outcome -> + linkSuccess = enable + linkProgress = when { + !enable -> "Default link handling restored" + outcome.stockChanged -> "Links routed to patched app, stock disabled" + else -> "Links routed to patched app" + } + }, + onFailure = { e -> + linkError = (e as? AdbException)?.message ?: e.message ?: "Unknown error" + } + ) + isApplyingLinks = false + } + } + + // Auto-route links once, right after a successful install, when the global + // setting is on. outputPackage is required (the apply no-ops without it). + LaunchedEffect(installSuccess, autoRouteLinks, outputPackage) { + if (installSuccess && autoRouteLinks && outputPackage != null && + !linkSuccess && !isApplyingLinks && linkError == null + ) { + applyLinkHandling(enable = true) + } + } + Column( modifier = Modifier .fillMaxSize() @@ -279,6 +343,30 @@ fun ResultScreenContent(outputPath: String) { }, onDismissError = { installError = null } ) + + // Link handling ("open with"). Only meaningful once the patched + // app is on the device, so gate on a successful install (or the + // app already being present) + a ready, selected device. + val device = monitorState.selectedDevice + if (outputPackage != null && device?.isReady == true && (installSuccess || alreadyInstalled)) { + LinkHandlingSection( + patchedPackage = outputPackage!!, + stockPackage = stockPackage?.takeIf { it != outputPackage }, + disableStockLinks = disableStockLinks, + onToggleDisableStock = { disableStockLinks = it }, + isApplying = isApplyingLinks, + progress = linkProgress, + error = linkError, + success = linkSuccess, + selectedDeviceName = device.displayName, + corners = corners, + mono = mono, + borderColor = borderColor, + onApply = { applyLinkHandling(enable = true) }, + onRestore = { applyLinkHandling(enable = false) }, + onDismissError = { linkError = null }, + ) + } } // Cleanup section @@ -656,6 +744,222 @@ private fun AdbInstallSection( } } +// ═══════════════════════════════════════════════════════════════════ +// LINK HANDLING ("OPEN WITH") SECTION +// ═══════════════════════════════════════════════════════════════════ + +/** + * Route the patched app's web links to it (and optionally stop the stock app + * from grabbing them). Shown only once the patched app is installed on a ready + * device. The stock-disable checkbox appears only when a rename patch was used + * (a distinct [stockPackage]); on-device, [AdbManager.setLinkHandling] still + * verifies the stock app is actually installed before touching it. + */ +@Composable +private fun LinkHandlingSection( + patchedPackage: String, + stockPackage: String?, + disableStockLinks: Boolean, + onToggleDisableStock: (Boolean) -> Unit, + isApplying: Boolean, + progress: String, + error: String?, + success: Boolean, + selectedDeviceName: String?, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onApply: () -> Unit, + onRestore: () -> Unit, + onDismissError: () -> Unit, +) { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + Text( + text = "LINK HANDLING", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Open supported web links in the patched app instead of the browser.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + + // Optional OFF half — only when a rename was used so stock + patched coexist. + if (stockPackage != null) { + Spacer(Modifier.height(12.dp)) + val stockName = SupportedApp.getDisplayName(stockPackage) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .clickable(enabled = !isApplying) { onToggleDisableStock(!disableStockLinks) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Checkbox( + checked = disableStockLinks, + onCheckedChange = { onToggleDisableStock(it) }, + enabled = !isApplying, + colors = CheckboxDefaults.colors(checkedColor = accents.secondary), + modifier = Modifier.size(20.dp) + ) + Text( + text = "Also stop $stockName from opening these links", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + ) + } + } + + Spacer(Modifier.height(14.dp)) + + when { + error != null -> { + Text( + text = error, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(10.dp)) + SecondaryActionChip(text = "DISMISS", corners = corners, mono = mono, onClick = onDismissError) + } + + isApplying -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accents.primary + ) + Spacer(Modifier.width(10.dp)) + Text( + text = progress.ifEmpty { "Applying..." }.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } + + success -> { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = accents.secondary, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = progress.ifEmpty { "Links routed to patched app" }.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + SecondaryActionChip(text = "RESTORE", corners = corners, mono = mono, onClick = onRestore) + } + } + + else -> { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + val bg by animateColorAsState( + if (isHovered) accents.secondary.copy(alpha = 0.9f) else accents.secondary, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .background(bg, RoundedCornerShape(corners.small)) + .clickable(onClick = onApply), + contentAlignment = Alignment.Center + ) { + Text( + text = "OPEN LINKS WITH PATCHED APP", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) + } + } + } + } + } +} + +/** Small bordered text button used for secondary actions (Dismiss/Restore). */ +@Composable +private fun SecondaryActionChip( + text: String, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + onClick: () -> Unit, +) { + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.3f else 0.12f), + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp + ) + } +} + // ═══════════════════════════════════════════════════════════════════ // CLEANUP SECTION // ═══════════════════════════════════════════════════════════════════ diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index 2adab97..adf40fa 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -5,6 +5,7 @@ package app.morphe.gui.util +import app.morphe.engine.util.AppLinkCommands import app.morphe.engine.util.SignatureIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -416,6 +417,44 @@ class AdbManager { } } + /** + * Uninstall [packageName] from [deviceId]. Used by the "Your apps" cards so a + * patched app can be removed through Morphe (keeping our recall state accurate) + * instead of from the launcher behind our back. + * + * Treats "package not installed" as success — the desired end state (app gone) + * already holds, so the caller can refresh and move on. + */ + suspend fun uninstallApk( + packageName: String, + deviceId: String, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + try { + val process = ProcessBuilder(adb, "-s", deviceId, "uninstall", packageName) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + Logger.info("uninstall $packageName on $deviceId -> exit $exitCode: $output") + + when { + exitCode == 0 && output.contains("Success") -> Result.success(Unit) + // Already gone is the end state we wanted. + output.contains("not installed", ignoreCase = true) || + output.contains("DELETE_FAILED_INTERNAL_ERROR", ignoreCase = true) && + listInstalledPackages(deviceId).getOrNull()?.contains(packageName) == false -> + Result.success(Unit) + else -> Result.failure(AdbException("Uninstall failed: ${output.ifBlank { "exit $exitCode" }}")) + } + } catch (e: Exception) { + Logger.error("Error uninstalling $packageName", e) + Result.failure(AdbException("Uninstall failed: ${e.message}")) + } + } + /** * Clear the device's logcat buffers (main + crash). * Crash buffer clear is best-effort — older devices may not have it. @@ -493,6 +532,106 @@ class AdbManager { } } + // ── Link handling ("open with") ────────────────────────────────────────── + + /** + * Route the patched app's declared web links to it, and optionally stop the + * stock app from handling those same links. Reverse with [enable] = false. + * + * The OFF half (stock) only runs when [stockPackage] is a real, different, + * *installed* package — i.e. a rename patch was used and stock is present. + * Otherwise it's skipped (reported via [LinkHandlingResult.stockChanged]), + * never silently no-op'd. + * + * Commands come from [AppLinkCommands] so the CLI and GUI share one source + * of truth; here we just execute them through `adb -s shell`. + * + * Must be called AFTER the patched app is installed — the package has to + * exist on the device for `pm set-app-links-*` to take effect. + */ + suspend fun setLinkHandling( + deviceId: String, + patchedPackage: String, + stockPackage: String? = null, + enable: Boolean = true, + onProgress: (String) -> Unit = {}, + ): Result = withContext(Dispatchers.IO) { + findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + // Stock OFF only applies to a genuinely different, installed package. + val installed = listInstalledPackages(deviceId).getOrNull() ?: emptySet() + val stockEligible = !stockPackage.isNullOrBlank() && + stockPackage != patchedPackage && + stockPackage in installed + + onProgress( + if (enable) "Routing links to $patchedPackage..." + else "Restoring default link handling..." + ) + + val patchedCommands = if (enable) AppLinkCommands.enablePatched(patchedPackage) + else AppLinkCommands.restorePatched(patchedPackage) + runShellCommands(deviceId, patchedCommands).onFailure { + return@withContext Result.failure(it) + } + + var stockChanged = false + if (stockEligible) { + val stockCommands = if (enable) AppLinkCommands.disableStock(stockPackage!!) + else AppLinkCommands.restoreStock(stockPackage!!) + onProgress( + if (enable) "Disabling links in $stockPackage..." + else "Re-enabling links in $stockPackage..." + ) + runShellCommands(deviceId, stockCommands).onFailure { + return@withContext Result.failure(it) + } + stockChanged = true + } + + Logger.info( + "Link handling ${if (enable) "enabled" else "restored"} for $patchedPackage" + + (if (stockChanged) " (stock $stockPackage toggled)" else "") + ) + Result.success(LinkHandlingResult(patchedChanged = true, stockChanged = stockChanged)) + } + + /** + * Run a sequence of `adb -s shell ` commands, stopping at the + * first failure. `pm set-app-links-*` print nothing on success and exit 0; a + * non-zero exit (or "Error"/"Failure" in output) is treated as a failure. + */ + private suspend fun runShellCommands( + deviceId: String, + commands: List>, + ): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure(AdbException("ADB not found")) + for (argv in commands) { + try { + val process = ProcessBuilder(listOf(adb, "-s", deviceId, "shell") + argv) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText().trim() + val exitCode = process.waitFor() + Logger.debug("ADB shell ${argv.joinToString(" ")} -> exit $exitCode${if (output.isNotBlank()) ": $output" else ""}") + if (exitCode != 0 || + output.contains("Error", ignoreCase = true) || + output.contains("Failure", ignoreCase = true) + ) { + return@withContext Result.failure( + AdbException("Command failed (pm ${argv.getOrNull(1) ?: ""}): ${output.ifBlank { "exit $exitCode" }}") + ) + } + } catch (e: Exception) { + Logger.error("Error running adb shell ${argv.joinToString(" ")}", e) + return@withContext Result.failure(AdbException("Failed to run command: ${e.message}")) + } + } + Result.success(Unit) + } + // ── Patched-app recall: device-side queries ────────────────────────────── /** Package names installed on [deviceId] (`pm list packages`). */ @@ -683,6 +822,15 @@ enum class DeviceStatus { UNKNOWN // Unknown status } +/** + * Outcome of [AdbManager.setLinkHandling]. [stockChanged] is false when the + * stock-app OFF step was skipped (no rename, or stock not installed). + */ +data class LinkHandlingResult( + val patchedChanged: Boolean, + val stockChanged: Boolean, +) + open class AdbException(message: String) : Exception(message) class AdbMultipleDevicesException( diff --git a/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt new file mode 100644 index 0000000..74c5255 --- /dev/null +++ b/src/test/kotlin/app/morphe/engine/AppLinkCommandsTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.engine.util.AppLinkCommands +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppLinkCommandsTest { + + @Test + fun `enablePatched sets master switch then approves all hosts`() { + val cmds = AppLinkCommands.enablePatched("app.morphe.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "app.morphe.android.youtube", "true"), + listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "app.morphe.android.youtube", "true", "all"), + ), + cmds, + ) + } + + @Test + fun `disableStock turns off the master switch only`() { + val cmds = AppLinkCommands.disableStock("com.google.android.youtube") + assertEquals( + listOf( + listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "com.google.android.youtube", "false"), + ), + cmds, + ) + } + + @Test + fun `restore commands invert apply commands`() { + assertEquals( + listOf(listOf("pm", "set-app-links-user-selection", "--user", "0", "--package", "p", "false", "all")), + AppLinkCommands.restorePatched("p"), + ) + assertEquals( + listOf(listOf("pm", "set-app-links-allowed", "--user", "0", "--package", "s", "true")), + AppLinkCommands.restoreStock("s"), + ) + } + + @Test + fun `custom user id is threaded through`() { + val cmds = AppLinkCommands.enablePatched("p", user = "10") + assertEquals("10", cmds.first()[3]) + } +} From cf5bde2c0d0a8686b0cbdef496a5136234c1a709 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:03:32 +0530 Subject: [PATCH 2/2] cli side addition for "route links through patched app" Added `--route-links` and `--disable-stock ` flag to utility install. This will route links through our patched app and also stop stock app from opening those links respectively. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../cli/command/utility/InstallCommand.kt | 54 +++++++++++++++++-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d9bc4ea..82a9b6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { api(libs.morphe.patcher) implementation(libs.arsclib) implementation(libs.morphe.library) + implementation(libs.jadb) implementation(libs.picocli) // -- Compose Desktop --------------------------------------------------- diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 480721a..91e52e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ picocli = "4.7.7" arsclib = "a28c6fb2a7" morphe-patcher = "1.5.2" morphe-library = "1.3.0" +jadb = "1.2.1" # Compose Desktop compose = "1.10.3" @@ -44,6 +45,7 @@ junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref picocli = { module = "info.picocli:picocli", version.ref = "picocli" } morphe-patcher = { module = "app.morphe:morphe-patcher", version.ref = "morphe-patcher" } morphe-library = { module = "app.morphe:morphe-library-jvm", version.ref = "morphe-library" } +jadb = { module = "app.morphe:jadb", version.ref = "jadb" } # Ktor Client ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } diff --git a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt index 412e849..bfb99b3 100644 --- a/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/utility/InstallCommand.kt @@ -1,10 +1,13 @@ package app.morphe.cli.command.utility +import app.morphe.engine.util.ApkManifestReader +import app.morphe.engine.util.AppLinkCommands import app.morphe.library.installation.installer.* import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import picocli.CommandLine.* +import se.vidstige.jadb.JadbConnection import java.io.File import java.util.logging.Logger @@ -34,6 +37,18 @@ internal object InstallCommand : Runnable { ) private var packageName: String? = null + @Option( + names = ["--route-links"], + description = ["After installing, route this app's supported web links to it (\"open with\")."], + ) + private var routeLinks: Boolean = false + + @Option( + names = ["--disable-stock"], + description = ["With --route-links: also stop this stock package from handling the links."], + ) + private var stockPackage: String? = null + override fun run() { suspend fun install(deviceSerial: String? = null) { val result = try { @@ -44,20 +59,51 @@ internal object InstallCommand : Runnable { }.install(Installer.Apk(apk, packageName)) } catch (e: Exception) { logger.severe(e.toString()) + return } when (result) { - RootInstallerResult.FAILURE -> + RootInstallerResult.FAILURE -> { logger.severe("Failed to mount the APK file") - is AdbInstallerResult.Failure -> + return + } + is AdbInstallerResult.Failure -> { logger.severe(result.exception.toString()) - else -> - logger.info("Installed the APK file") + return + } + else -> logger.info("Installed the APK file") } + + if (routeLinks) routeLinks(deviceSerial) } runBlocking { deviceSerials?.map { async { install(it) } }?.awaitAll() ?: install() } } + + private fun routeLinks(deviceSerial: String?) { + val patched = ApkManifestReader.read(apk)?.packageName ?: run { + logger.severe("Could not read package name from APK; skipping link routing") + return + } + val commands = AppLinkCommands.enablePatched(patched) + + (stockPackage?.let { AppLinkCommands.disableStock(it) } ?: emptyList()) + + val devices = JadbConnection().devices + val device = deviceSerial?.let { s -> devices.firstOrNull { it.serial == s } } + ?: devices.firstOrNull() + ?: run { logger.severe("No ADB device for link routing"); return } + + commands.forEach { argv -> + val cmd = argv.joinToString(" ") + val process = device.shellProcessBuilder(cmd).start() + val out = process.inputStream.bufferedReader().readText().trim() + val exit = process.waitFor() + if (exit != 0 || out.contains("Error", true) || out.contains("Failure", true)) { + logger.severe("Link command failed: $cmd -> ${out.ifBlank { "exit $exit" }}") + } + } + logger.info("Routed links to $patched") + } }