From 148de9b3c6183494e5b928f2d7ccfa111a66da66 Mon Sep 17 00:00:00 2001 From: Andrew Gunnerson Date: Fri, 25 Sep 2026 15:01:08 -0400 Subject: [PATCH] Allow overriding proxy settings We already supported using Android's proxy settings, but Android only allows specifying HTTP proxies, not SOCKS5 proxies. This commit adds a new setting for overriding the proxy URL. This requires more go:linkname shenanigans due to cached environment variables, but such is life. Fixes: #258 Signed-off-by: Andrew Gunnerson --- .../com/chiller3/basicsync/Preferences.kt | 5 ++ .../basicsync/settings/ProxyOverrideDialog.kt | 88 +++++++++++++++++++ .../basicsync/settings/SettingsScreen.kt | 36 +++++++- .../basicsync/syncthing/SyncthingService.kt | 23 +++-- app/src/main/res/values/strings.xml | 6 ++ external/syncthing | 2 +- stbridge/stbridge.go | 37 ++++++-- 7 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/chiller3/basicsync/settings/ProxyOverrideDialog.kt 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 {