Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
802ae36
feat(root): refactor root access
researchxxl Jan 10, 2026
5d03d39
readd imports dropped in e778efb4d6e0fc30f4ff8fabeb8c0567537a66c7
researchxxl Jan 10, 2026
7ab68db
Merge branch 'main' into refactorRoot
researchxxl Jan 20, 2026
68de8d3
Merge branch 'main' into refactorRoot
researchxxl Jan 31, 2026
cef2e2f
Merge branch 'main' into refactorRoot
researchxxl Feb 4, 2026
43d3200
Merge branch 'main' into refactorRoot
researchxxl Feb 6, 2026
77f6283
Merge branch 'main' into refactorRoot
researchxxl Feb 8, 2026
f5697d0
Merge branch 'main' into refactorRoot
researchxxl Feb 9, 2026
0b804f8
Merge branch 'main' into refactorRoot
researchxxl Feb 12, 2026
5bafb68
Merge branch 'main' into refactorRoot
researchxxl Feb 14, 2026
cdbfc52
Merge branch 'main' into refactorRoot
researchxxl Feb 14, 2026
5694aff
Merge branch 'main' into refactorRoot
researchxxl Feb 23, 2026
6b8d6e4
Resolve conflict with main, add use root preference to new settings s…
dbhavsar76 Feb 23, 2026
bbdbafc
Merge branch 'main' into refactorRoot
researchxxl Feb 23, 2026
a787407
Merge branch 'main' into refactorRoot
researchxxl Feb 23, 2026
59d7dd4
restart syncthing after enabling/disabling root (#173)
dbhavsar76 Feb 26, 2026
bfc6b85
Merge branch 'main' into refactorRoot
researchxxl Feb 26, 2026
d8014fd
Merge branch 'main' into refactorRoot
researchxxl Mar 6, 2026
6cfd409
Merge branch 'main' into refactorRoot
researchxxl Mar 6, 2026
c8a3d7a
Merge branch 'main' into refactorRoot
researchxxl Mar 8, 2026
5583f1c
Merge branch 'main' into refactorRoot
researchxxl Mar 10, 2026
7e80aac
Merge branch 'main' into refactorRoot
researchxxl Mar 11, 2026
e4e8dab
Merge branch 'main' into refactorRoot
researchxxl Mar 17, 2026
69f216c
Merge branch 'main' into refactorRoot
researchxxl Mar 17, 2026
8e86422
Merge remote-tracking branch 'origin/main' into refactorRoot
dbhavsar76 Apr 4, 2026
b9cad51
Merge branch 'main' into refactorRoot
researchxxl Apr 17, 2026
a32e4c6
Merge branch 'main' into refactorRoot
researchxxl Apr 27, 2026
60c557f
abstract all the libsuperuser calls to new RootAccess adapter
dbhavsar76 May 5, 2026
e715ce5
resolve conflicts
dbhavsar76 May 5, 2026
79eddd4
Merge branch 'main' into refactorRoot
researchxxl May 18, 2026
108ff5c
java.io.DataOutputStream
researchxxl May 18, 2026
37cd13e
Merge branch 'main' into refactorRoot
researchxxl May 18, 2026
a2a0005
Merge branch 'main' into refactorRoot
researchxxl May 20, 2026
36f61df
Merge branch 'main' into refactorRoot
researchxxl May 26, 2026
025b206
Merge branch 'main' into refactorRoot
researchxxl Jun 8, 2026
d2e9cba
Merge branch 'main' into refactorRoot
researchxxl Jun 12, 2026
40d41fa
Merge branch 'main' into refactorRoot
researchxxl Jun 12, 2026
c7b2a9a
Update SettingsBehaviorScreen.kt
researchxxl Jun 12, 2026
97cdb0b
Merge branch 'main' into refactorRoot
researchxxl Jun 12, 2026
1be472f
Merge branch 'main' into refactorRoot
researchxxl Jun 17, 2026
de323ec
Merge branch 'main' into refactorRoot
researchxxl Jun 17, 2026
4b6dc20
Merge branch 'main' into refactorRoot
researchxxl Jun 29, 2026
4c65155
Merge branch 'main' into refactorRoot
researchxxl Jul 1, 2026
48ddedc
Merge branch 'main' into refactorRoot
researchxxl Jul 8, 2026
95dc859
Merge branch 'main' into refactorRoot
researchxxl Jul 22, 2026
fb0b138
Merge branch 'main' into refactorRoot
researchxxl Jul 29, 2026
ee19dac
Merge branch 'main' into refactorRoot
researchxxl Aug 4, 2026
be5253e
fix bootreceiver
researchxxl Aug 4, 2026
db3f0b9
util: add useroot to new funcs
researchxxl Aug 4, 2026
08a7d37
fix restapi
researchxxl Aug 4, 2026
40788a7
add preference.java
researchxxl Aug 4, 2026
8ec299e
fix syncthingservice
researchxxl Aug 4, 2026
18295cb
fix conflict detector not working in root mode
researchxxl Aug 4, 2026
5259afc
Apply suggestions from code review
researchxxl Aug 4, 2026
523228c
preference into appprefs
researchxxl Aug 4, 2026
fe3a6f6
simplify util
researchxxl Aug 4, 2026
2089e8f
Merge branch 'main' into refactorRoot
researchxxl Aug 4, 2026
71e424d
Merge branch 'main' into refactorRoot
researchxxl Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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;
Expand Down Expand Up @@ -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"};
Expand Down Expand Up @@ -270,7 +274,7 @@ private void putCustomEnvironmentVariables(Map<String, String> environment, Shar
*/
private List<String> getSyncthingPIDs(Boolean enableLog) {
List<String> syncthingPIDs = new ArrayList<String>();
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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -458,9 +462,30 @@ private Process setupAndLaunch(HashMap<String, String> 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<String, String> 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();
Comment thread
researchxxl marked this conversation as resolved.
return process;
} else {
ProcessBuilder pb = new ProcessBuilder(mCommand);
pb.environment().putAll(env);
return pb.start();
}
}

public class ExecutableNotFoundException extends Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -19,10 +26,34 @@ fun EntryProviderScope<SettingsRoute>.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)
Comment thread
researchxxl marked this conversation as resolved.
useRoot.value = false
}
}
}

SettingsScaffold(
title = stringResource(R.string.category_behaviour),
Expand All @@ -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)
},
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Comment thread
researchxxl marked this conversation as resolved.
Expand Down Expand Up @@ -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;
}
Expand Down
79 changes: 74 additions & 5 deletions app/src/main/java/com/nutomic/syncthingandroid/util/Util.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +34,8 @@
import java.time.ZoneOffset;
import java.util.Locale;

import eu.chainfire.libsuperuser.Shell;

public class Util {

private static final String TAG = "Util";
Expand Down Expand Up @@ -66,16 +71,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) {
Expand All @@ -94,7 +159,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");
}
Expand All @@ -104,14 +169,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);
Expand Down Expand Up @@ -152,13 +217,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);
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,9 @@
<!-- Shown if no folders are configured -->
<string name="folders_list_empty">No folders configured. Tap to add a new folder.</string>

<!-- Toast show if we could not get root permissions -->
<string name="toast_root_denied">Did not get root permissions</string>
Comment thread
researchxxl marked this conversation as resolved.

<!-- RecentChangesActivity -->

<string name="no_recent_changes">There are no recent changes.</string>
Expand Down Expand Up @@ -527,6 +530,8 @@
<string name="behaviour_autostart_title">Autostart</string>
<string name="behaviour_autostart_summary">Start app automatically on operating system startup.</string>

<string name="use_root_title">Run Syncthing as Superuser</string>
<string name="use_root_summary">Running Syncthing as root allows it to write to folders Android normally restricts to be readonly accessed. Use this feature with caution.</string>
Comment thread
researchxxl marked this conversation as resolved.
Outdated

<string name="allow_overwrite_files_title">Allow overwrite of existing files</string>
<string name="allow_overwrite_files_summary">When another app shares files to one of our folders.</string>
Expand Down
Loading