Skip to content
Merged
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
28 changes: 23 additions & 5 deletions app/src/main/assets/root/post-fs-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ mount_stock_path() {
return
fi

if nomount_only; then
if [ -n "$nm_bin" ] && "$nm_bin" rule add "$target" "$stock_apk" 2>> "$log"; then
log_msg "Redirected stock APK over $target with NoMount"
else
log_msg "Failed to redirect stock APK over $target; bind mounts are off"
fi
return
fi

umount -l "$target" 2>/dev/null || true
if mount -o bind "$stock_apk" "$target" 2>> "$log"; then
log_msg "Mounted stock APK over $target"
Expand Down Expand Up @@ -79,21 +88,30 @@ done < "$stock_paths"
# service.sh redirects the installed APK with NoMount when its driver is loaded, and the APK on
# disk is the stock one already, so a mount over it would only show up in the mount table. The
# driver may load after this script, and service.sh then lifts the mount itself.
nomount_ready() {
find_nomount() {
for nm in /data/adb/modules/nomount/bin/nm /data/adb/ksu/bin/nm /data/adb/ap/bin/nm; do
[ -x "$nm" ] || continue
protocol="$("$nm" version 2>/dev/null)" || continue
[ "${protocol%% *}" -ge 22 ] 2>/dev/null && return 0
if [ "${protocol%% *}" -ge 22 ] 2>/dev/null; then
nm_bin="$nm"
return 0
fi
done
return 1
}
redirect_installed=0
nomount_ready && redirect_installed=1
nm_bin=""
find_nomount

# Set in Morphe, where NoMount only never falls back to a bind mount. The hidden system copy is
# then redirected for every UID instead, as PackageManager scans it from system_server.
nomount_only() {
[ "$(cat "$module_dir/nomount-only" 2>/dev/null)" = 1 ]
}

while IFS= read -r path; do
case "$path" in
/data/app/*)
if [ "$redirect_installed" = 1 ]; then
if [ -n "$nm_bin" ] || nomount_only; then
log_msg "Skipping stock mount over $path; NoMount redirects it"
continue
fi
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/assets/root/service.sh
Original file line number Diff line number Diff line change
Expand Up @@ -195,17 +195,26 @@ find_nomount() {
return 1
}

# Set in Morphe, where NoMount only never falls back to a bind mount
nomount_only() {
[ "$(cat "$module_dir/nomount-only" 2>/dev/null)" = 1 ]
}

# Redirects the stock path to the patched APK in the kernel, which no mount table shows and no
# root manager unmounts from an app. The stock APK post-fs-data may have mounted is lifted first.
redirect_patched() {
find_nomount || return 1
if ! find_nomount; then
nomount_only && not_mounted "NoMount is unavailable and bind mounts are off"
return 1
fi
unmount_from_zygote_namespaces
lift_layers /proc/self/mountinfo umount -l "$stock_path"
# A rule is only deleted under the UID scope it was added with, so the unscoped one other root
# installs add for the same path goes too
"$nm_bin" rule del $nomount_scope "$stock_path" 2>/dev/null
"$nm_bin" rule del "$stock_path" 2>/dev/null
if ! "$nm_bin" rule add $nomount_scope "$stock_path" "$base_path" 2>> "$log"; then
nomount_only && not_mounted "NoMount redirect failed and bind mounts are off, see log.txt"
log_msg "NoMount redirect failed, bind mounting instead"
return 1
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.content.pm.PackageInfo
import android.os.Process
import android.os.SystemClock
import android.util.Log
import app.morphe.manager.domain.manager.PreferencesManager
import app.morphe.manager.patcher.split.SplitApkInspector
import app.morphe.manager.patcher.split.SplitApkPreparer
import app.morphe.manager.util.PLAY_STORE_INSTALLER_PACKAGE
Expand All @@ -31,7 +32,8 @@ private const val TAG = "Morphe RootInstaller"

class RootInstaller(
private val app: Application,
private val pm: PM
private val pm: PM,
private val prefs: PreferencesManager
) {
@Volatile
private var cachedHasRoot: Boolean? = null
Expand Down Expand Up @@ -160,20 +162,35 @@ class RootInstaller(
// The redirect leaves system_server on the stock APK, so a framework restart scans that
// one and needs no watcher. Whatever a bind mount left behind is lifted first, and only
// the rule decides whether the redirect took
val redirected = noMountBin()?.shellQuote()?.let { nm ->
val noMountOnly = prefs.noMountOnly.get()
val marker = "$MODULES_PATH/${moduleId(packageName)}/$NO_MOUNT_MARKER".shellQuote()
execute("printf %s ${noMountMarker(noMountOnly)} > $marker")
.assertSuccess("Failed to save the NoMount only setting")
val nm = noMountBin()?.shellQuote()
val redirected = nm?.let {
execute(
"chcon u:object_r:apk_data_file:s0 $patchedPath; " +
unmountBindCommands(stockPath) + "; " +
frameworkWatcherCommand(packageName, "stop") + "; " +
noMountRemoveCommand(nm, stockPath) + "; " +
"$nm rule add $NO_MOUNT_SCOPE $stockPath $patchedPath"
).also { result ->
if (!result.isSuccess) Log.w(TAG, "NoMount redirect failed, bind mounting instead: ${result.failureDetail()}")
if (!result.isSuccess) Log.w(TAG, "NoMount redirect failed: ${result.failureDetail()}")
}.isSuccess
} == true

if (redirected) {
execute(moduleStatusCommand(packageName, "Redirected the patched APK in Morphe with NoMount") + "; " + forceStop)
} else if (noMountOnly) {
// Whatever a bind mount left behind is lifted, so the app is left on the stock APK
val reason = if (nm == null) "NoMount is unavailable" else "NoMount redirect failed"
execute(
unmountBindCommands(stockPath) + "; " +
frameworkWatcherCommand(packageName, "stop") + "; " +
moduleStatusCommand(packageName, "Not mounted in Morphe: $reason and bind mounts are off") + "; " +
forceStop
)
error("$reason and bind mounts are off")
} else {
// Set SELinux context, bind-mount in the root and zygote namespaces, replace any other
// APK running processes still hold, and restart the app so its next process inherits
Expand Down Expand Up @@ -297,7 +314,8 @@ class RootInstaller(
} else {
emptyMap()
}
val textFiles = (moduleFiles + stockPathsFile).mapKeys { (name, _) -> "$modulePath/$name" }
val noMountFile = NO_MOUNT_MARKER to noMountMarker(prefs.noMountOnly.get())
val textFiles = (moduleFiles + stockPathsFile + noMountFile).mapKeys { (name, _) -> "$modulePath/$name" }
stageModuleFiles(modulePath, textFiles)

val patchedModuleApk = "$modulePath/$packageName.apk"
Expand Down Expand Up @@ -338,7 +356,13 @@ class RootInstaller(
if (isAppMounted(packageName))
unmount(packageName)

execute("rm -rf ${"$MODULES_PATH/${moduleId(packageName)}".shellQuote()}")
// A NoMount only module redirects the hidden system copy to its stock APK until the next
// boot, and the rule would point at nothing once the module is gone
val modulePath = "$MODULES_PATH/${moduleId(packageName)}"
val removeStockRedirects = noMountBin()?.let { nm ->
noMountRemoveRedirectsToCommand(nm.shellQuote(), "$modulePath/$packageName-stock.apk") + "; "
}.orEmpty()
execute(removeStockRedirects + "rm -rf ${modulePath.shellQuote()}")
.assertSuccess("Failed to delete files")
}

Expand Down Expand Up @@ -550,6 +574,13 @@ class RootInstaller(
private fun noMountRemoveCommand(nm: String, stockPath: String) =
"$nm rule del $NO_MOUNT_SCOPE $stockPath 2>/dev/null; $nm rule del $stockPath 2>/dev/null; true"

// Removes the unscoped rules redirecting to the real path, which are listed as "virtual -> real"
private fun noMountRemoveRedirectsToCommand(nm: String, realPath: String) =
$$"""
$$nm rule list 2>/dev/null | grep -F $${" -> $realPath".shellQuote()} | sed 's| -> .*||' |
while IFS= read -r path; do $$nm rule del "$path"; done; true
""".trimIndent()

// A namespace can hold the patched APK twice, once propagated from the root namespace and
// once mounted into it directly, and a single umount only lifts the top one. Morphe's own
// namespace was copied from zygote's, so it is cleared too or the APK it reads back for
Expand Down Expand Up @@ -617,6 +648,10 @@ class RootInstaller(
private val MODULE_SCRIPTS = listOf("service.sh", "post-fs-data.sh")
private val MODULE_FILES = MODULE_SCRIPTS + "module.prop"
private const val STOCK_PATHS_FILE = "stock-paths.txt"
/** Holds "1" when the module may only redirect with NoMount and never falls back to a bind mount. */
private const val NO_MOUNT_MARKER = "nomount-only"

private fun noMountMarker(noMountOnly: Boolean) = if (noMountOnly) "1" else "0"

/** Where NoMount puts its CLI, linked next to the root manager's own tools. */
private val NO_MOUNT_BINS = listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ class PreferencesManager(

// System tab
val installerPrimary = stringPreference("installer_primary", InstallerPreferenceTokens.INTERNAL)
val noMountOnly = booleanPreference("nomount_only", false)
val promptInstallerOnInstall = booleanPreference("prompt_installer_on_install", false)
/** Lets an expert run on a rooted device pick patches regardless of what its install target requires or rules out. */
val ignoreInstallTargetRules = booleanPreference("ignore_install_target_rules", false)
Expand Down Expand Up @@ -285,6 +286,7 @@ class PreferencesManager(
val useJvmRuntime: Boolean? = null,
val allowMeteredUpdates: Boolean? = null,
val installerPrimary: String? = null,
val noMountOnly: Boolean? = null,
val installerCustomComponents: Set<String>? = null,
val installerHiddenComponents: Set<String>? = null,
val keystoreAlias: String? = null,
Expand Down Expand Up @@ -355,6 +357,7 @@ class PreferencesManager(
useJvmRuntime = useJvmRuntime.takeIf { patching },
allowMeteredUpdates = allowMeteredUpdates.takeIf { updates },
installerPrimary = installerPrimary.takeIf { patching },
noMountOnly = noMountOnly.takeIf { patching },
installerCustomComponents = installerCustomComponents.takeIf { patching },
installerHiddenComponents = installerHiddenComponents.takeIf { patching },
keystoreAlias = keystoreAlias.takeIf { patching },
Expand Down Expand Up @@ -415,6 +418,7 @@ class PreferencesManager(
useJvmRuntime = useJvmRuntime.get(),
allowMeteredUpdates = allowMeteredUpdates.get(),
installerPrimary = installerPrimary.get(),
noMountOnly = noMountOnly.get(),
installerCustomComponents = installerCustomComponents.get(),
installerHiddenComponents = installerHiddenComponents.get(),
firstLaunch = firstLaunch.get(),
Expand Down Expand Up @@ -472,6 +476,7 @@ class PreferencesManager(
snapshot.useJvmRuntime?.let { useJvmRuntime.value = it }
snapshot.allowMeteredUpdates?.let { allowMeteredUpdates.value = it }
snapshot.installerPrimary?.let { installerPrimary.value = it }
snapshot.noMountOnly?.let { noMountOnly.value = it }
snapshot.installerCustomComponents?.let { installerCustomComponents.value = it }
snapshot.installerHiddenComponents?.let { installerHiddenComponents.value = it }
snapshot.keystoreAlias?.let { keystoreAlias.value = it }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ fun InstallerSettings(settingsViewModel: SettingsViewModel = koinViewModel()) {
}

val autoInstallEnabled by prefs.autoInstallAfterPatching.getAsState()
val noMountOnly by prefs.noMountOnly.getAsState()
val autoUninstallEnabled by prefs.autoUninstallWithShizuku.getAsState()
val promptEnabled by prefs.promptInstallerOnInstall.getAsState()
val ignoreTargetRulesEnabled by prefs.ignoreInstallTargetRules.getAsState()
val useExpertMode by prefs.useExpertMode.getAsState()
val rooted = remember { settingsViewModel.isDeviceRooted() }
// NoMount runs on KernelSU and APatch only
val magisk by produceState(false) { value = settingsViewModel.isMagisk() }

var pendingAutoUninstallConfirm by remember { mutableStateOf(false) }

Expand Down Expand Up @@ -65,6 +68,17 @@ fun InstallerSettings(settingsViewModel: SettingsViewModel = koinViewModel()) {
shizukuStatusProvider = settingsViewModel::getShizukuStatus,
onRequestShizukuPermission = settingsViewModel::requestShizukuPermission
)
FoldingVisibility(visible = primaryToken.isRootMount && !magisk) {
SettingsGroup {
SettingsSwitchItem(
checked = noMountOnly,
onToggle = { settingsViewModel.setNoMountOnly(!noMountOnly) },
icon = MaterialSymbols.Rounded.Lock,
title = stringResource(R.string.settings_nomount_only),
subtitle = stringResource(R.string.settings_nomount_only_description)
)
}
}
}

SettingsSection(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ class SettingsViewModel(
if (enabled) prefs.promptInstallerOnInstall.update(false)
}

fun setNoMountOnly(enabled: Boolean) = viewModelScope.launch {
prefs.noMountOnly.update(enabled)
}

fun setAutoUninstallWithShizuku(enabled: Boolean) = viewModelScope.launch {
prefs.autoUninstallWithShizuku.update(enabled)
}
Expand All @@ -245,6 +249,8 @@ class SettingsViewModel(
/** Whether the device has root, which the patch mode and the rules tied to it depend on. */
fun isDeviceRooted() = rootInstaller.isDeviceRooted()

suspend fun isMagisk() = rootInstaller.isMagisk()

fun setUseCustomFilePicker(enabled: Boolean) = viewModelScope.launch {
prefs.useCustomFilePicker.update(enabled)
prefs.customFilePickerUserConfigured.update(true)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,8 @@ The image dimensions must be as follows:
<string name="installer_play_store_warning_continue">I understand, continue</string>
<string name="installer_play_store_mode">Install as Play Store</string>
<string name="installer_play_store_mode_description">Marks Google Play Store as the install source</string>
<string name="settings_nomount_only">NoMount only</string>
<string name="settings_nomount_only_description">Mounts apps only with NoMount and never falls back to a bind mount, so they stay unmounted without it</string>
<string name="settings_prompt_installer_on_install">Installer selection prompt</string>
<string name="settings_prompt_installer_on_install_description">Asks how to install every app: with root before patching, otherwise after it</string>
<string name="settings_auto_install_with_shizuku">Auto-install after patching</string>
Expand Down
5 changes: 5 additions & 0 deletions docs/installers.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ with a **Mount** chip and offers **Remount** and **Unmount**, an unmounted one o
Morphe redirects the app to the patched APK in the kernel instead of mounting it, which
module unmounting does not affect and the mount table does not show. Keep the app off
NoMount's own exclusion list, or it sees the stock APK.
To keep the mount table clean, turn on **NoMount only** in **Settings → Installation**. A
preinstalled app's system copy is then redirected to the stock APK with NoMount as well, and
when NoMount is missing or its redirect fails the app stays unmounted instead of being bind
mounted. The setting applies to an app the next time it is mounted or installed, and a stock
APK mounted over the system copy before stays there until the next reboot.
- The **Root mount** entry in the installer list only handles apps that were patched in
root mount mode. Standard patched APKs still go through whichever other installer you
selected.
Expand Down
Loading