From 802ae36a019aa135892f4681b1664aa30ad8b8d4 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Sat, 10 Jan 2026 14:56:59 +0100 Subject: [PATCH 01/17] feat(root): refactor root access step 1 restore outdated and buggy old root code --- app/build.gradle.kts | 1 + .../activities/LogActivity.java | 2 +- .../activities/SettingsActivity.java | 61 +++++++++++++- .../receiver/BootReceiver.java | 17 ++++ .../syncthingandroid/service/Constants.java | 1 + .../service/SyncthingRunnable.java | 35 ++++++-- .../service/SyncthingService.java | 1 - .../syncthingandroid/util/ConfigXml.java | 4 +- .../nutomic/syncthingandroid/util/Util.java | 80 ++++++++++++++++--- app/src/main/res/values/strings.xml | 5 ++ app/src/main/res/xml/app_settings.xml | 8 ++ gradle/libs.versions.toml | 2 + 12 files changed, 197 insertions(+), 20 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7b926e030..9f6251348 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(libs.gson) implementation(libs.guava) implementation(libs.jbcrypt) + implementation(libs.libsuperuser) implementation(libs.lingala.zip4j) implementation(libs.localbroadcastmanager) implementation(libs.preference.ktx) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java index f97ca6953..2105be876 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java @@ -191,7 +191,7 @@ protected void onPostExecute(Void aVoid) { * @param syncthingLog Filter on Syncthing's native messages. */ private String getAndroidLog() { - String output = Util.runShellCommandGetOutput("/system/bin/logcat -t " + Integer.toString(ANDROID_LOG_FILE_MAX_LINES) + " -v time *:i ps:s art:s"); + String output = Util.runShellCommandGetOutput("/system/bin/logcat -t " + Integer.toString(ANDROID_LOG_FILE_MAX_LINES) + " -v time *:i ps:s art:s", false); // Filter Android log. output = output.replaceAll("I/SyncthingNativeCode", ""); diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java index a5114f664..cab245141 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/SettingsActivity.java @@ -68,6 +68,8 @@ import javax.inject.Inject; +import eu.chainfire.libsuperuser.Shell; + public class SettingsActivity extends SyncthingActivity { private static final String TAG = "SettingsActivity"; @@ -179,6 +181,7 @@ public static class SettingsFragment extends PreferenceFragment /* Behaviour */ private CheckBoxPreference mStartServiceOnBoot; + private CheckBoxPreference mUseRoot; /* Syncthing Options */ private PreferenceScreen mCategorySyncthingOptions; @@ -306,6 +309,9 @@ public void onActivityCreated(Bundle savedInstanceState) { PreferenceScreen categoryBehaviour = (PreferenceScreen) findPreference("category_behaviour"); mStartServiceOnBoot = (CheckBoxPreference) findPreference(Constants.PREF_START_SERVICE_ON_BOOT); + mUseRoot = + (CheckBoxPreference) findPreference(Constants.PREF_USE_ROOT); + setPreferenceCategoryChangeListener(categoryBehaviour, this::onBehaviourPreferenceChange); /* Syncthing Options */ mDeviceName = (EditTextPreference) findPreference("deviceName"); @@ -620,6 +626,20 @@ public boolean onUserInterfacePreferenceChange(Preference preference, Object o) return true; } + public boolean onBehaviourPreferenceChange(Preference preference, Object o) { + switch (preference.getKey()) { + case Constants.PREF_USE_ROOT: + if ((Boolean) o) { + new TestRootTask(this).execute(); + } else { + new Thread(() -> Util.fixAppDataPermissions(getActivity())).start(); + mPendingConfig = true; + } + break; + } + return true; + } + public boolean onSyncthingPreferenceChange(Preference preference, Object o) { Splitter splitter = Splitter.on(",").trimResults().omitEmptyStrings(); switch (preference.getKey()) { @@ -941,6 +961,43 @@ private static AlertDialog.Builder getAppRestartConfirmationDialog(Activity acti .setNegativeButton(android.R.string.no, (dialogInterface, i) -> {}); } + /** + * Enables or disables {@link #mUseRoot} preference depending whether root is available. + */ + private static class TestRootTask extends AsyncTask { + private WeakReference refSettingsFragment; + + TestRootTask(SettingsFragment context) { + refSettingsFragment = new WeakReference<>(context); + } + + @Override + protected Boolean doInBackground(Void... params) { + return Shell.SU.available(); + } + + @Override + protected void onPostExecute(Boolean haveRoot) { + // Get a reference to the fragment if it is still there. + SettingsFragment settingsFragment = refSettingsFragment.get(); + if (settingsFragment == null) { + return; + } + settingsFragment.mUseRoot.setOnPreferenceChangeListener(null); + settingsFragment.mUseRoot.setChecked(haveRoot); + if (haveRoot) { + settingsFragment.mPendingConfig = true; + } else { + SyncthingActivity syncthingActivity = (SyncthingActivity) settingsFragment.getActivity(); + if (syncthingActivity != null && !syncthingActivity.isFinishing()) { + Toast.makeText(syncthingActivity, R.string.toast_root_denied, Toast.LENGTH_SHORT) + .show(); + } + } + settingsFragment.mUseRoot.setOnPreferenceChangeListener(settingsFragment::onBehaviourPreferenceChange); + } + } + /** * Performs export of settings, config and database in the background. */ @@ -1109,7 +1166,7 @@ private boolean handleHttpProxyPreferenceChange(Preference preference, String ne */ private String getDatabaseSize() { String dbPath = Constants.getIndexDbFolder(mContext).getAbsolutePath(); - String result = Util.runShellCommandGetOutput("/system/bin/du -sh " + dbPath); + String result = Util.runShellCommandGetOutput("/system/bin/du -sh " + dbPath, false); if (TextUtils.isEmpty(result)) { return "N/A"; } @@ -1128,7 +1185,7 @@ private String getOpenFileLimit() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { shellCommand = "/system/bin/" + shellCommand; } - String result = Util.runShellCommandGetOutput(shellCommand); + String result = Util.runShellCommandGetOutput(shellCommand, false); if (TextUtils.isEmpty(result)) { return "N/A"; } 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 a8360de97..4cbba25e3 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -12,6 +12,8 @@ import com.nutomic.syncthingandroid.service.SyncthingRunnable; import com.nutomic.syncthingandroid.service.SyncthingService; +import eu.chainfire.libsuperuser.Shell; + import java.lang.SecurityException; public class BootReceiver extends BroadcastReceiver { @@ -30,6 +32,16 @@ public void onReceive(Context context, Intent intent) { return; } + if (packageReplaced) { + if (getPrefUseRoot(context) && Shell.SU.available()) { + /** + * 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 ..."); + new SyncthingRunnable(context, SyncthingRunnable.Command.main).killSyncthing(); + } + } + // Check if we should (re)start now. if (!getPrefStartServiceOnBoot(context)) { return; @@ -57,4 +69,9 @@ private static boolean getPrefStartServiceOnBoot(Context context) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); } + + private static boolean getPrefUseRoot(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 579f01a9c..d414d732a 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/Constants.java @@ -52,6 +52,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/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java index d54b11866..9e3981b23 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -47,6 +47,8 @@ import javax.inject.Inject; +import eu.chainfire.libsuperuser.Shell; + import static com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE; /** @@ -68,6 +70,7 @@ public class SyncthingRunnable implements Runnable { private final File mSyncthingBinary; private String[] mCommand; private final File mSyncthingLogFile; + private final boolean mUseRoot; @Inject SharedPreferences mPreferences; @@ -97,6 +100,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) && Shell.SU.available(); switch (command) { case deviceid: mCommand = new String[]{mSyncthingBinary.getPath(), "device-id"}; @@ -270,7 +274,7 @@ private void putCustomEnvironmentVariables(Map environment, Shar */ private List getSyncthingPIDs(Boolean enableLog) { List syncthingPIDs = new ArrayList(); - String output = Util.runShellCommandGetOutput("ps\n"); + String output = Util.runShellCommandGetOutput("ps\n", mUseRoot); if (TextUtils.isEmpty(output)) { Log.w(TAG, "Failed to list SyncthingNative processes. ps command returned empty."); return syncthingPIDs; @@ -306,7 +310,7 @@ public void killSyncthing() { return; } for (String syncthingPID : syncthingPIDs) { - exitCode = Util.runShellCommand("kill -SIGINT " + syncthingPID + "\n"); + exitCode = Util.runShellCommand("kill -SIGINT " + syncthingPID + "\n", mUseRoot); if (exitCode == 0) { LogV("Sent kill SIGINT to process " + syncthingPID); } else { @@ -458,9 +462,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 e694d3c28..97d36f06d 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -1095,7 +1095,6 @@ private boolean importConfigSharedPrefs(final File file) { case "pref_current_language": case "restartOnWakeup": case "wakelock_while_binary_running": - case "use_root": LogV("importConfig: Ignoring deprecated pref \"" + prefKey + "\"."); break; // Cached information which is not available on SettingsActivity. 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 e290399dc..44278ab9b 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(); } @@ -1257,7 +1257,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 c3b4482f8..394e2a410 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -31,6 +31,8 @@ import java.time.ZoneOffset; import java.util.Locale; +import eu.chainfire.libsuperuser.Shell; + public class Util { private static final String TAG = "Util"; @@ -66,16 +68,76 @@ 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 (!Shell.SU.available()) { + 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; + Boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.PREF_USE_ROOT, false); + if (prefUseRoot && Shell.SU.available()) { + 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) { @@ -94,7 +156,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"); } @@ -104,14 +166,14 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut /** * 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); @@ -151,14 +213,14 @@ public static int runShellCommand(String cmd) { return exitCode; } - public static String runShellCommandGetOutput(String cmd) { + 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); @@ -209,7 +271,7 @@ public static String runShellCommandGetOutput(String cmd) { */ public static Boolean isTcpPortListening(Integer port) { // t: tcp, l: listening, n: numeric - String output = runShellCommandGetOutput("netstat -t -l -n"); + String output = runShellCommandGetOutput("netstat -t -l -n", false); if (TextUtils.isEmpty(output)) { Log.w(TAG, "isTcpPortListening: Failed to run netstat. Returning false."); return false; @@ -430,7 +492,7 @@ public boolean accept(File dir, String name) { // Execute script. String command = cmdBuilder.toString(); // Log.d(TAG, "runScriptSet: Exec [" + command + "]"); - Log.v(TAG, "runScriptSet: Exec result [" + runShellCommandGetOutput(command) + "]"); + Log.v(TAG, "runScriptSet: Exec result [" + runShellCommandGetOutput(command, false) + "]"); } } @@ -445,7 +507,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, false); // 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 9cbb26c85..bfed4944a 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. @@ -520,6 +523,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 be readonly accessed. Use this feature with caution. Allow overwrite of existing files When another app shares files to one of our folders. diff --git a/app/src/main/res/xml/app_settings.xml b/app/src/main/res/xml/app_settings.xml index f6bf59aec..073b4d593 100644 --- a/app/src/main/res/xml/app_settings.xml +++ b/app/src/main/res/xml/app_settings.xml @@ -166,6 +166,14 @@ android:summary="@string/broadcast_service_control_summary" android:singleLineTitle="false" /> + + + Date: Sat, 10 Jan 2026 15:05:57 +0100 Subject: [PATCH 02/17] readd imports dropped in e778efb4d6e0fc30f4ff8fabeb8c0567537a66c7 --- app/src/main/java/com/nutomic/syncthingandroid/util/Util.java | 3 +++ 1 file changed, 3 insertions(+) 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 394e2a410..caf1ba364 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -3,12 +3,15 @@ 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.text.TextUtils; import android.util.Log; import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; import com.google.common.base.Charsets; import com.nutomic.syncthingandroid.R; From 6b8d6e4b826a087bbd32758919a833f09f37a2dc Mon Sep 17 00:00:00 2001 From: Dhruv Bhavsar <40565844+dbhavsar76@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:52:11 +0530 Subject: [PATCH 03/17] Resolve conflict with main, add use root preference to new settings screen (#172) Signed-off-by: Jonas <244199422+researchxxl@users.noreply.github.com> Co-authored-by: Dhruv Bhavsar Co-authored-by: Jonas <244199422+researchxxl@users.noreply.github.com> --- .../activities/LogActivity.java | 2 +- .../settings/SettingsBehaviorScreen.kt | 41 +++++++++++++++++++ .../nutomic/syncthingandroid/util/Util.java | 10 +++-- gradle/libs.versions.toml | 2 +- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java b/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java index 2105be876..f97ca6953 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/activities/LogActivity.java @@ -191,7 +191,7 @@ protected void onPostExecute(Void aVoid) { * @param syncthingLog Filter on Syncthing's native messages. */ private String getAndroidLog() { - String output = Util.runShellCommandGetOutput("/system/bin/logcat -t " + Integer.toString(ANDROID_LOG_FILE_MAX_LINES) + " -v time *:i ps:s art:s", false); + String output = Util.runShellCommandGetOutput("/system/bin/logcat -t " + Integer.toString(ANDROID_LOG_FILE_MAX_LINES) + " -v time *:i ps:s art:s"); // Filter Android log. output = output.replaceAll("I/SyncthingNativeCode", ""); 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..1c74d818a 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,18 @@ package com.nutomic.syncthingandroid.settings +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.service.Constants +import com.nutomic.syncthingandroid.util.Util +import eu.chainfire.libsuperuser.Shell +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 +26,34 @@ 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 toggleRoot = { enabled: Boolean -> + scope.launch(Dispatchers.IO) { + if (enabled) { + if (Shell.SU.available()) { + useRoot.value = true + } else { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + R.string.toast_root_denied, + Toast.LENGTH_LONG + ).show() + } + } + } else { + Util.fixAppDataPermissions(context) + useRoot.value = false + } + } + } SettingsScaffold( title = stringResource(R.string.category_behaviour), @@ -48,5 +79,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/Util.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java index caf1ba364..b5a3eea15 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -216,6 +216,10 @@ public static int runShellCommand(String cmd, Boolean useRoot) { return exitCode; } + 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; @@ -274,7 +278,7 @@ public static String runShellCommandGetOutput(String cmd, Boolean useRoot) { */ public static Boolean isTcpPortListening(Integer port) { // t: tcp, l: listening, n: numeric - String output = runShellCommandGetOutput("netstat -t -l -n", false); + String output = runShellCommandGetOutput("netstat -t -l -n"); if (TextUtils.isEmpty(output)) { Log.w(TAG, "isTcpPortListening: Failed to run netstat. Returning false."); return false; @@ -495,7 +499,7 @@ public boolean accept(File dir, String name) { // Execute script. String command = cmdBuilder.toString(); // Log.d(TAG, "runScriptSet: Exec [" + command + "]"); - Log.v(TAG, "runScriptSet: Exec result [" + runShellCommandGetOutput(command, false) + "]"); + Log.v(TAG, "runScriptSet: Exec result [" + runShellCommandGetOutput(command) + "]"); } } @@ -510,7 +514,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, false); + String output = runShellCommandGetOutput(command); // Log.v(TAG, "getSyncConflictFileCount: Exec result [" + output + "]"); if (output == null || output.isEmpty()) { return new String[]{}; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66665c270..95b9c97d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,4 +87,4 @@ aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.re android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} +jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization" } From 59d7dd4e8aeb3ee8c90f5657620e16d07ed40ead Mon Sep 17 00:00:00 2001 From: Dhruv Bhavsar <40565844+dbhavsar76@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:59:27 +0530 Subject: [PATCH 04/17] restart syncthing after enabling/disabling root (#173) Co-authored-by: Dhruv Bhavsar --- .../settings/SettingsBehaviorScreen.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 1c74d818a..7e6d0b842 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt @@ -1,5 +1,6 @@ package com.nutomic.syncthingandroid.settings +import android.content.Intent import android.widget.Toast import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -8,6 +9,7 @@ import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.EntryProviderScope import com.nutomic.syncthingandroid.R import com.nutomic.syncthingandroid.service.Constants +import com.nutomic.syncthingandroid.service.SyncthingService import com.nutomic.syncthingandroid.util.Util import eu.chainfire.libsuperuser.Shell import kotlinx.coroutines.Dispatchers @@ -34,11 +36,22 @@ fun SettingsBehaviorScreen() { 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 (Shell.SU.available()) { - useRoot.value = true + withContext(Dispatchers.Main) { + useRoot.value = true + } + // restart after enabling root + restartSt() } else { withContext(Dispatchers.Main) { Toast.makeText( @@ -50,7 +63,10 @@ fun SettingsBehaviorScreen() { } } else { Util.fixAppDataPermissions(context) - useRoot.value = false + withContext(Dispatchers.Main) { + useRoot.value = false + } + restartSt() } } } From 60c557f36c97494645d497ae5acc3c1439efdd9e Mon Sep 17 00:00:00 2001 From: Dhruv Bhavsar <40565844+dbhavsar76@users.noreply.github.com> Date: Tue, 5 May 2026 18:14:23 +0530 Subject: [PATCH 05/17] abstract all the libsuperuser calls to new RootAccess adapter --- .../syncthingandroid/receiver/BootReceiver.java | 5 ++--- .../nutomic/syncthingandroid/root/RootAccess.java | 13 +++++++++++++ .../syncthingandroid/service/SyncthingRunnable.java | 5 ++--- .../settings/SettingsBehaviorScreen.kt | 4 ++-- .../com/nutomic/syncthingandroid/util/Util.java | 7 +++---- 5 files changed, 22 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/root/RootAccess.java 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 4cbba25e3..8bbe82aaa 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -11,8 +11,7 @@ import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.service.SyncthingRunnable; import com.nutomic.syncthingandroid.service.SyncthingService; - -import eu.chainfire.libsuperuser.Shell; +import com.nutomic.syncthingandroid.root.RootAccess; import java.lang.SecurityException; @@ -33,7 +32,7 @@ public void onReceive(Context context, Intent intent) { } if (packageReplaced) { - if (getPrefUseRoot(context) && Shell.SU.available()) { + if (getPrefUseRoot(context) && RootAccess.isRootAvailableBlocking()) { /** * In Root mode, there will be a SyncthingNative process left running after app update. */ 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/SyncthingRunnable.java b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java index 9e3981b23..5c5dc8eba 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -22,6 +22,7 @@ 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; @@ -47,8 +48,6 @@ import javax.inject.Inject; -import eu.chainfire.libsuperuser.Shell; - import static com.nutomic.syncthingandroid.service.SyncthingService.EXTRA_STOP_AFTER_CRASHED_NATIVE; /** @@ -100,7 +99,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) && Shell.SU.available(); + mUseRoot = mPreferences.getBoolean(Constants.PREF_USE_ROOT, false) && RootAccess.isRootAvailableBlocking(); switch (command) { case deviceid: mCommand = new String[]{mSyncthingBinary.getPath(), "device-id"}; 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 7e6d0b842..160150ca0 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt @@ -8,10 +8,10 @@ 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.Util -import eu.chainfire.libsuperuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -46,7 +46,7 @@ fun SettingsBehaviorScreen() { val toggleRoot = { enabled: Boolean -> scope.launch(Dispatchers.IO) { if (enabled) { - if (Shell.SU.available()) { + if (RootAccess.isRootAvailableBlocking()) { withContext(Dispatchers.Main) { useRoot.value = true } 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 b5a3eea15..0f0aaa730 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -14,6 +14,7 @@ import androidx.preference.PreferenceManager; import com.google.common.base.Charsets; +import com.nutomic.syncthingandroid.root.RootAccess; import com.nutomic.syncthingandroid.R; import com.nutomic.syncthingandroid.service.Constants; @@ -34,8 +35,6 @@ import java.time.ZoneOffset; import java.util.Locale; -import eu.chainfire.libsuperuser.Shell; - public class Util { private static final String TAG = "Util"; @@ -87,7 +86,7 @@ public static boolean fixAppDataPermissions(Context context) { // 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 (!Shell.SU.available()) { + if (!RootAccess.isRootAvailableBlocking()) { Log.e(TAG, "Root is not available. Cannot fix permissions."); return false; } @@ -134,7 +133,7 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut Boolean useRoot = false; Boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(Constants.PREF_USE_ROOT, false); - if (prefUseRoot && Shell.SU.available()) { + if (prefUseRoot && RootAccess.isRootAvailableBlocking()) { useRoot = true; } From 108ff5c28a98ad01a99335eb5104ea6a74b4fc5c Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Mon, 18 May 2026 17:15:04 +0200 Subject: [PATCH 06/17] java.io.DataOutputStream --- .../com/nutomic/syncthingandroid/service/SyncthingRunnable.java | 1 + 1 file changed, 1 insertion(+) 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 ad76863ed..6ebef53d2 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -23,6 +23,7 @@ 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; From c7b2a9a56068fd71e13d2c3a64b3ee29b50f1c00 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:52:33 +0200 Subject: [PATCH 07/17] Update SettingsBehaviorScreen.kt --- .../nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt | 1 + 1 file changed, 1 insertion(+) 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 160150ca0..d54dbcaf0 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt +++ b/app/src/main/java/com/nutomic/syncthingandroid/settings/SettingsBehaviorScreen.kt @@ -11,6 +11,7 @@ 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 From be5253e0cce65f55bd1e7b91b16e5d126fa381b2 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:33:15 +0200 Subject: [PATCH 08/17] fix bootreceiver --- .../com/nutomic/syncthingandroid/receiver/BootReceiver.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8bbe82aaa..77620670c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -12,6 +12,7 @@ import com.nutomic.syncthingandroid.service.SyncthingRunnable; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.root.RootAccess; +import com.nutomic.syncthingandroid.util.Util; import java.lang.SecurityException; @@ -37,7 +38,7 @@ public void onReceive(Context context, Intent intent) { * 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 ..."); - new SyncthingRunnable(context, SyncthingRunnable.Command.main).killSyncthing(); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, true); } } From db3f0b9f31043b1747172e23c9064bccebcce077 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:33:42 +0200 Subject: [PATCH 09/17] util: add useroot to new funcs --- .../java/com/nutomic/syncthingandroid/util/Util.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 878c6df20..14a8460b6 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -177,9 +177,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; @@ -205,15 +205,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)); @@ -223,7 +223,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"); From 08a7d374d10598aea405b77d5fdafef275c54494 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:33:50 +0200 Subject: [PATCH 10/17] fix restapi --- .../main/java/com/nutomic/syncthingandroid/service/RestApi.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ec028eb56..a567b64e5 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", false); new PostRequest(mContext, mUrl, PostRequest.URI_SYSTEM_SHUTDOWN, mApiKey, null, null, null); } From 40788a7311a09534db2dff0e99a810296f46f8e3 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:42:12 +0200 Subject: [PATCH 11/17] add preference.java --- .../receiver/BootReceiver.java | 18 +++-------------- .../syncthingandroid/util/Preference.java | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java 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 77620670c..3247544e9 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -3,15 +3,13 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; import android.os.Build; -import androidx.preference.PreferenceManager; import android.util.Log; import com.nutomic.syncthingandroid.service.Constants; -import com.nutomic.syncthingandroid.service.SyncthingRunnable; import com.nutomic.syncthingandroid.service.SyncthingService; import com.nutomic.syncthingandroid.root.RootAccess; +import com.nutomic.syncthingandroid.util.Preference; import com.nutomic.syncthingandroid.util.Util; import java.lang.SecurityException; @@ -33,7 +31,7 @@ public void onReceive(Context context, Intent intent) { } if (packageReplaced) { - if (getPrefUseRoot(context) && RootAccess.isRootAvailableBlocking()) { + if (Preference.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { /** * In Root mode, there will be a SyncthingNative process left running after app update. */ @@ -43,7 +41,7 @@ public void onReceive(Context context, Intent intent) { } // Check if we should (re)start now. - if (!getPrefStartServiceOnBoot(context)) { + if (!Preference.getStartServiceOnBoot(context)) { return; } @@ -64,14 +62,4 @@ public static void startServiceCompat(Context context) { context.startService(intent); } } - - private static boolean getPrefStartServiceOnBoot(Context context) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - } - - private static boolean getPrefUseRoot(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/util/Preference.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java new file mode 100644 index 000000000..5580fba9a --- /dev/null +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java @@ -0,0 +1,20 @@ +package com.nutomic.syncthingandroid.util; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.preference.PreferenceManager; + +import com.nutomic.syncthingandroid.service.Constants; + +public class Preference { + + public static boolean getStartServiceOnBoot(Context context) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); + } + + public static boolean getUseRoot(Context context) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + return sp.getBoolean(Constants.PREF_USE_ROOT, false); + } +} From 8ec299e67fda32102b9d5078e79fc7020f75393b Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:47:21 +0200 Subject: [PATCH 12/17] fix syncthingservice --- .../nutomic/syncthingandroid/service/SyncthingService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 cddfd48db..e06fc82f6 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -19,6 +19,7 @@ import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.PermissionUtil; +import com.nutomic.syncthingandroid.util.Preference; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -584,7 +585,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, Preference.getUseRoot(this)); // Start the syncthing binary in a separate thread. Thread.UncaughtExceptionHandler syncthingRunnableThreadExceptionHandler = new Thread.UncaughtExceptionHandler() { @@ -712,7 +713,7 @@ private void shutdown(State newState) { } if (mSyncthingRunnable != null) { - Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, Preference.getUseRoot(this)); if (mSyncthingRunnableThread != null) { LogV("Waiting for mSyncthingRunnableThread to finish after killProcess(Syncthing) ..."); try { From 18295cbe361fbf8037713b28a8edd943d923e718 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:52:25 +0200 Subject: [PATCH 13/17] fix conflict detector not working in root mode --- .../java/com/nutomic/syncthingandroid/service/RestApi.java | 5 +++-- .../main/java/com/nutomic/syncthingandroid/util/Util.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 a567b64e5..fd92c4f1c 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -52,6 +52,7 @@ import com.nutomic.syncthingandroid.model.SystemVersion; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.util.FileUtils; +import com.nutomic.syncthingandroid.util.Preference; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -613,7 +614,7 @@ public void sendConfig() { public void shutdown() { hasShutdown = true; executorService.shutdownNow(); - Util.killProcess("find", false); + Util.killProcess("find", Preference.getUseRoot(mContext)); new PostRequest(mContext, mUrl, PostRequest.URI_SYSTEM_SHUTDOWN, mApiKey, null, null, null); } @@ -1252,7 +1253,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, Preference.getUseRoot(mContext)) ); } 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 14a8460b6..067bf0343 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -569,7 +569,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: @@ -577,7 +577,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[]{}; From 5259afc7a37700a8def7971733678d7217fbb4bd Mon Sep 17 00:00:00 2001 From: Jonas <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:01:08 +0200 Subject: [PATCH 14/17] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Jonas <244199422+researchxxl@users.noreply.github.com> --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f488e5a2..cd4cad795 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -531,7 +531,7 @@ 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 be readonly accessed. Use this feature with caution. + 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. From 523228caeb8d89fc60acfb81b70a2e535ddbfbe3 Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:07:40 +0200 Subject: [PATCH 15/17] preference into appprefs --- .../receiver/BootReceiver.java | 6 +++--- .../syncthingandroid/service/AppPrefs.java | 14 +++++++++++-- .../syncthingandroid/service/RestApi.java | 5 ++--- .../service/SyncthingService.java | 5 ++--- .../syncthingandroid/util/Preference.java | 20 ------------------- 5 files changed, 19 insertions(+), 31 deletions(-) delete mode 100644 app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java 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 3247544e9..fd3f17787 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/receiver/BootReceiver.java @@ -6,10 +6,10 @@ import android.os.Build; import android.util.Log; +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.Preference; import com.nutomic.syncthingandroid.util.Util; import java.lang.SecurityException; @@ -31,7 +31,7 @@ public void onReceive(Context context, Intent intent) { } if (packageReplaced) { - if (Preference.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { + if (AppPrefs.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { /** * In Root mode, there will be a SyncthingNative process left running after app update. */ @@ -41,7 +41,7 @@ public void onReceive(Context context, Intent intent) { } // Check if we should (re)start now. - if (!Preference.getStartServiceOnBoot(context)) { + if (!AppPrefs.getStartServiceOnBoot(context)) { return; } 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 8e0c42b41..f71b26168 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/AppPrefs.java @@ -1,11 +1,11 @@ - package com.nutomic.syncthingandroid.service; import android.content.Context; import android.content.SharedPreferences; -import androidx.preference.PreferenceManager; import android.util.Log; +import androidx.preference.PreferenceManager; + /** * Provides preference getters and setters. */ @@ -29,4 +29,14 @@ public static final boolean getPrefVerboseLog(SharedPreferences sharedPreference } return sharedPreferences.getBoolean(Constants.PREF_VERBOSE_LOG, PREF_VERBOSE_LOG_DEFAULT); } + + 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/RestApi.java b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java index fd92c4f1c..42d70b012 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/RestApi.java @@ -52,7 +52,6 @@ import com.nutomic.syncthingandroid.model.SystemVersion; import com.nutomic.syncthingandroid.service.Constants; import com.nutomic.syncthingandroid.util.FileUtils; -import com.nutomic.syncthingandroid.util.Preference; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -614,7 +613,7 @@ public void sendConfig() { public void shutdown() { hasShutdown = true; executorService.shutdownNow(); - Util.killProcess("find", Preference.getUseRoot(mContext)); + Util.killProcess("find", AppPrefs.getUseRoot(mContext)); new PostRequest(mContext, mUrl, PostRequest.URI_SYSTEM_SHUTDOWN, mApiKey, null, null, null); } @@ -1253,7 +1252,7 @@ public void setRemoteCompletionInfo(final String deviceId, // Check for ".sync-conflict-YYYYMMDD-HHMMSS-DEVICEI*" files. mLocalCompletion.setDiscoveredConflictFiles( folderId, - Util.getSyncConflictFiles(folder.path, Preference.getUseRoot(mContext)) + Util.getSyncConflictFiles(folder.path, AppPrefs.getUseRoot(mContext)) ); } 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 e06fc82f6..8b5398fd8 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingService.java @@ -19,7 +19,6 @@ import com.nutomic.syncthingandroid.util.ConfigXml; import com.nutomic.syncthingandroid.util.FileUtils; import com.nutomic.syncthingandroid.util.PermissionUtil; -import com.nutomic.syncthingandroid.util.Preference; import com.nutomic.syncthingandroid.util.Util; import java.io.File; @@ -585,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, Preference.getUseRoot(this)); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, AppPrefs.getUseRoot(this)); // Start the syncthing binary in a separate thread. Thread.UncaughtExceptionHandler syncthingRunnableThreadExceptionHandler = new Thread.UncaughtExceptionHandler() { @@ -713,7 +712,7 @@ private void shutdown(State newState) { } if (mSyncthingRunnable != null) { - Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, Preference.getUseRoot(this)); + Util.killProcess(Constants.FILENAME_SYNCTHING_BINARY, AppPrefs.getUseRoot(this)); if (mSyncthingRunnableThread != null) { LogV("Waiting for mSyncthingRunnableThread to finish after killProcess(Syncthing) ..."); try { diff --git a/app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java b/app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java deleted file mode 100644 index 5580fba9a..000000000 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Preference.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.nutomic.syncthingandroid.util; - -import android.content.Context; -import android.content.SharedPreferences; -import androidx.preference.PreferenceManager; - -import com.nutomic.syncthingandroid.service.Constants; - -public class Preference { - - public static boolean getStartServiceOnBoot(Context context) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - return sp.getBoolean(Constants.PREF_START_SERVICE_ON_BOOT, false); - } - - public static boolean getUseRoot(Context context) { - SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - return sp.getBoolean(Constants.PREF_USE_ROOT, false); - } -} From fe3a6f6f1754f2f81987751fecfb4c5b86d64bdc Mon Sep 17 00:00:00 2001 From: researchxxl <244199422+researchxxl@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:26:20 +0200 Subject: [PATCH 16/17] simplify util --- .../main/java/com/nutomic/syncthingandroid/util/Util.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 067bf0343..befc939b1 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -12,11 +12,11 @@ import android.util.Log; import androidx.appcompat.app.AppCompatActivity; -import androidx.preference.PreferenceManager; 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; @@ -139,9 +139,7 @@ public static boolean fixAppDataPermissions(Context context) { public static boolean nativeBinaryCanWriteToPath(Context context, String absoluteFolderPath) { final String TOUCH_FILE_NAME = ".stwritetest"; Boolean useRoot = false; - Boolean prefUseRoot = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(Constants.PREF_USE_ROOT, false); - if (prefUseRoot && RootAccess.isRootAvailableBlocking()) { + if (AppPrefs.getUseRoot(context) && RootAccess.isRootAvailableBlocking()) { useRoot = true; } From d5f9170419c5acaee1b6f604fd1f1cccedea9f23 Mon Sep 17 00:00:00 2001 From: Dhruv Bhavsar <40565844+dbhavsar76@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:58:55 +0530 Subject: [PATCH 17/17] fix(root): escape values written into the root shell (#357) --- .../service/SyncthingRunnable.java | 17 +++++- .../nutomic/syncthingandroid/util/Util.java | 57 +++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) 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 4a1de8c62..1e53cd131 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/service/SyncthingRunnable.java @@ -448,15 +448,28 @@ private Process setupAndLaunch(HashMap env) throws IOException, // 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()) { - suOut.writeBytes(String.format("export %s=\"%s\"\n", entry.getKey(), entry.getValue())); + 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. - suOut.writeBytes("exec " + TextUtils.join(" ", mCommand) + "\n"); + 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 { 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 befc939b1..e47dfca0e 100644 --- a/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java +++ b/app/src/main/java/com/nutomic/syncthingandroid/util/Util.java @@ -115,12 +115,13 @@ public static boolean fixAppDataPermissions(Context context) { // 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 + "; "; + 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 " + dir + "\n"; + cmd += "restorecon -R " + quotedDir + "\n"; Log.d(TAG, "Running: '" + cmd); int exitCode = runShellCommand(cmd, true); if (exitCode == 0) { @@ -144,8 +145,11 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut } // 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", useRoot); + String quotedTouchFile = shellQuote(touchFile); + int exitCode = runShellCommand("echo \"\" > " + quotedTouchFile + "\n", useRoot); if (exitCode != 0) { String error; switch (exitCode) { @@ -164,13 +168,58 @@ public static boolean nativeBinaryCanWriteToPath(Context context, String absolut Log.i(TAG, "Successfully wrote test file '" + touchFile + "'"); // Remove test file. - if (runShellCommand("rm \"" + touchFile + "\"\n", useRoot) != 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.