Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/main/kotlin/app/morphe/engine/util/AppLinkCommands.kt
Original file line number Diff line number Diff line change
@@ -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 <serial> 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<List<String>> = 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<List<String>> = 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<List<String>> = 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<List<String>> = listOf(
listOf("pm", "set-app-links-allowed", "--user", user, "--package", stockPackage, "true"),
)
}
8 changes: 8 additions & 0 deletions src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
16 changes: 16 additions & 0 deletions src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
14 changes: 14 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ fun SettingsButton(
var keepArchitectures by remember { mutableStateOf<Set<String>>(emptySet()) }
var collapsibleSectionStates by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
var updateChannelPreference by remember { mutableStateOf(UpdateChannelPreference.STABLE) }
var autoRouteLinksAfterInstall by remember { mutableStateOf(false) }
var disableStockLinksAfterInstall by remember { mutableStateOf(false) }

LaunchedEffect(showSettingsDialog) {
if (showSettingsDialog) {
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Boolean> = emptyMap(),
onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> }
) {
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<app.morphe.engine.model.PatchedAppRecord?>(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(
Expand Down Expand Up @@ -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,
)
}

Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -1376,6 +1433,8 @@ private fun SupportedAppsListPane(
onForget = onForget,
onInstall = onInstall,
installingPackage = installingPackage,
onUninstall = onUninstall,
uninstallingPackage = uninstallingPackage,
paneMaxHeight = paneMaxHeight,
showSearch = activeCount > 4,
)
Expand Down Expand Up @@ -1527,6 +1586,8 @@ private fun YourAppsListBody(
onForget: (String) -> Unit,
onInstall: (String) -> Unit,
installingPackage: String?,
onUninstall: (String) -> Unit,
uninstallingPackage: String?,
paneMaxHeight: Dp,
showSearch: Boolean,
) {
Expand Down Expand Up @@ -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,
)
}
}
Expand Down
45 changes: 45 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<String, DeviceAppInfo> = emptyMap(),
val patchesVersion: String? = null,
Expand Down
Loading
Loading