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")) + } +}