diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f570c21bd..1bff49981 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 b49a652eb..fd3f17787 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 000000000..653d7c166 --- /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 af8492c58..f71b26168 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 71b9a2fc0..bc039205e 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 8fe62479e..999f60371 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); } @@ -1262,7 +1262,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 b799d0f93..1e53cd131 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,43 @@ 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()); + // Every value written into this shell must be quoted. The environment may contain + // user supplied entries (Constants.PREF_ENVIRONMENT_VARIABLES), and an unquoted value + // would be expanded by the shell - meaning e.g. "$(...)" executes as root. + for (Map.Entry entry : env.entrySet()) { + if (!Util.isValidEnvVarName(entry.getKey())) { + // A name cannot be quoted, so reject it rather than let it inject commands. + Log.w(TAG, "Skipping environment variable with invalid name [" + entry.getKey() + "]"); + continue; + } + suOut.writeBytes(String.format("export %s=%s\n", + entry.getKey(), Util.shellQuote(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. + StringBuilder execLine = new StringBuilder("exec"); + for (String arg : mCommand) { + execLine.append(' ').append(Util.shellQuote(arg)); + } + suOut.writeBytes(execLine.append('\n').toString()); + 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 720b06d3c..a42e0c807 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -594,7 +594,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() { @@ -722,7 +722,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 { @@ -1431,7 +1431,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 68d5e0bf4..d54dbcaf0 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 83f3ccccd..c63cbca40 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 faea11afe..e47dfca0e 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,78 @@ 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 quotedDir = shellQuote(dir); + String cmd = "chown -R " + appInfo.uid + ":" + appInfo.uid + " " + quotedDir + "; "; + // 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 " + quotedDir + "\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. + // The folder path is chosen by the user, so it must be quoted - it may contain characters + // the shell would otherwise expand, and this command runs as root when root mode is on. String touchFile = absoluteFolderPath + "/" + TOUCH_FILE_NAME; - int exitCode = runShellCommand("echo \"\" > \"" + touchFile + "\"\n"); + String quotedTouchFile = shellQuote(touchFile); + int exitCode = runShellCommand("echo \"\" > " + quotedTouchFile + "\n", useRoot); if (exitCode != 0) { String error; switch (exitCode) { @@ -102,20 +168,65 @@ 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 " + quotedTouchFile + "\n", useRoot) != 0) { // This is very unlikely to happen, so we have less error handling. Log.i(TAG, "Failed to remove test file"); } return true; } + /** + * Quotes a string for safe use as a single argument in a POSIX shell command line. + * + * Everything is wrapped in single quotes, within which the shell performs no expansion at all, + * so '$', '`', '"', '\' and whitespace are passed through literally. An embedded single quote + * cannot be escaped inside single quotes, so it is emitted as '\'' - closing the quoted run, + * appending an escaped quote, then reopening it. + * + * Always use this for any value that ends up in a command line, especially when the command + * runs as root. Values reachable from the UI (folder paths, custom environment variables) are + * otherwise evaluated by the shell. + */ + public static String shellQuote(String s) { + if (s == null) { + return "''"; + } + return "'" + s.replace("'", "'\\''") + "'"; + } + + /** + * Returns whether the given name is a valid POSIX shell variable name, i.e. matches + * [A-Za-z_][A-Za-z0-9_]* + * + * The name side of an "export NAME=value" statement cannot be quoted - it has to be a bare + * identifier - so unlike the value it cannot be made safe by escaping. It must be rejected + * instead, otherwise a crafted name injects additional shell commands. + */ + public static boolean isValidEnvVarName(String name) { + if (TextUtils.isEmpty(name)) { + return false; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + boolean isAlpha = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; + if (isAlpha) { + continue; + } + if (i > 0 && c >= '0' && c <= '9') { + continue; + } + return false; + } + return true; + } + /** * 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 +252,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 +270,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 +279,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 +327,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 +616,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 +624,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 36574d1ec..12b2e89b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,6 +414,9 @@ No folders configured. Tap to add a new folder. + + Did not get root permissions + There are no recent changes. @@ -537,6 +540,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 aafa3804d..8551751cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,7 @@ kotlin = "2.4.20" kotlinSerialization = "2.4.20" kotlinxSerializationCore = "1.11.0" ksp = "2.3.12" +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" }