diff --git a/app/src/main/assets/root/post-fs-data.sh b/app/src/main/assets/root/post-fs-data.sh index 32c0a94a6..c9d3c2161 100644 --- a/app/src/main/assets/root/post-fs-data.sh +++ b/app/src/main/assets/root/post-fs-data.sh @@ -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" @@ -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 diff --git a/app/src/main/assets/root/service.sh b/app/src/main/assets/root/service.sh index 9268aa10a..2a76b5eb8 100644 --- a/app/src/main/assets/root/service.sh +++ b/app/src/main/assets/root/service.sh @@ -195,10 +195,18 @@ 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 @@ -206,6 +214,7 @@ redirect_patched() { "$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 diff --git a/app/src/main/java/app/morphe/manager/domain/installer/RootInstaller.kt b/app/src/main/java/app/morphe/manager/domain/installer/RootInstaller.kt index 063a20cfa..e39022c5a 100644 --- a/app/src/main/java/app/morphe/manager/domain/installer/RootInstaller.kt +++ b/app/src/main/java/app/morphe/manager/domain/installer/RootInstaller.kt @@ -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 @@ -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 @@ -160,7 +162,12 @@ 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) + "; " + @@ -168,12 +175,22 @@ class RootInstaller( 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 @@ -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" @@ -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") } @@ -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 @@ -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( diff --git a/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt index d71a91aa8..f04b8f05e 100644 --- a/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/morphe/manager/domain/manager/PreferencesManager.kt @@ -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) @@ -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? = null, val installerHiddenComponents: Set? = null, val keystoreAlias: String? = null, @@ -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 }, @@ -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(), @@ -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 } diff --git a/app/src/main/java/app/morphe/manager/ui/screen/settings/system/InstallerSettings.kt b/app/src/main/java/app/morphe/manager/ui/screen/settings/system/InstallerSettings.kt index 60791d7b7..8dd8c312c 100644 --- a/app/src/main/java/app/morphe/manager/ui/screen/settings/system/InstallerSettings.kt +++ b/app/src/main/java/app/morphe/manager/ui/screen/settings/system/InstallerSettings.kt @@ -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) } @@ -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( diff --git a/app/src/main/java/app/morphe/manager/ui/viewmodel/SettingsViewModel.kt b/app/src/main/java/app/morphe/manager/ui/viewmodel/SettingsViewModel.kt index 06427bb59..a8dc07ada 100644 --- a/app/src/main/java/app/morphe/manager/ui/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/app/morphe/manager/ui/viewmodel/SettingsViewModel.kt @@ -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) } @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63d16df7c..2423de726 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1115,6 +1115,8 @@ The image dimensions must be as follows: I understand, continue Install as Play Store Marks Google Play Store as the install source + NoMount only + Mounts apps only with NoMount and never falls back to a bind mount, so they stay unmounted without it Installer selection prompt Asks how to install every app: with root before patching, otherwise after it Auto-install after patching diff --git a/docs/installers.md b/docs/installers.md index 03e8c1d4f..8bc0703ed 100644 --- a/docs/installers.md +++ b/docs/installers.md @@ -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.