diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index f81680e0f..5e5bf5c9a 100644
--- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -170,6 +170,7 @@ class FrontEndActivity : ComponentActivity() {
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
+ private var downloadProxy by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
@@ -768,6 +769,7 @@ class FrontEndActivity : ComponentActivity() {
downloadSource = DownloadSource.mode(this)
downloadMirror = DownloadSource.mirror(this)
downloadPrefix = DownloadSource.custom(this)
+ downloadProxy = DownloadSource.proxyHostPort(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
LauncherIcon.repair(this, intent?.component)
@@ -833,6 +835,7 @@ class FrontEndActivity : ComponentActivity() {
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
+ downloadProxy = downloadProxy,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
@@ -1033,6 +1036,10 @@ class FrontEndActivity : ComponentActivity() {
DownloadSource.setCustom(this, prefix)
downloadPrefix = DownloadSource.custom(this)
},
+ onDownloadProxy = { value ->
+ DownloadSource.setProxy(this, value)
+ downloadProxy = DownloadSource.proxyHostPort(this)
+ },
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
index 870e8ed42..b11e3113a 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DownloadSource.kt
@@ -3,6 +3,8 @@ package com.droiddeck.launcher.core
import android.content.Context
import android.content.SharedPreferences
import java.net.HttpURLConnection
+import java.net.InetSocketAddress
+import java.net.Proxy
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
@@ -20,6 +22,7 @@ object DownloadSource {
private const val KEY_MODE = "mode"
private const val KEY_MIRROR = "mirror"
private const val KEY_CUSTOM = "custom"
+ private const val KEY_PROXY = "proxy"
private const val KEY_FASTEST = "fastest"
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
@@ -175,6 +178,22 @@ object DownloadSource {
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
}
+ fun proxyHostPort(context: Context): String = prefs(context)?.getString(KEY_PROXY, "") ?: ""
+
+ fun setProxy(context: Context, value: String) {
+ prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply()
+ }
+
+ /** An HTTP proxy every Downloader connection uses, or null when none is configured. */
+ fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "")
+
+ internal fun proxyOf(value: String): Proxy? {
+ val host = value.substringBefore(':')
+ val port = value.substringAfter(':', "").toIntOrNull() ?: return null
+ if (host.isEmpty()) return null
+ return Proxy(Proxy.Type.HTTP, InetSocketAddress(host, port))
+ }
+
fun reset(context: Context) {
lastGood = null
prefs(context)?.edit()?.clear()?.apply()
@@ -205,4 +224,11 @@ object DownloadSource {
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
}
+
+ internal fun normalizeProxy(value: String): String {
+ val trimmed = value.trim().removePrefix("http://").trimEnd('/')
+ val host = trimmed.substringBefore(':')
+ val port = trimmed.substringAfter(':', "").toIntOrNull() ?: 0
+ return if (host.isNotEmpty() && port in 1..65535) "$host:$port" else ""
+ }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
index 183076188..98e41ab6f 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
+++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java
@@ -11,6 +11,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
+import java.net.Proxy;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -277,7 +278,10 @@ public final class Downloader {
}
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
- HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ // An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
+ Proxy proxy = DownloadSource.INSTANCE.httpProxy();
+ HttpURLConnection connection = (HttpURLConnection) (proxy != null
+ ? new URL(url).openConnection(proxy) : new URL(url).openConnection());
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(readTimeoutMs);
connection.setInstanceFollowRedirects(true);
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
index 8b4f14e5a..33317e15b 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/GuestCommand.kt
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.runtime
import android.content.Context
import android.os.Environment
+import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.HostProcess
import com.droiddeck.launcher.session.OrphanReaper
import com.droiddeck.launcher.session.SessionFiles
@@ -45,6 +46,15 @@ object GuestCommand {
"DROIDDECK_BWRAP_SOCKET=${BwrapSpawner.socket(context)}",
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
)
+ // The Steam client installer and the package tools inside the guest fetch over plain curl,
+ // so the app proxy has to reach them through the environment proot starts them with.
+ DownloadSource.proxyHostPort(context).takeIf { it.isNotEmpty() }?.let { hostPort ->
+ cmd += listOf(
+ "http_proxy=http://$hostPort", "https_proxy=http://$hostPort",
+ "HTTP_PROXY=http://$hostPort", "HTTPS_PROXY=http://$hostPort",
+ "no_proxy=localhost,127.0.0.1,::1", "NO_PROXY=localhost,127.0.0.1,::1",
+ )
+ }
fastPathKey?.let { cmd += ProotFastPath.guestEnv(root, binds, it) }
cmd += argv
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index bde8b54f7..41e18b486 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -137,6 +137,7 @@ class FrontEndState(
val downloadSource: String = DownloadSource.MODE_AUTO,
val downloadMirror: String = "",
val downloadPrefix: String = "",
+ val downloadProxy: String = "",
val animationsEnabled: Boolean = true,
val launcherIcon: String = com.droiddeck.launcher.LauncherIcon.DARK,
val gameStoresEnabled: Boolean = true,
@@ -214,6 +215,7 @@ class FrontEndActions(
val onDownloadSource: (String) -> Unit = {},
val onDownloadMirror: (String) -> Unit = {},
val onDownloadPrefix: (String) -> Unit = {},
+ val onDownloadProxy: (String) -> Unit = {},
val onGameStoresEnabled: (Boolean) -> Unit = {},
val onGameStoresSpeedTier: (String) -> Unit = {},
val onNavigated: () -> Unit = {},
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index 8f92dbbc6..f96c0eb2d 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -174,6 +174,7 @@ internal fun SetupPanel(
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
var editSgdbKey by remember { mutableStateOf(false) }
var editDownloadPrefix by remember { mutableStateOf(false) }
+ var editDownloadProxy by remember { mutableStateOf(false) }
if (editSgdbKey) SgdbKeyDialog(s.sgdbUserKey, onSave = { a.onSgdbKey(it); editSgdbKey = false }, onDismiss = { editSgdbKey = false })
if (editDownloadPrefix) TextEntryDialog(
@@ -181,6 +182,11 @@ internal fun SetupPanel(
onDismiss = { editDownloadPrefix = false },
onSave = { a.onDownloadPrefix(it); editDownloadPrefix = false },
)
+ if (editDownloadProxy) TextEntryDialog(
+ stringResource(R.string.setup_download_proxy), s.downloadProxy,
+ onDismiss = { editDownloadProxy = false },
+ onSave = { a.onDownloadProxy(it); editDownloadProxy = false },
+ )
val tabs = listOf(stringResource(R.string.setup_tab_overview), stringResource(R.string.setup_tab_controller), stringResource(R.string.setup_tab_launcher), stringResource(R.string.setup_tab_storage), stringResource(R.string.setup_tab_diagnostics), stringResource(R.string.setup_tab_glasses))
var tab by rememberSaveable { mutableStateOf(if (SetupTabs.openLauncher) LAUNCHER_TAB else 0) }
SetupTabs.openLauncher = false
@@ -439,6 +445,12 @@ internal fun SetupPanel(
onClick = { editDownloadPrefix = true },
)
}
+ ActionRow(
+ stringResource(R.string.setup_download_proxy),
+ s.downloadProxy.takeIf { it.isNotEmpty() },
+ stringResource(R.string.mode_sgdb_edit),
+ onClick = { editDownloadProxy = true },
+ )
}
}
if (wide) Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.Top) {
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index a2ca7e857..3004e5b17 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -838,6 +838,7 @@
自定义前缀
镜像站点
前缀
+ HTTP 代理
文件
浏览和管理文件
Proton 版本
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0889e5d8b..11dc9fd56 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2420,6 +2420,7 @@
Custom prefix
Site
Prefix
+ HTTP proxy
LINUX APPS
Flathub
Desktop apps for the Linux desktop, opened from Desktop.
diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt
new file mode 100644
index 000000000..46725fc7f
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/core/DownloadSourceProxyTest.kt
@@ -0,0 +1,36 @@
+package com.droiddeck.launcher.core
+
+import java.net.InetSocketAddress
+import java.net.Proxy
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class DownloadSourceProxyTest {
+ @Test fun hostAndPortBecomeAnHttpProxy() {
+ val proxy = DownloadSource.proxyOf("127.0.0.1:9910")
+ assertEquals(Proxy.Type.HTTP, proxy?.type())
+ val address = proxy?.address() as InetSocketAddress
+ assertEquals("127.0.0.1", address.hostString)
+ assertEquals(9910, address.port)
+ }
+
+ @Test fun missingOrInvalidPortIsIgnored() {
+ assertNull(DownloadSource.proxyOf(""))
+ assertNull(DownloadSource.proxyOf("127.0.0.1"))
+ assertNull(DownloadSource.proxyOf("127.0.0.1:"))
+ assertNull(DownloadSource.proxyOf("127.0.0.1:notaport"))
+ }
+
+ @Test fun normalizationAcceptsSchemeAndTrailingSlash() {
+ assertEquals("127.0.0.1:9910", DownloadSource.normalizeProxy(" http://127.0.0.1:9910/ "))
+ assertEquals("192.168.1.2:8080", DownloadSource.normalizeProxy("192.168.1.2:8080"))
+ }
+
+ @Test fun invalidInputDisablesTheProxy() {
+ assertEquals("", DownloadSource.normalizeProxy(""))
+ assertEquals("", DownloadSource.normalizeProxy("127.0.0.1"))
+ assertEquals("", DownloadSource.normalizeProxy("127.0.0.1:70000"))
+ assertEquals("", DownloadSource.normalizeProxy("http://127.0.0.1:notaport"))
+ }
+}