diff --git a/app/src/main/java/com/chiller3/basicsync/Preferences.kt b/app/src/main/java/com/chiller3/basicsync/Preferences.kt
index f50044e..ee59738 100644
--- a/app/src/main/java/com/chiller3/basicsync/Preferences.kt
+++ b/app/src/main/java/com/chiller3/basicsync/Preferences.kt
@@ -25,6 +25,7 @@ class Preferences(context: Context) {
const val PREF_REMOTE_CONTROL = "remote_control"
const val PREF_ALLOW_AUTO_MODE = "allow_auto_mode"
const val PREF_START_ON_BOOT = "start_on_boot"
+ const val PREF_PROXY_OVERRIDE = "proxy_override"
const val PREF_REQUIRE_UNMETERED_NETWORK = "require_unmetered_network"
const val PREF_NETWORK_ALLOW_WIFI = "network_allow_wifi"
const val PREF_NETWORK_ALLOW_CELLULAR = "network_allow_cellular"
@@ -97,6 +98,10 @@ class Preferences(context: Context) {
get() = prefs.getBoolean(PREF_START_ON_BOOT, true)
set(enabled) = prefs.edit { putBoolean(PREF_START_ON_BOOT, enabled) }
+ var proxyOverride: String?
+ get() = prefs.getString(PREF_PROXY_OVERRIDE, null)
+ set(url) = prefs.edit { putString(PREF_PROXY_OVERRIDE, url?.ifEmpty { null }) }
+
var requireUnmeteredNetwork: Boolean
get() = prefs.getBoolean(PREF_REQUIRE_UNMETERED_NETWORK, true)
set(enabled) = prefs.edit { putBoolean(PREF_REQUIRE_UNMETERED_NETWORK, enabled) }
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/ProxyOverrideDialog.kt b/app/src/main/java/com/chiller3/basicsync/settings/ProxyOverrideDialog.kt
new file mode 100644
index 0000000..8f78410
--- /dev/null
+++ b/app/src/main/java/com/chiller3/basicsync/settings/ProxyOverrideDialog.kt
@@ -0,0 +1,88 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Andrew Gunnerson
+ * SPDX-License-Identifier: GPL-3.0-only
+ */
+
+package com.chiller3.basicsync.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.net.toUri
+import com.chiller3.basicsync.R
+
+@Composable
+fun ProxyOverrideDialog(
+ initialUrl: String?,
+ onSelect: (String?) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val input = rememberTextFieldState(initialText = initialUrl ?: "")
+ val url = tryParseInput(input.text.toString())
+
+ AlertDialog(
+ title = { Text(text = stringResource(R.string.pref_proxy_override_name)) },
+ text = {
+ Column(modifier = Modifier.verticalScroll(state = rememberScrollState())) {
+ Text(text = stringResource(R.string.pref_proxy_override_desc))
+
+ OutlinedTextField(
+ state = input,
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
+ label = { Text(text = stringResource(R.string.dialog_proxy_override_hint)) },
+ placeholder = { Text(text = "{http,https,socks5}://1.2.3.4:5678") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
+ lineLimits = TextFieldLineLimits.SingleLine,
+ )
+ }
+ },
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = { onSelect(url!!.ifEmpty { null }) },
+ enabled = url != null,
+ ) {
+ Text(text = stringResource(android.R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(android.R.string.cancel))
+ }
+ },
+ properties = DialogProperties(
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false,
+ ),
+ )
+}
+
+private fun tryParseInput(input: String): String? {
+ if (input.isEmpty()) {
+ return input
+ }
+
+ val uri = input.toUri()
+
+ if ((uri.scheme == "http" || uri.scheme == "https" || uri.scheme == "socks5")
+ && !uri.authority.isNullOrEmpty()) {
+ return input
+ }
+
+ return null
+}
diff --git a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt
index d3f7695..26b81d1 100644
--- a/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/chiller3/basicsync/settings/SettingsScreen.kt
@@ -113,6 +113,7 @@ fun SettingsScreen(
val remoteControl = remember(reloadPrefs) { prefs.remoteControl }
val allowAutoMode = remember(reloadPrefs) { prefs.allowAutoMode }
val startOnBoot = remember(reloadPrefs) { prefs.startOnBoot }
+ val proxyOverride = remember(reloadPrefs) { prefs.proxyOverride }
val isDebugMode = remember(reloadPrefs) { prefs.isDebugMode }
var reloadPerms by remember { mutableIntStateOf(0) }
@@ -336,6 +337,7 @@ fun SettingsScreen(
remoteControl = remoteControl,
allowAutoMode = allowAutoMode,
startOnBoot = startOnBoot,
+ proxyOverride = proxyOverride,
isDebugMode = isDebugMode,
onInhibitBatteryOptGrant = {
requestInhibitBatteryOpt.launch(Permissions.getInhibitBatteryOptIntent(context))
@@ -470,6 +472,10 @@ fun SettingsScreen(
prefs.startOnBoot = enabled
reloadPrefs++
},
+ onProxyOverrideChange = { url ->
+ prefs.proxyOverride = url
+ reloadPrefs++
+ },
onDebugModeChange = { enabled ->
prefs.isDebugMode = enabled
reloadPrefs++
@@ -577,6 +583,7 @@ private fun SettingsContent(
remoteControl: Boolean,
allowAutoMode: Boolean,
startOnBoot: Boolean,
+ proxyOverride: String?,
isDebugMode: Boolean,
onInhibitBatteryOptGrant: () -> Unit,
onNotificationsGrant: () -> Unit,
@@ -602,6 +609,7 @@ private fun SettingsContent(
onRemoteControlChange: (Boolean) -> Unit,
onAllowAutoModeChange: (Boolean) -> Unit,
onStartOnBootChange: (Boolean) -> Unit,
+ onProxyOverrideChange: (String?) -> Unit,
onDebugModeChange: (Boolean) -> Unit,
onSourceRepoOpen: () -> Unit,
onSaveLogs: () -> Unit,
@@ -668,6 +676,7 @@ private fun SettingsContent(
val runState = serviceState?.runState
var showMinBatteryLevelDialog by rememberSaveable { mutableStateOf(false) }
+ var showProxyOverrideDialog by rememberSaveable { mutableStateOf(false) }
PreferenceColumn(contentPadding = contentPadding) {
if (missingPermissions.isNotEmpty()) {
@@ -914,13 +923,23 @@ private fun SettingsContent(
SwitchPreference(
checked = startOnBoot,
onCheckedChange = onStartOnBootChange,
- shapes = BetterSegmentedShapes.bottom(),
+ shapes = BetterSegmentedShapes.middle(),
title = { Text(text = stringResource(R.string.pref_start_on_boot_name)) },
summary = { Text(text = stringResource(R.string.pref_start_on_boot_desc)) },
modifier = Modifier.animateItem(),
)
}
+ item(key = "proxy_override") {
+ Preference(
+ onClick = { showProxyOverrideDialog = true },
+ shapes = BetterSegmentedShapes.bottom(),
+ title = { Text(text = stringResource(R.string.pref_proxy_override_name)) },
+ summary = { Text(text = stringResource(R.string.pref_proxy_override_desc)) },
+ modifier = Modifier.animateItem(),
+ )
+ }
+
item(key = "about") {
PreferenceCategory(
title = { Text(text = stringResource(R.string.pref_header_about)) },
@@ -971,6 +990,19 @@ private fun SettingsContent(
},
)
}
+
+ if (showProxyOverrideDialog) {
+ ProxyOverrideDialog(
+ initialUrl = proxyOverride,
+ onSelect = { url ->
+ onProxyOverrideChange(url)
+ showProxyOverrideDialog = false
+ },
+ onDismiss = {
+ showProxyOverrideDialog = false
+ },
+ )
+ }
}
@Composable
@@ -1074,6 +1106,7 @@ private fun PreviewSettingsScreen() {
remoteControl = false,
allowAutoMode = true,
startOnBoot = true,
+ proxyOverride = null,
isDebugMode = true,
onInhibitBatteryOptGrant = {},
onNotificationsGrant = {},
@@ -1099,6 +1132,7 @@ private fun PreviewSettingsScreen() {
onRemoteControlChange = {},
onAllowAutoModeChange = {},
onStartOnBootChange = {},
+ onProxyOverrideChange = {},
onDebugModeChange = {},
onSourceRepoOpen = {},
onSaveLogs = {},
diff --git a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
index 52ca009..d02c478 100644
--- a/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
+++ b/app/src/main/java/com/chiller3/basicsync/syncthing/SyncthingService.kt
@@ -440,8 +440,11 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
private lateinit var deviceStateTracker: DeviceStateTracker
@GuardedBy("stateLock")
private var deviceState = DeviceState()
+
+ @GuardedBy("stateLock")
+ private var desiredProxyInfo = ProxyInfo(proxy = "", noProxy = "")
@GuardedBy("stateLock")
- private var runningProxyInfo: ProxyInfo? = null
+ private var runningProxyInfo = desiredProxyInfo
@GuardedBy("stateLock")
private var blockedReasons = EnumSet.noneOf(BlockedReason::class.java)
@@ -632,6 +635,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
Log.d(TAG, "Preference $key changed")
var recomputeBlockedReasons = false
+ var recomputeProxyInfo = false
// We have to switch foreground service and network callback types when location becomes
// needed or no longer needed.
@@ -640,11 +644,13 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
when (key) {
in BLOCKED_REASONS_PREFS, in DeviceState.PREFS -> recomputeBlockedReasons = true
in STATE_CHANGE_PREFS -> {}
+ Preferences.PREF_PROXY_OVERRIDE -> recomputeProxyInfo = true
else -> return
}
stateChanged(
recomputeBlockedReasons = recomputeBlockedReasons,
+ recomputeProxyInfo = recomputeProxyInfo,
forceShowNotification = forceShowNotification,
)
}
@@ -652,7 +658,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
override fun onDeviceStateChanged(state: DeviceState) {
synchronized(stateLock) {
deviceState = state
- stateChanged(recomputeBlockedReasons = true)
+ stateChanged(recomputeBlockedReasons = true, recomputeProxyInfo = true)
}
}
@@ -678,6 +684,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
private fun stateChanged(
recomputeBlockedReasons: Boolean = false,
+ recomputeProxyInfo: Boolean = false,
forceShowNotification: Boolean = false,
) {
synchronized(stateLock) {
@@ -702,6 +709,12 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
}
}
+ if (recomputeProxyInfo) {
+ desiredProxyInfo = prefs.proxyOverride
+ ?.let { ProxyInfo(proxy = it, noProxy = "") }
+ ?: deviceState.proxyInfo
+ }
+
handleStateChangeLocked()
if (!shouldThreadRun) {
@@ -773,7 +786,7 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
// The service needs to be restarted for proxy changes to take effect. The hack we do to set
// the proxy on the golang side can't be made thread-safe.
val needFullRestart = !shouldThreadRun
- || runningProxyInfo != deviceState.proxyInfo
+ || runningProxyInfo != desiredProxyInfo
|| preRunActions.isNotEmpty()
|| recheckPermissions
@@ -811,8 +824,8 @@ class SyncthingService : Service(), SyncthingStatusReceiver, DeviceStateListener
actions.addAll(preRunActions)
preRunActions.clear()
- runningProxyInfo = deviceState.proxyInfo
- proxyInfo = deviceState.proxyInfo
+ runningProxyInfo = desiredProxyInfo
+ proxyInfo = desiredProxyInfo
// recheckPermissions is cleared in onCheckStoragePermissions().
recheckOnly = !shouldStart
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 02c55ff..38147c2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -110,6 +110,10 @@
Start on boot
Automatically start the app after a reboot.
+
+ Override proxy settings
+
+ Use a custom proxy instead of the proxy specified in Android\'s network settings.
Version
@@ -248,6 +252,8 @@
Enter the name (SSID) of the Wi-Fi network. This also works for hidden Wi-Fi networks.
SSID
+
+ Proxy URL
Select folder
diff --git a/external/syncthing b/external/syncthing
index 14a0bfc..f334599 160000
--- a/external/syncthing
+++ b/external/syncthing
@@ -1 +1 @@
-Subproject commit 14a0bfc1daa33dcea493afb58cedc6647cadf93c
+Subproject commit f33459940fff3a3d1b355426974755e775de86e5
diff --git a/stbridge/stbridge.go b/stbridge/stbridge.go
index c85f07d..5961d23 100644
--- a/stbridge/stbridge.go
+++ b/stbridge/stbridge.go
@@ -29,11 +29,13 @@ import (
_ "unsafe"
_ "golang.org/x/mobile/event/key"
+ _ "golang.org/x/net/proxy"
"golang.org/x/sys/unix"
_ "github.com/syncthing/syncthing/cmd/syncthing/cli"
"github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/config"
+ "github.com/syncthing/syncthing/lib/dialer"
"github.com/syncthing/syncthing/lib/events"
"github.com/syncthing/syncthing/lib/fs"
"github.com/syncthing/syncthing/lib/locations"
@@ -136,34 +138,57 @@ func readPemCert(path string) (*x509.Certificate, error) {
return cert, nil
}
+type netProxyEnvOnce struct {
+ names []string
+ once sync.Once
+ val string
+}
+
//go:linkname resetProxyConfig net/http.resetProxyConfig
func resetProxyConfig()
+//go:linkname netProxyAllProxyEnv golang.org/x/net/proxy.allProxyEnv
+var netProxyAllProxyEnv *netProxyEnvOnce
+
+//go:linkname netProxyNoProxyEnv golang.org/x/net/proxy.noProxyEnv
+var netProxyNoProxyEnv *netProxyEnvOnce
+
+//go:linkname netProxyEnvOnceReset golang.org/x/net/proxy.(*envOnce).reset
+func netProxyEnvOnceReset(*netProxyEnvOnce)
+
// This is not thread-safe and should only be called by run().
func applyProxySettings(proxy string, no_proxy string) {
if len(proxy) > 0 {
log.Printf("Setting proxy to %q", proxy)
- os.Setenv("http_proxy", proxy)
- os.Setenv("https_proxy", proxy)
+ os.Setenv("ALL_PROXY", proxy)
+ os.Setenv("ALL_PROXY_NO_FALLBACK", "1")
} else {
log.Print("Clearing proxy settings")
- os.Unsetenv("http_proxy")
- os.Unsetenv("https_proxy")
+ os.Unsetenv("ALL_PROXY")
+ os.Unsetenv("ALL_PROXY_NO_FALLBACK")
}
if len(no_proxy) > 0 {
log.Printf("Setting no_proxy to %q", no_proxy)
- os.Setenv("no_proxy", no_proxy)
+ os.Setenv("NO_PROXY", no_proxy)
} else {
log.Print("Clearing no_proxy settings")
- os.Unsetenv("no_proxy")
+ os.Unsetenv("NO_PROXY")
}
+ // For NO_PROXY in the standard library.
resetProxyConfig()
+
+ // For ALL_PROXY and NO_PROXY in golang.org/x/net/proxy.
+ netProxyEnvOnceReset(netProxyAllProxyEnv)
+ netProxyEnvOnceReset(netProxyNoProxyEnv)
+
+ // For ALL_PROXY_NO_FALLBACK and http.DefaultTransport in Syncthing.
+ dialer.ResetProxyConfig()
}
func InitDirs(filesDir string, cacheDir string, externalDir string) error {