diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ab24f54..d5724b70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(libs.guava) implementation(libs.jbcrypt) implementation(libs.kotlinx.serialization.core) + implementation(libs.libsuperuser) implementation(libs.lingala.zip4j) implementation(libs.localbroadcastmanager) implementation(libs.navigation3.runtime) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java index b49a652e..fd3f1778 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -9,6 +9,8 @@ import com.nutomic.syncthingandroid.service.AppPrefs; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.SyncthingService; +import com.nutomic.syncthingandroid.root.RootAccess; +import com.nutomic.syncthingandroid.util.Util; import java.lang.SecurityException; @@ -28,6 +30,16 @@ public void onReceive(Context context, Intent intent) { return; } + if (packageReplaced) { + if (AppPrefs.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { + /** + * In Root mode, there will be a SyncthingNative process left running after app update. + */ + Log.d(TAG, "ACTION_MY_PACKAGE_REPLACED: Killing leftover SyncthingNative instance if present ..."); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, true); + } + } + // Check if we should (re)start now. if (!AppPrefs.getStartServiceOnBoot(context)) { return; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/root/RootAccess.java b/app/src/main/java/com/nutomic/syncthingandroid/root/RootAccess.java new file mode 100644 index 00000000..653d7c16 --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/root/RootAccess.java @@ -0,0 +1,13 @@ +package com.nutomic.syncthingandroid.root; + +import eu.chainfire.libsuperuser.Shell; + +public final class RootAccess { + + private RootAccess() { + } + + public static boolean isRootAvailableBlocking() { + return Shell.SU.available(); + } +} diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java b/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java index af8492c5..f71b2616 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java @@ -34,4 +34,9 @@ public static final boolean getStartServiceOnBoot(Context context) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); } + + public static final boolean getUseRoot(Context context) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + return sp.getBoolean(Constants.PREF_USE_ROOT, false); + } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java index 948edca4..aed1d6e5 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java @@ -50,6 +50,7 @@ private PowerSource() { } // Preferences - Behaviour public static final String PREF_START_SERVICE_ON_BOOT = "always_run_in_background"; public static final String PREF_BROADCAST_SERVICE_CONTROL = "broadcast_service_control"; + public static final String PREF_USE_ROOT = "use_root"; public static final String PREF_ALLOW_OVERWRITE_FILES = "allow_overwrite_files"; // Preferences - Syncthing Options diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index ec028eb5..42d70b01 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -613,7 +613,7 @@ public void sendConfig() { public void shutdown() { hasShutdown = true; executorService.shutdownNow(); - Util.killProcess("find"); + Util.killProcess("find", AppPrefs.getUseRoot(mContext)); new PostRequest(mContext, mUrl, PostRequest.URI_SYSTEM_SHUTDOWN, mApiKey, null, null, null); } @@ -1252,7 +1252,7 @@ public void setRemoteCompletionInfo(final String deviceId, // Check for ".sync-conflict-YYYYMMDD-HHMMSS-DEVICEI*" files. mLocalCompletion.setDiscoveredConflictFiles( folderId, - Util.getSyncConflictFiles(folder.path) + Util.getSyncConflictFiles(folder.path, AppPrefs.getUseRoot(mContext)) ); } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java index b799d0f9..4a1de8c6 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -17,10 +17,12 @@ import com.google.common.io.Files; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.SyncthingApp; +import com.nutomic.syncthingandroid.root.RootAccess; import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.Util; import java.io.BufferedReader; +import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -59,6 +61,7 @@ public class SyncthingRunnable implements Runnable { private final File mSyncthingBinary; private String[] mCommand; private final File mSyncthingLogFile; + private final boolean mUseRoot; @Inject SharedPreferences mPreferences; @@ -88,6 +91,7 @@ public SyncthingRunnable(Context context, Command command) { mSyncthingLogFile = Constants.getSyncthingLogFile(mContext); // Get preferences relevant to starting syncthing core. + mUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false) && RootAccess.isRootAvailableBlocking(); switch (command) { case deviceid: mCommand = new String[]{mSyncthingBinary.getPath(), "device-id"}; @@ -436,9 +440,30 @@ private Process setupAndLaunch(HashMap env) throws IOException, throw new ExecutableNotFoundException(mCommand[0]); } } - ProcessBuilder pb = new ProcessBuilder(mCommand); - pb.environment().putAll(env); - return pb.start(); + + if (mUseRoot) { + ProcessBuilder pb = new ProcessBuilder("su"); + Process process = pb.start(); + // The su binary prohibits the inheritance of environment variables. + // Even with --preserve-environment the environment gets messed up. + // We therefore start a root shell, and set all the environment variables manually. + DataOutputStream suOut = new DataOutputStream(process.getOutputStream()); + for (Map.Entry entry : env.entrySet()) { + suOut.writeBytes(String.format("export %s=\"%s\"\n", entry.getKey(), entry.getValue())); + } + suOut.flush(); + // Exec will replace the su process image by Syncthing as execlp in C does. + // Without using exec, the process will drop to the root shell as soon as Syncthing terminates like a normal shell does. + // If we did not use exec, we would wait infinitely for the process to terminate (ret = process.waitFor(); in run()). + // With exec the whole process terminates when Syncthing exits. + suOut.writeBytes("exec " + TextUtils.join(" ", mCommand) + "\n"); + suOut.flush(); + return process; + } else { + ProcessBuilder pb = new ProcessBuilder(mCommand); + pb.environment().putAll(env); + return pb.start(); + } } public class ExecutableNotFoundException extends Exception { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java index 1c2133f4..8b5398fd 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -584,7 +584,7 @@ private void launchStartupTask(SyncthingRunnable.Command srCommand) { * Check if an old syncthing instance is still running. * This happens after an in-place app upgrade. If so, end it. */ - Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, AppPrefs.getUseRoot(this)); // Start the syncthing binary in a separate thread. Thread.UncaughtExceptionHandler syncthingRunnableThreadExceptionHandler = new Thread.UncaughtExceptionHandler() { @@ -712,7 +712,7 @@ private void shutdown(State newState) { } if (mSyncthingRunnable != null) { - Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, AppPrefs.getUseRoot(this)); if (mSyncthingRunnableThread != null) { LogV("Waiting for mSyncthingRunnableThread to finish after killProcess(Syncthing) ..."); try { @@ -1383,7 +1383,6 @@ private boolean importConfigSharedPrefs(final File file) { case "pref_current_language": case "restartOnWakeup": case "wakelock_while_binary_running": - case "use_root": case "important_news_shown_version": LogV("importConfig: Ignoring deprecated pref \"" + prefKey + "\"."); break; diff --git a/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt b/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt index 68d5e0bf..d54dbcaf 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt @@ -1,11 +1,21 @@ package com.nutomic.syncthingandroid.settings +import android.content.Intent +import android.widget.Toast import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.EntryProviderScope import com.nutomic.syncthingandroid.R +import com.nutomic.syncthingandroid.root.RootAccess import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService +import com.nutomic.syncthingandroid.util.LocalActivityScope +import com.nutomic.syncthingandroid.util.Util +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import me.zhanghai.compose.preference.SwitchPreference import me.zhanghai.compose.preference.rememberPreferenceState @@ -19,10 +29,48 @@ fun EntryProviderScope.settingsBehaviorEntry() { @Composable fun SettingsBehaviorScreen() { + val context = LocalContext.current + val scope = LocalActivityScope.current val autoStart = rememberPreferenceState(Constants.PREF_START_SERVICE_ON_BOOT, false) val broadcast = rememberPreferenceState(Constants.PREF_BROADCAST_SERVICE_CONTROL, false) val overwrite = rememberPreferenceState(Constants.PREF_ALLOW_OVERWRITE_FILES, false) + val useRoot = rememberPreferenceState(Constants.PREF_USE_ROOT, false) + + val restartSt = { + val intent = Intent(context, SyncthingService::class.java).apply { + action = SyncthingService.ACTION_RESTART + } + context.startService(intent) + } + + val toggleRoot = { enabled: Boolean -> + scope.launch(Dispatchers.IO) { + if (enabled) { + if (RootAccess.isRootAvailableBlocking()) { + withContext(Dispatchers.Main) { + useRoot.value = true + } + // restart after enabling root + restartSt() + } else { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + R.string.toast_root_denied, + Toast.LENGTH_LONG + ).show() + } + } + } else { + Util.fixAppDataPermissions(context) + withContext(Dispatchers.Main) { + useRoot.value = false + } + restartSt() + } + } + } SettingsScaffold( title = stringResource(R.string.category_behaviour), @@ -48,5 +96,15 @@ fun SettingsBehaviorScreen() { state = overwrite, ) } + item { + SwitchPreference( + title = { Text(stringResource(R.string.use_root_title)) }, + summary = { Text(stringResource(R.string.use_root_summary)) }, + value = useRoot.value, + onValueChange = { + toggleRoot(it) + }, + ) + } } } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java index 83f3cccc..c63cbca4 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/ConfigXml.java @@ -225,7 +225,7 @@ private String getLocalDeviceIDandStoreToPref() throws SyncthingRunnable.Executa } private void parseConfig() { - if (!mConfigFile.canRead()) { + if (!mConfigFile.canRead() && !Util.fixAppDataPermissions(mContext)) { Log.w(TAG, "Failed to open config file '" + mConfigFile + "'"); throw new OpenConfigException(); } @@ -1261,7 +1261,7 @@ private boolean addSyncthingCameraFolder() { * Writes updated mConfig back to file. */ public void saveChanges() { - if (!mConfigFile.canWrite()) { + if (!mConfigFile.canWrite() && !Util.fixAppDataPermissions(mContext)) { Log.w(TAG, "Failed to save updated config. Cannot change the owner of the config file."); return; } diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java index faea11af..befc939b 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -3,6 +3,8 @@ import android.app.Dialog; import android.app.UiModeManager; import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager.NameNotFoundException; import android.content.res.Configuration; import android.os.Build; import android.os.SystemClock; @@ -12,7 +14,9 @@ import androidx.appcompat.app.AppCompatActivity; import com.google.common.base.Charsets; +import com.nutomic.syncthingandroid.root.RootAccess; import com.nutomic.syncthingandroid.R; +import com.nutomic.syncthingandroid.service.AppPrefs; import com.nutomic.syncthingandroid.service.Constants; import java.io.BufferedReader; @@ -74,16 +78,74 @@ public static String readableTransferRate(Context context, long bits) { .format(bytes / Math.pow(1024, digitGroups)) + " " + units[digitGroups]; } + /** + * Normally an application's data directory is only accessible by the corresponding application. + * Therefore, every file and directory is owned by an application's user and group. When running Syncthing as root, + * it writes to the application's data directory. This leaves files and directories behind which are owned by root having 0600. + * Moreover, those actions performed as root changes a file's type in terms of SELinux. + * A subsequent start of Syncthing will fail due to insufficient permissions. + * Hence, this method fixes the owner, group and the files' type of the data directory. + * + * @return true if the operation was successfully performed. False otherwise. + */ + public static boolean fixAppDataPermissions(Context context) { + // We can safely assume that root magic is somehow available, because readConfig and saveChanges check for + // read and write access before calling us. + // Be paranoid :) and check if root is available. + // Ignore the 'use_root' preference, because we might want to fix the permission + // just after the root option has been disabled. + if (!RootAccess.isRootAvailableBlocking()) { + Log.e(TAG, "Root is not available. Cannot fix permissions."); + return false; + } + + String packageName; + ApplicationInfo appInfo; + try { + packageName = context.getPackageName(); + appInfo = context.getPackageManager().getApplicationInfo(packageName, 0); + + } catch (NameNotFoundException e) { + // This should not happen! + // One should always be able to retrieve the application info for its own package. + Log.w(TAG, "Error getting current package name", e); + return false; + } + Log.d(TAG, "Uid of '" + packageName + "' is " + appInfo.uid); + + // Get private app's "files" dir residing in "/data/data/[packageName]". + String dir = context.getFilesDir().getAbsolutePath(); + String cmd = "chown -R " + appInfo.uid + ":" + appInfo.uid + " " + dir + "; "; + // Running Syncthing as root might change a file's or directories type in terms of SELinux. + // Leaving them as they are, the Android service won't be able to access them. + // At least for those files residing in an application's data folder. + // Simply reverting the type to its default should do the trick. + cmd += "restorecon -R " + dir + "\n"; + Log.d(TAG, "Running: '" + cmd); + int exitCode = runShellCommand(cmd, true); + if (exitCode == 0) { + Log.i(TAG, "Fixed app data permissions on '" + dir + "'."); + } else { + Log.w(TAG, "Failed to fix app data permissions on '" + dir + "'. Result: " + + Integer.toString(exitCode)); + } + return exitCode == 0; + } + /** * Returns if the syncthing binary would be able to write a file into * the given folder given the configured access level. */ public static boolean nativeBinaryCanWriteToPath(Context context, String absoluteFolderPath) { final String TOUCH_FILE_NAME = ".stwritetest"; + Boolean useRoot = false; + if (AppPrefs.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { + useRoot = true; + } // Write permission test file. String touchFile = absoluteFolderPath + "/" + TOUCH_FILE_NAME; - int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n"); + int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n", useRoot); if (exitCode != 0) { String error; switch (exitCode) { @@ -102,7 +164,7 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut Log.i(TAG, "Successfully wrote test file '" + touchFile + "'"); // Remove test file. - if (runShellCommand("rm \"" + touchFile + "\"\n") != 0) { + if (runShellCommand("rm \"" + touchFile + "\"\n", useRoot) != 0) { // This is very unlikely to happen, so we have less error handling. Log.i(TAG, "Failed to remove test file"); } @@ -113,9 +175,9 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut * Look for running processes and return an array * containing the PIDs of found instances. */ - public static List getProcessPIDs(final String processName) { + public static List getProcessPIDs(final String processName, final Boolean useRoot) { List processPIDs = new ArrayList(); - String output = runShellCommandGetOutput("ps\n"); + String output = runShellCommandGetOutput("ps\n", useRoot); if (TextUtils.isEmpty(output)) { Log.w(TAG, "getProcessPIDs: Failed to list processes. ps command returned empty."); return processPIDs; @@ -141,15 +203,15 @@ public static List getProcessPIDs(final String processName) { /** * Look for running processes and end them gracefully. */ - public static void killProcess(final String processName) { + public static void killProcess(final String processName, final Boolean useRoot) { int exitCode; - List processPIDs = getProcessPIDs(processName); + List processPIDs = getProcessPIDs(processName, useRoot); if (processPIDs.isEmpty()) { Log.v(TAG, "killProcess: Found no running instances of [" + processName + "]"); return; } for (String processPID : processPIDs) { - exitCode = runShellCommand("kill -SIGINT " + processPID + "\n"); + exitCode = runShellCommand("kill -SIGINT " + processPID + "\n", useRoot); if (exitCode != 0) { Log.w(TAG, "killProcess: Failed to send kill SIGINT to process [" + processPID + "] exit code " + Integer.toString(exitCode)); @@ -159,7 +221,7 @@ public static void killProcess(final String processName) { /** * Wait for process to end. */ - while (!getProcessPIDs(processName).isEmpty()) { + while (!getProcessPIDs(processName, useRoot).isEmpty()) { SystemClock.sleep(50); } Log.d(TAG, "killProcess: No more instances of [" + processName + "] running"); @@ -168,14 +230,14 @@ public static void killProcess(final String processName) { /** * Run command in a shell and return the exit code. */ - public static int runShellCommand(String cmd) { + public static int runShellCommand(String cmd, Boolean useRoot) { // Assume "failure" exit code if an error is caught. // Note: redirectErrorStream(true); System.getProperty("line.separator"); int exitCode = 255; Process shellProc = null; DataOutputStream shellOut = null; try { - shellProc = Runtime.getRuntime().exec("sh"); + shellProc = Runtime.getRuntime().exec((useRoot) ? "su" : "sh"); shellOut = new DataOutputStream(shellProc.getOutputStream()); BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(shellOut)); Log.d(TAG, "runShellCommand: " + cmd); @@ -216,13 +278,17 @@ public static int runShellCommand(String cmd) { } public static String runShellCommandGetOutput(String cmd) { + return runShellCommandGetOutput(cmd, false); + } + + public static String runShellCommandGetOutput(String cmd, Boolean useRoot) { // Note: redirectErrorStream(true); System.getProperty("line.separator"); int exitCode = 255; String capturedStdOut = ""; Process shellProc = null; DataOutputStream shellOut = null; try { - shellProc = Runtime.getRuntime().exec("sh"); + shellProc = Runtime.getRuntime().exec((useRoot) ? "su" : "sh"); shellOut = new DataOutputStream(shellProc.getOutputStream()); BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(shellOut)); Log.d(TAG, "runShellCommandGetOutput: " + cmd); @@ -501,7 +567,7 @@ public boolean accept(File dir, String name) { /** * Called by RestApi/setRemoteCompletionInfo after folder completed. */ - public static String[] getSyncConflictFiles(final String absPath) { + public static String[] getSyncConflictFiles(final String absPath, final Boolean useRoot) { StringBuilder cmdBuilder = new StringBuilder(); cmdBuilder.append("cd \"").append(absPath).append("/\";"); // Unescaped: @@ -509,7 +575,7 @@ public static String[] getSyncConflictFiles(final String absPath) { cmdBuilder.append("find -type f -name \"*\\.sync-conflict-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-[a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9][a-zA-Z0-9]*\" -not -path \"\\.\\/\\" + Constants.FOLDER_NAME_STVERSIONS + "\\/*\" -print | sed \"s~\\\\.\\/~~\""); String command = cmdBuilder.toString(); // Log.v(TAG, "getSyncConflictFileCount: Exec [" + command + "]"); - String output = runShellCommandGetOutput(command); + String output = runShellCommandGetOutput(command, useRoot); // Log.v(TAG, "getSyncConflictFileCount: Exec result [" + output + "]"); if (output == null || output.isEmpty()) { return new String[]{}; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8372560b..ab597bbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,6 +410,9 @@ No folders configured. Tap to add a new folder. + + Did not get root permissions + There are no recent changes. @@ -531,6 +534,8 @@ Autostart Start app automatically on operating system startup. + Run Syncthing as Superuser + Running Syncthing as root allows it to write to folders Android normally restricts to read-only access. Use this feature with caution. Allow overwrite of existing files When another app shares files to one of our folders. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5e96b642..b3999dae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ kotlin = "2.4.10" kotlinSerialization = "2.4.10" kotlinxSerializationCore = "1.11.0" ksp = "2.3.10" +libsuperuser = "1.1.1" lingala-zip4j = "2.11.6" localbroadcastmanager = "1.1.0" nav3Core = "1.1.3" @@ -68,6 +69,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" } guava = { module = "com.google.guava:guava", version.ref = "guava" } jbcrypt = { module = "org.mindrot:jbcrypt", version.ref = "jbcrypt" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } +libsuperuser = { module = "eu.chainfire:libsuperuser", version.ref = "libsuperuser" } lingala-zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "lingala-zip4j" } localbroadcastmanager = { module = "androidx.localbroadcastmanager:localbroadcastmanager", version.ref = "localbroadcastmanager" } navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }