From 1f9584d7c2153e87b230f2cd16de7d6594887724 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sun, 26 Apr 2026 19:17:51 +0800 Subject: [PATCH 01/16] =?UTF-8?q?Add=20emulator=E2=86=94host=20SOCKS5/HTTP?= =?UTF-8?q?=20integration=20test=20rig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * HostSocks5ProxyIntegrationTest: instrumentation test that performs a SOCKS5 NO_AUTH handshake from inside the AVD against a host proxy and asserts a 2xx HTTP response. Defaults to 10.0.2.2:1080 (the AVD alias for the host loopback); all knobs overridable via -P testInstrumentationRunnerArguments. * scripts/socks5_test_server.py: stdlib SOCKS5 (NO_AUTH, CONNECT only). * scripts/http_connect_test_server.py: stdlib HTTP CONNECT proxy with optional --auth user:pass for testing the HTTP/HTTPS upstream paths. * README: how to run them. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 21 ++ .../HostSocks5ProxyIntegrationTest.kt | 134 ++++++++++++ scripts/http_connect_test_server.py | 159 +++++++++++++++ scripts/socks5_test_server.py | 191 ++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt create mode 100644 scripts/http_connect_test_server.py create mode 100755 scripts/socks5_test_server.py diff --git a/README.md b/README.md index 6e877e72..bd9e7ea9 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,27 @@ app/ └── build.gradle ``` +## INTEGRATION TEST (EMULATOR ↔ HOST SOCKS5) + +`HostSocks5ProxyIntegrationTest` runs inside an Android emulator and routes an +HTTP request through a SOCKS5 proxy listening on the host. The host proxy is a +small stdlib-only Python server in `scripts/socks5_test_server.py`. + +The emulator reaches the host loopback via the alias `10.0.2.2`, so a host +proxy bound to `0.0.0.0:1080` is seen by the device as `10.0.2.2:1080`. + +```bash +# 1. Start the SOCKS5 proxy on the host (terminal 1) +python3 scripts/socks5_test_server.py --host 0.0.0.0 --port 1080 + +# 2. Boot any AVD, then run the instrumentation test (terminal 2) +./gradlew connectedAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.class=org.proxydroid.HostSocks5ProxyIntegrationTest +``` + +Override the proxy / target with `-Pandroid.testInstrumentationRunnerArguments.socksHost=...`, +`socksPort`, `targetHost`, `targetPort`. + ## SUPPORTED ARCHITECTURES * armeabi-v7a diff --git a/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt b/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt new file mode 100644 index 00000000..89ba0bcb --- /dev/null +++ b/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt @@ -0,0 +1,134 @@ +package org.proxydroid + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Integration test: from inside the Android emulator, route an HTTP request through + * a SOCKS5 proxy listening on the host machine at 0.0.0.0:1080. + * + * The emulator reaches the host loopback via the special alias 10.0.2.2, so a host + * proxy bound to 0.0.0.0:1080 is reachable as 10.0.2.2:1080 from the device. + * + * Override at runtime with instrumentation args, e.g.: + * ./gradlew connectedAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.socksHost=10.0.2.2 \ + * -Pandroid.testInstrumentationRunnerArguments.socksPort=1080 \ + * -Pandroid.testInstrumentationRunnerArguments.targetHost=example.com \ + * -Pandroid.testInstrumentationRunnerArguments.targetPort=80 + */ +@RunWith(AndroidJUnit4::class) +class HostSocks5ProxyIntegrationTest { + + private val args = InstrumentationRegistry.getArguments() + private val socksHost: String = args.getString("socksHost", "10.0.2.2") + private val socksPort: Int = args.getString("socksPort", "1080").toInt() + private val targetHost: String = args.getString("targetHost", "example.com") + private val targetPort: Int = args.getString("targetPort", "80").toInt() + private val connectTimeoutMs: Int = args.getString("connectTimeoutMs", "10000").toInt() + private val readTimeoutMs: Int = args.getString("readTimeoutMs", "15000").toInt() + + @Test + fun httpGetThroughHostSocks5Proxy() { + Socket().use { socket -> + socket.connect(InetSocketAddress(socksHost, socksPort), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + socks5Greet(out, input) + socks5ConnectByDomain(out, input, targetHost, targetPort) + + val request = buildString { + append("GET / HTTP/1.1\r\n") + append("Host: ").append(targetHost).append("\r\n") + append("User-Agent: ProxyDroid-IntegrationTest/1.0\r\n") + append("Accept: */*\r\n") + append("Connection: close\r\n\r\n") + }.toByteArray(Charsets.US_ASCII) + out.write(request) + out.flush() + + val statusLine = readLine(input) + assertTrue( + "Expected HTTP/1.x status line, got: $statusLine", + statusLine.startsWith("HTTP/1.") + ) + val parts = statusLine.split(' ', limit = 3) + assertTrue("Malformed status line: $statusLine", parts.size >= 2) + val code = parts[1].toIntOrNull() ?: -1 + assertTrue( + "Expected 2xx/3xx through proxy, got: $statusLine", + code in 200..399 + ) + } + } + + private fun socks5Greet(out: DataOutputStream, input: DataInputStream) { + // VER=5, NMETHODS=1, METHOD=0 (NO AUTH) + out.write(byteArrayOf(0x05, 0x01, 0x00)) + out.flush() + val ver = input.readUnsignedByte() + val method = input.readUnsignedByte() + assertEquals("SOCKS version mismatch", 0x05, ver) + assertEquals( + "SOCKS proxy did not accept NO_AUTH (method=$method); test assumes unauthenticated proxy", + 0x00, + method + ) + } + + private fun socks5ConnectByDomain( + out: DataOutputStream, + input: DataInputStream, + host: String, + port: Int, + ) { + val hostBytes = host.toByteArray(Charsets.US_ASCII) + require(hostBytes.size <= 255) { "Hostname too long for SOCKS5: $host" } + // VER=5, CMD=1 CONNECT, RSV=0, ATYP=3 DOMAINNAME, LEN, HOST, PORT(BE) + out.write(byteArrayOf(0x05, 0x01, 0x00, 0x03, hostBytes.size.toByte())) + out.write(hostBytes) + out.writeShort(port) + out.flush() + + val ver = input.readUnsignedByte() + val rep = input.readUnsignedByte() + input.readUnsignedByte() // RSV + val atyp = input.readUnsignedByte() + assertEquals("SOCKS reply version mismatch", 0x05, ver) + assertEquals("SOCKS CONNECT failed with REP=$rep", 0x00, rep) + + // Drain BND.ADDR + BND.PORT so the stream sits at the start of payload. + when (atyp) { + 0x01 -> input.skipBytes(4) // IPv4 + 0x03 -> { + val len = input.readUnsignedByte() + input.skipBytes(len) + } + 0x04 -> input.skipBytes(16) // IPv6 + else -> throw AssertionError("Unknown SOCKS ATYP=$atyp") + } + input.skipBytes(2) // BND.PORT + } + + private fun readLine(input: DataInputStream): String { + val buf = StringBuilder() + while (true) { + val b = input.read() + if (b == -1) break + if (b == '\n'.code) break + if (b != '\r'.code) buf.append(b.toChar()) + } + return buf.toString() + } +} diff --git a/scripts/http_connect_test_server.py b/scripts/http_connect_test_server.py new file mode 100644 index 00000000..fcd56477 --- /dev/null +++ b/scripts/http_connect_test_server.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Minimal HTTP CONNECT proxy for testing ProxyDroid's HTTP / HTTPS upstream +modes. Stdlib only. + +Supports: + - CONNECT host:port HTTP/1.1 (used by HTTPS through HTTP proxy) + - Optional Proxy-Authorization: Basic (set via --auth user:pass) + +Usage: + python3 scripts/http_connect_test_server.py --port 8080 + python3 scripts/http_connect_test_server.py --port 8080 --auth alice:s3cret +""" + +from __future__ import annotations + +import argparse +import base64 +import logging +import select +import socket +import threading + +log = logging.getLogger("http-connect") + + +def recv_until_double_crlf(sock: socket.socket, max_bytes: int = 16384) -> bytes: + buf = bytearray() + while b"\r\n\r\n" not in buf and len(buf) < max_bytes: + chunk = sock.recv(4096) + if not chunk: + break + buf.extend(chunk) + return bytes(buf) + + +def relay(a: socket.socket, b: socket.socket) -> None: + socks = [a, b] + try: + while True: + ready, _, errored = select.select(socks, [], socks, 60) + if errored or not ready: + break + for s in ready: + peer = b if s is a else a + data = s.recv(8192) + if not data: + return + peer.sendall(data) + except OSError: + pass + + +def handle(client: socket.socket, addr, expect_auth: str | None) -> None: + log.info("client connected: %s:%s", *addr) + remote: socket.socket | None = None + try: + client.settimeout(15) + head = recv_until_double_crlf(client) + if not head: + return + + head_str = head.decode("iso-8859-1", errors="replace") + request_line, _, _ = head_str.partition("\r\n") + parts = request_line.split() + if len(parts) < 3 or parts[0].upper() != "CONNECT": + client.sendall(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") + log.warning("rejected non-CONNECT: %r", request_line) + return + + target = parts[1] + host, _, port_s = target.rpartition(":") + if not host: + host = target + port_s = "443" + try: + port = int(port_s) + except ValueError: + client.sendall(b"HTTP/1.1 400 Bad Request\r\n\r\n") + return + + if expect_auth is not None: + wanted = "Basic " + base64.b64encode(expect_auth.encode()).decode() + authd = False + for line in head_str.split("\r\n")[1:]: + if line.lower().startswith("proxy-authorization:"): + if line.split(":", 1)[1].strip() == wanted: + authd = True + break + if not authd: + client.sendall( + b"HTTP/1.1 407 Proxy Authentication Required\r\n" + b'Proxy-Authenticate: Basic realm="proxy"\r\n\r\n' + ) + log.warning("rejected unauthenticated CONNECT %s:%d", host, port) + return + + log.info("CONNECT %s:%d", host, port) + try: + remote = socket.create_connection((host, port), timeout=10) + except OSError as e: + client.sendall(f"HTTP/1.1 502 Bad Gateway\r\n\r\n{e}\r\n".encode()) + log.warning("upstream connect failed for %s:%d: %s", host, port, e) + return + + client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + client.settimeout(None) + remote.settimeout(None) + relay(client, remote) + except Exception as e: + log.warning("session error from %s:%s: %s", addr[0], addr[1], e) + finally: + try: + client.close() + except OSError: + pass + if remote is not None: + try: + remote.close() + except OSError: + pass + + +def serve(host: str, port: int, expect_auth: str | None) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((host, port)) + srv.listen(64) + log.info( + "HTTP CONNECT proxy listening on %s:%d (auth=%s)", + host, + port, + "yes" if expect_auth else "no", + ) + while True: + client, addr = srv.accept() + t = threading.Thread(target=handle, args=(client, addr, expect_auth), daemon=True) + t.start() + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int, default=8080) + p.add_argument("--auth", help="Require Proxy-Authorization: Basic, value 'user:password'") + p.add_argument("--quiet", action="store_true") + args = p.parse_args() + + logging.basicConfig( + level=logging.WARNING if args.quiet else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + try: + serve(args.host, args.port, args.auth) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/scripts/socks5_test_server.py b/scripts/socks5_test_server.py new file mode 100755 index 00000000..595ca3ce --- /dev/null +++ b/scripts/socks5_test_server.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Minimal SOCKS5 proxy server used by the Android instrumentation test +HostSocks5ProxyIntegrationTest. Stdlib only. + +Defaults to listening on 0.0.0.0:1080. From inside the Android emulator the +host loopback is reachable as 10.0.2.2, so the device sees this as +10.0.2.2:1080. + +Supports: + - SOCKS5 with NO_AUTH (method 0x00) only + - CMD = CONNECT (0x01) only + - ATYP = IPv4 (0x01), DOMAINNAME (0x03), IPv6 (0x04) + +Usage: + python3 scripts/socks5_test_server.py + python3 scripts/socks5_test_server.py --host 0.0.0.0 --port 1080 +""" + +from __future__ import annotations + +import argparse +import logging +import select +import socket +import struct +import threading + +VER = 0x05 +NO_AUTH = 0x00 +CMD_CONNECT = 0x01 +ATYP_IPV4 = 0x01 +ATYP_DOMAIN = 0x03 +ATYP_IPV6 = 0x04 + +REP_OK = 0x00 +REP_GENERAL_FAILURE = 0x01 +REP_NETWORK_UNREACHABLE = 0x03 +REP_HOST_UNREACHABLE = 0x04 +REP_CONN_REFUSED = 0x05 +REP_CMD_NOT_SUPPORTED = 0x07 +REP_ATYP_NOT_SUPPORTED = 0x08 + +log = logging.getLogger("socks5") + + +def recv_exact(sock: socket.socket, n: int) -> bytes: + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise ConnectionError(f"peer closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_reply(sock: socket.socket, rep: int) -> None: + # VER, REP, RSV, ATYP=IPv4, BND.ADDR=0.0.0.0, BND.PORT=0 + sock.sendall(struct.pack("!BBBB4sH", VER, rep, 0x00, ATYP_IPV4, b"\x00\x00\x00\x00", 0)) + + +def negotiate_auth(client: socket.socket) -> None: + ver, nmethods = struct.unpack("!BB", recv_exact(client, 2)) + if ver != VER: + raise ConnectionError(f"bad SOCKS version: {ver}") + methods = recv_exact(client, nmethods) + if NO_AUTH not in methods: + client.sendall(struct.pack("!BB", VER, 0xFF)) + raise ConnectionError("client did not offer NO_AUTH") + client.sendall(struct.pack("!BB", VER, NO_AUTH)) + + +def read_request(client: socket.socket) -> tuple[str, int]: + ver, cmd, _rsv, atyp = struct.unpack("!BBBB", recv_exact(client, 4)) + if ver != VER: + raise ConnectionError(f"bad SOCKS version in request: {ver}") + if cmd != CMD_CONNECT: + send_reply(client, REP_CMD_NOT_SUPPORTED) + raise ConnectionError(f"unsupported CMD: {cmd}") + + if atyp == ATYP_IPV4: + addr = socket.inet_ntop(socket.AF_INET, recv_exact(client, 4)) + elif atyp == ATYP_IPV6: + addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16)) + elif atyp == ATYP_DOMAIN: + (length,) = struct.unpack("!B", recv_exact(client, 1)) + addr = recv_exact(client, length).decode("ascii") + else: + send_reply(client, REP_ATYP_NOT_SUPPORTED) + raise ConnectionError(f"unsupported ATYP: {atyp}") + + (port,) = struct.unpack("!H", recv_exact(client, 2)) + return addr, port + + +def open_remote(host: str, port: int) -> socket.socket: + last_err: Exception | None = None + for family, socktype, proto, _canon, sa in socket.getaddrinfo( + host, port, type=socket.SOCK_STREAM + ): + s = socket.socket(family, socktype, proto) + try: + s.settimeout(10) + s.connect(sa) + s.settimeout(None) + return s + except OSError as e: + last_err = e + s.close() + raise last_err or ConnectionError(f"cannot connect to {host}:{port}") + + +def relay(a: socket.socket, b: socket.socket) -> None: + socks = [a, b] + try: + while True: + ready, _, errored = select.select(socks, [], socks, 60) + if errored or not ready: + break + for s in ready: + peer = b if s is a else a + data = s.recv(8192) + if not data: + return + peer.sendall(data) + except OSError: + pass + + +def handle(client: socket.socket, addr: tuple[str, int]) -> None: + log.info("client connected: %s:%s", *addr) + remote: socket.socket | None = None + try: + client.settimeout(15) + negotiate_auth(client) + host, port = read_request(client) + log.info("CONNECT %s:%s", host, port) + try: + remote = open_remote(host, port) + except OSError as e: + log.warning("upstream connect failed for %s:%s: %s", host, port, e) + send_reply(client, REP_HOST_UNREACHABLE) + return + send_reply(client, REP_OK) + client.settimeout(None) + relay(client, remote) + except Exception as e: + log.warning("session error from %s:%s: %s", addr[0], addr[1], e) + finally: + try: + client.close() + except OSError: + pass + if remote is not None: + try: + remote.close() + except OSError: + pass + + +def serve(host: str, port: int) -> None: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((host, port)) + srv.listen(64) + log.info("SOCKS5 proxy listening on %s:%d (NO_AUTH, CONNECT only)", host, port) + while True: + client, addr = srv.accept() + t = threading.Thread(target=handle, args=(client, addr), daemon=True) + t.start() + + +def main() -> None: + p = argparse.ArgumentParser(description="Minimal SOCKS5 proxy for emulator integration tests") + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int, default=1080) + p.add_argument("--quiet", action="store_true") + args = p.parse_args() + + logging.basicConfig( + level=logging.WARNING if args.quiet else logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + ) + try: + serve(args.host, args.port) + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() From 621dd57159dc46103bfcee04424aa4c4497b4055 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sun, 26 Apr 2026 19:18:24 +0800 Subject: [PATCH 02/16] Wire release signing + drop dead root/iptables code paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * app/build.gradle: signingConfigs.release reads keystore creds from local.properties (KEYSTORE_PATH/PASSWORD, KEY_ALIAS/PASSWORD), so assembleRelease produces an installable APK matching the published cert. Falls back to unsigned when local.properties has no keystore (CI). * Delete ProxyDroidService.kt — the legacy iptables-based service is unreachable from the new VPN flow. ProxyDroidReceiver, the widget, and ConnectivityBroadcastReceiver now all funnel through ProxyController. * Delete the obsolete preference XML (replaced by the Compose UI added in the next commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/build.gradle | 72 ++++++- .../java/org/proxydroid/ProxyDroidService.kt | 192 ------------------ .../res/xml-v14/proxydroid_preference.xml | 156 -------------- .../main/res/xml/proxydroid_preference.xml | 156 -------------- 4 files changed, 68 insertions(+), 508 deletions(-) delete mode 100644 app/src/main/java/org/proxydroid/ProxyDroidService.kt delete mode 100644 app/src/main/res/xml-v14/proxydroid_preference.xml delete mode 100644 app/src/main/res/xml/proxydroid_preference.xml diff --git a/app/build.gradle b/app/build.gradle index 4126af96..3f09ecb3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,32 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.mozilla.rust-android-gradle.rust-android' +} + +def localProps = new Properties() +def localPropsFile = rootProject.file('local.properties') +if (localPropsFile.exists()) { + localPropsFile.withInputStream { localProps.load(it) } } android { namespace 'org.proxydroid' - compileSdk 33 + compileSdk 34 ndkVersion "25.1.8937393" + signingConfigs { + release { + def keystorePath = localProps.getProperty('KEYSTORE_PATH') + if (keystorePath) { + storeFile file(keystorePath) + storePassword localProps.getProperty('KEYSTORE_PASSWORD') + keyAlias localProps.getProperty('KEY_ALIAS') + keyPassword localProps.getProperty('KEY_PASSWORD') + } + } + } + defaultConfig { applicationId "org.proxydroid" minSdk 21 @@ -18,9 +37,9 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" ndk { - // Specifies the ABI configurations of your native - // libraries Gradle should build and package with your APK. - abiFilters 'x86', 'x86_64', 'armeabi-v7a', 'arm64-v8a' + // Cargo Rust crate (proxydroid-tun2socks) currently builds for arm64-v8a + // only; expand once the other Rust android targets are installed. + abiFilters 'arm64-v8a' } } @@ -28,6 +47,9 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + if (localProps.getProperty('KEYSTORE_PATH')) { + signingConfig signingConfigs.release + } } } @@ -50,6 +72,35 @@ android { lint { abortOnError false } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.3' + } + + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +cargo { + module = "src/main/rust/proxydroid-tun2socks" + libname = "proxydroid_tun2socks" + targets = ["arm64"] + profile = "release" + prebuiltToolchains = true +} + +tasks.whenTaskAdded { task -> + if (task.name == 'mergeDebugJniLibFolders' || task.name == 'mergeReleaseJniLibFolders' || + task.name == 'javaPreCompileDebug' || task.name == 'javaPreCompileRelease') { + task.dependsOn 'cargoBuild' + } } dependencies { @@ -64,6 +115,19 @@ dependencies { } implementation 'org.mozilla:rhino:1.7.14' + // Compose + def composeBom = platform('androidx.compose:compose-bom:2023.10.01') + implementation composeBom + androidTestImplementation composeBom + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + implementation 'androidx.activity:activity-compose:1.8.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.2' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' + testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.3.1' diff --git a/app/src/main/java/org/proxydroid/ProxyDroidService.kt b/app/src/main/java/org/proxydroid/ProxyDroidService.kt deleted file mode 100644 index e8414b06..00000000 --- a/app/src/main/java/org/proxydroid/ProxyDroidService.kt +++ /dev/null @@ -1,192 +0,0 @@ -/* proxydroid - Global / Individual Proxy App for Android - * Copyright (C) 2011 Max Lv - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.proxydroid - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.IBinder -import android.util.Log -import androidx.core.app.NotificationCompat -import org.proxydroid.utils.Utils - -class ProxyDroidService : Service() { - - companion object { - private const val TAG = "ProxyDroidService" - private const val NOTIFICATION_ID = 1 - private const val CHANNEL_ID = "proxydroid_channel" - } - - private var host: String = "" - private var port: Int = 0 - private var user: String = "" - private var password: String = "" - private var domain: String = "" - private var proxyType: String = "http" - private var bypassAddrs: String = "" - private var isAuth: Boolean = false - private var isNTLM: Boolean = false - private var isDNSProxy: Boolean = false - private var isPAC: Boolean = false - private var isAutoSetProxy: Boolean = false - private var isBypassApps: Boolean = false - - override fun onCreate() { - super.onCreate() - Log.d(TAG, "ProxyDroid service created") - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent == null) { - stopSelf() - return START_NOT_STICKY - } - - val bundle = intent.extras - if (bundle != null) { - host = bundle.getString("host", "") - port = bundle.getInt("port", 0) - user = bundle.getString("user", "") - password = bundle.getString("password", "") - domain = bundle.getString("domain", "") - proxyType = bundle.getString("proxyType", "http") - bypassAddrs = bundle.getString("bypassAddrs", "") - isAuth = bundle.getBoolean("isAuth", false) - isNTLM = bundle.getBoolean("isNTLM", false) - isDNSProxy = bundle.getBoolean("isDNSProxy", false) - isPAC = bundle.getBoolean("isPAC", false) - isAutoSetProxy = bundle.getBoolean("isAutoSetProxy", false) - isBypassApps = bundle.getBoolean("isBypassApps", false) - } - - startForeground(NOTIFICATION_ID, createNotification()) - - Thread { - startProxy() - }.start() - - return START_STICKY - } - - override fun onDestroy() { - super.onDestroy() - stopProxy() - Utils.setWorking(false) - Utils.setConnecting(false) - Log.d(TAG, "ProxyDroid service destroyed") - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun createNotification(): Notification { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "ProxyDroid Service", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "ProxyDroid proxy service notification" - } - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, ProxyDroid::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.service_running)) - .setSmallIcon(R.drawable.ic_stat_proxydroid) - .setContentIntent(pendingIntent) - .setOngoing(true) - .build() - } - - private fun startProxy() { - Log.d(TAG, "Starting proxy: $host:$port type=$proxyType") - Utils.setConnecting(true) - - try { - if (Utils.isRoot()) { - setupIptables() - } - Utils.setWorking(true) - } catch (e: Exception) { - Log.e(TAG, "Error starting proxy", e) - } finally { - Utils.setConnecting(false) - } - } - - private fun stopProxy() { - Log.d(TAG, "Stopping proxy") - try { - if (Utils.isRoot()) { - clearIptables() - } - } catch (e: Exception) { - Log.e(TAG, "Error stopping proxy", e) - } - } - - private fun setupIptables() { - val iptables = Utils.getIptablesPath() - val commands = StringBuilder() - - // Clear existing rules - commands.append("$iptables -t nat -F OUTPUT\n") - - // Add bypass rules for localhost - commands.append("$iptables -t nat -A OUTPUT -d 127.0.0.1 -j RETURN\n") - - // Add bypass rules for configured addresses - val addrs = Profile.decodeAddrs(bypassAddrs) - for (addr in addrs) { - if (addr.isNotEmpty()) { - commands.append("$iptables -t nat -A OUTPUT -d $addr -j RETURN\n") - } - } - - // Add proxy redirect rule based on proxy type - val localPort = when (proxyType) { - "http" -> 8123 - "socks4", "socks5" -> 1080 - else -> 8123 - } - - commands.append("$iptables -t nat -A OUTPUT -p tcp -j REDIRECT --to-ports $localPort\n") - - Utils.runRootCommand(commands.toString()) - } - - private fun clearIptables() { - val iptables = Utils.getIptablesPath() - Utils.runRootCommand("$iptables -t nat -F OUTPUT") - } -} diff --git a/app/src/main/res/xml-v14/proxydroid_preference.xml b/app/src/main/res/xml-v14/proxydroid_preference.xml deleted file mode 100644 index 4ab63ee5..00000000 --- a/app/src/main/res/xml-v14/proxydroid_preference.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/proxydroid_preference.xml b/app/src/main/res/xml/proxydroid_preference.xml deleted file mode 100644 index 448973b6..00000000 --- a/app/src/main/res/xml/proxydroid_preference.xml +++ /dev/null @@ -1,156 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From c0fe3322634457c80a1797ef1243f7f4e39bd511 Mon Sep 17 00:00:00 2001 From: Max Lv Date: Sun, 26 Apr 2026 19:19:13 +0800 Subject: [PATCH 03/16] Rewrite UI in Jetpack Compose + Material 3, switch to VPN-first flow Main screen is now a single ComponentActivity hosting Compose: * Connection card with prominent switch + status; 1s poller mirrors the service's working/connecting flags into UI state so the chip flips promptly. * Profile dropdown + add/rename/delete via top-bar overflow menu. * Inline Host/Port fields, Material chip row for proxy type (SOCKS5 / SOCKS4 / HTTP / HTTPS), Authentication accordion (with NTLM domain), Advanced section (PAC, DNS proxy, SSID auto-connect, bypass addresses, per-app routing). BypassListActivity and AppManager rewritten in Compose (LazyColumn + ListItem, search field, FilterChip). FileChooser removed in favour of ActivityResultContracts.OpenDocument / CreateDocument for SAF-based import/export; FileArrayAdapter and utils/Option deleted with it. ProxyDroidVpnService: * Always addDisallowedApplication(packageName) so tun2socks's outbound socket bypasses our own tun (fixes ETIMEDOUT loops). * Handles ACTION_STOP intent: closes the tun, calls stopForeground + stopSelf so the system actually destroys the service. Plain stopService doesn't cut it because the VpnService binding holds the service alive while the tun is open. ProxyController is the shared start/stop helper used by activity, broadcast receivers, and the home-screen widget. start runs VpnService.prepare() and either starts the service directly or routes through the activity for the consent dialog (EXTRA_AUTO_START). Profile gains a copy() method; MainViewModel.updateProfile emits a new Profile instance so MutableStateFlow doesn't drop the emission for the in-place mutation. Default proxyType bumped from "http" to "socks5". Drops the legacy preference XML, the AppCompat sub-screens, and the dead "PLEASE ROOT YOUR DEVICE FIRST" alert. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/main/AndroidManifest.xml | 14 +- .../main/java/org/proxydroid/AppManager.kt | 534 +++++----- .../java/org/proxydroid/BypassListActivity.kt | 723 +++++++------- .../ConnectivityBroadcastReceiver.kt | 6 +- .../java/org/proxydroid/FileArrayAdapter.kt | 28 - .../main/java/org/proxydroid/FileChooser.kt | 87 -- app/src/main/java/org/proxydroid/Profile.kt | 31 +- .../main/java/org/proxydroid/ProxyDroid.kt | 923 +++--------------- .../java/org/proxydroid/ProxyDroidReceiver.kt | 81 +- .../org/proxydroid/ProxyDroidVpnService.kt | 60 +- .../proxydroid/ProxyDroidWidgetProvider.kt | 196 ++-- .../java/org/proxydroid/ui/DrawablePainter.kt | 23 + .../main/java/org/proxydroid/ui/MainScreen.kt | 556 +++++++++++ .../java/org/proxydroid/ui/MainViewModel.kt | 215 ++++ .../java/org/proxydroid/ui/theme/Theme.kt | 48 + .../main/java/org/proxydroid/utils/Option.kt | 12 - .../org/proxydroid/utils/ProxyController.kt | 63 ++ .../res/layout/alert_dialog_text_entry.xml | 34 - app/src/main/res/layout/bypass_list.xml | 105 -- app/src/main/res/layout/bypass_list_item.xml | 15 - app/src/main/res/layout/file_view.xml | 7 - app/src/main/res/layout/layout_apps.xml | 10 - app/src/main/res/layout/layout_apps_item.xml | 18 - app/src/main/res/layout/overlay.xml | 6 - app/src/main/res/values-night/themes.xml | 8 + app/src/main/res/values/themes.xml | 10 + build.gradle | 2 + 27 files changed, 1848 insertions(+), 1967 deletions(-) delete mode 100644 app/src/main/java/org/proxydroid/FileArrayAdapter.kt delete mode 100644 app/src/main/java/org/proxydroid/FileChooser.kt create mode 100644 app/src/main/java/org/proxydroid/ui/DrawablePainter.kt create mode 100644 app/src/main/java/org/proxydroid/ui/MainScreen.kt create mode 100644 app/src/main/java/org/proxydroid/ui/MainViewModel.kt create mode 100644 app/src/main/java/org/proxydroid/ui/theme/Theme.kt delete mode 100644 app/src/main/java/org/proxydroid/utils/Option.kt create mode 100644 app/src/main/java/org/proxydroid/utils/ProxyController.kt delete mode 100644 app/src/main/res/layout/alert_dialog_text_entry.xml delete mode 100644 app/src/main/res/layout/bypass_list.xml delete mode 100644 app/src/main/res/layout/bypass_list_item.xml delete mode 100644 app/src/main/res/layout/file_view.xml delete mode 100644 app/src/main/res/layout/layout_apps.xml delete mode 100644 app/src/main/res/layout/layout_apps_item.xml delete mode 100644 app/src/main/res/layout/overlay.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/themes.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0042bffa..9fec5091 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,11 +12,13 @@ android:name=".ProxyDroidApplication" android:icon="@drawable/ic_launcher" android:label="@string/app_name" + android:theme="@style/Theme.ProxyDroid" android:usesCleartextTraffic="true"> @@ -25,21 +27,11 @@ + android:label="@string/app_name" /> - - - ? = null - private lateinit var listApps: ListView - private lateinit var overlay: TextView - private var pd: ProgressDialog? = null - private var adapter: ListAdapter? = null - private lateinit var dm: ImageLoader - private var appsLoaded = false - - companion object { - private const val MSG_LOAD_START = 1 - private const val MSG_LOAD_FINISH = 2 - const val PREFS_KEY_PROXYED = "Proxyed" - - @JvmStatic - fun getProxyedApps(context: Context, self: Boolean): Array { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val tordAppString = prefs.getString(PREFS_KEY_PROXYED, "") ?: "" - val st = StringTokenizer(tordAppString, "|") - val tordApps = Array(st.countTokens()) { st.nextToken() } - Arrays.sort(tordApps) - - val pMgr = context.packageManager - val lAppInfo = pMgr.getInstalledApplications(0) - val vectorApps = Vector() - - for (aInfo in lAppInfo) { - if (aInfo.uid < 10000) continue - - val app = ProxyedApp().apply { - uid = aInfo.uid - username = pMgr.getNameForUid(uid) - isProxyed = when { - aInfo.packageName == "org.proxydroid" -> self - username != null && Arrays.binarySearch(tordApps, username) >= 0 -> true - else -> false - } - } - - if (app.isProxyed) { - vectorApps.add(app) - } - } - - return vectorApps.toTypedArray() - } - } - - private val handler = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - when (msg.what) { - MSG_LOAD_START -> { - pd = ProgressDialog.show(this@AppManager, "", getString(R.string.loading), true, true) - } - MSG_LOAD_FINISH -> { - listApps.adapter = adapter - listApps.setOnScrollListener(object : AbsListView.OnScrollListener { - var visible = false - - override fun onScrollStateChanged(view: AbsListView, scrollState: Int) { - visible = true - if (scrollState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { - overlay.visibility = View.INVISIBLE - } - } - - override fun onScroll(view: AbsListView, firstVisibleItem: Int, visibleItemCount: Int, totalItemCount: Int) { - if (visible && apps != null && firstVisibleItem < apps!!.size) { - val name = apps!![firstVisibleItem].name - overlay.text = if (name != null && name.length > 1) name.substring(0, 1) else "*" - overlay.visibility = View.VISIBLE - } - } - }) - - pd?.dismiss() - pd = null - } - } - super.handleMessage(msg) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - setContentView(R.layout.layout_apps) - - dm = ImageLoaderFactory.getImageLoader(this) - - overlay = View.inflate(this, R.layout.overlay, null) as TextView - windowManager.addView( - overlay, - WindowManager.LayoutParams( - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.WRAP_CONTENT, - WindowManager.LayoutParams.TYPE_APPLICATION, - WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, - PixelFormat.TRANSLUCENT - ) - ) - } - - override fun onDestroy() { - windowManager.removeView(overlay) - super.onDestroy() - } - - override fun onResume() { - super.onResume() - - Thread { - handler.sendEmptyMessage(MSG_LOAD_START) - listApps = findViewById(R.id.applistview) - if (!appsLoaded) loadApps() - handler.sendEmptyMessage(MSG_LOAD_FINISH) - }.start() - } - - private fun loadApps() { - getApps(this) - - apps?.sortWith { o1, o2 -> - when { - o1 == null || o2 == null || o1.name == null || o2.name == null -> 1 - o1.isProxyed == o2.isProxyed -> o1.name!!.compareTo(o2.name!!) - o1.isProxyed -> -1 - else -> 1 - } - } - - val inflater = layoutInflater - - adapter = object : ArrayAdapter(this, R.layout.layout_apps_item, R.id.itemtext, apps!!) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val entry: ListEntry - val view: View - - if (convertView == null) { - view = inflater.inflate(R.layout.layout_apps_item, parent, false) - entry = ListEntry( - view.findViewById(R.id.itemicon), - view.findViewById(R.id.itemcheck), - view.findViewById(R.id.itemtext) - ) - entry.text.setOnClickListener(this@AppManager) - view.tag = entry - entry.box.setOnCheckedChangeListener(this@AppManager) - } else { - view = convertView - entry = view.tag as ListEntry - } - - val app = apps!![position] - entry.icon.tag = app.uid - dm.displayImage(app.uid, view.context as Activity, entry.icon) - entry.text.text = app.name - entry.box.tag = app - entry.box.isChecked = app.isProxyed - entry.text.tag = entry.box - - return view - } - } - - appsLoaded = true - } - - private data class ListEntry( - val icon: ImageView, - val box: CheckBox, - val text: TextView - ) - - override fun onStop() { - super.onStop() - } - - private fun getApps(context: Context) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) - val tordAppString = prefs.getString(PREFS_KEY_PROXYED, "") ?: "" - val st = StringTokenizer(tordAppString, "|") - val tordApps = Array(st.countTokens()) { st.nextToken() } - Arrays.sort(tordApps) - - val vectorApps = Vector() - val pMgr = context.packageManager - val lAppInfo = pMgr.getInstalledApplications(0) - - for (aInfo in lAppInfo) { - if (aInfo.uid < 10000) continue - if (aInfo.processName == null) continue - val label = pMgr.getApplicationLabel(aInfo) - if (label == null || label.toString().isEmpty()) continue - if (pMgr.getApplicationIcon(aInfo) == null) continue - - val tApp = ProxyedApp().apply { - isEnabled = aInfo.enabled - uid = aInfo.uid - username = pMgr.getNameForUid(uid) - procname = aInfo.processName - name = label.toString() - isProxyed = username != null && Arrays.binarySearch(tordApps, username) >= 0 - } - vectorApps.add(tApp) - } - - apps = vectorApps.toTypedArray() - } - - fun saveAppSettings(context: Context) { - val currentApps = apps ?: return - val prefs = PreferenceManager.getDefaultSharedPreferences(this) - - val tordApps = StringBuilder() - for (app in currentApps) { - if (app.isProxyed) { - tordApps.append(app.username) - tordApps.append("|") - } - } - - prefs.edit().putString(PREFS_KEY_PROXYED, tordApps.toString()).apply() - } - - override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { - val app = buttonView.tag as? ProxyedApp - app?.isProxyed = isChecked - saveAppSettings(this) - } - - override fun onClick(v: View) { - val cbox = v.tag as CheckBox - val app = cbox.tag as? ProxyedApp - app?.let { - it.isProxyed = !it.isProxyed - cbox.isChecked = it.isProxyed - } - saveAppSettings(this) - } - -} +/* Per-app proxy selector. Originally based on Orbot/The Guardian Project. */ + +package org.proxydroid + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.preference.PreferenceManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.proxydroid.ui.theme.ProxyDroidTheme +import org.proxydroid.ui.toImageBitmap +import java.util.StringTokenizer + +class AppManager : ComponentActivity() { + + companion object { + const val PREFS_KEY_PROXYED = "Proxyed" + + @JvmStatic + fun getProxyedApps(context: Context, self: Boolean): Array { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val raw = prefs.getString(PREFS_KEY_PROXYED, "") ?: "" + val st = StringTokenizer(raw, "|") + val tokens = Array(st.countTokens()) { st.nextToken() }.also { it.sort() } + + val pMgr = context.packageManager + val out = mutableListOf() + for (info in pMgr.getInstalledApplications(0)) { + if (info.uid < 10000) continue + val app = ProxyedApp().apply { + uid = info.uid + username = pMgr.getNameForUid(uid) + isProxyed = when { + info.packageName == "org.proxydroid" -> self + username != null && tokens.binarySearch(username!!) >= 0 -> true + else -> false + } + } + if (app.isProxyed) out.add(app) + } + return out.toTypedArray() + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ProxyDroidTheme { + AppManagerScreen(onBack = { finish() }) + } + } + } +} + +private data class AppRow( + val uid: Int, + val username: String?, + val name: String, + val icon: Drawable?, + var proxyed: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppManagerScreen(onBack: () -> Unit) { + val ctx = LocalContext.current + val rows = remember { mutableStateListOf() } + var loading by remember { mutableStateOf(true) } + var query by remember { mutableStateOf("") } + var selectedOnly by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val loaded = withContext(Dispatchers.IO) { loadApps(ctx) } + rows.clear() + rows.addAll(loaded) + loading = false + } + + val visible by remember(query, selectedOnly, rows.size) { + derivedStateOf { + rows.filter { r -> + (!selectedOnly || r.proxyed) && + (query.isBlank() || r.name.contains(query, ignoreCase = true) || + (r.username?.contains(query, ignoreCase = true) == true)) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Per-app routing") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = query, + onValueChange = { query = it }, + label = { Text("Search") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = selectedOnly, + onClick = { selectedOnly = !selectedOnly }, + label = { Text("Selected") }, + ) + } + + if (loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (visible.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + if (selectedOnly) "No apps selected yet." else "No matches.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(visible.size, key = { i -> visible[i].uid }) { i -> + val row = visible[i] + ListItem( + headlineContent = { Text(row.name) }, + supportingContent = { + row.username?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + }, + leadingContent = { + val icon = row.icon + if (icon != null) { + Image( + painter = BitmapPainter(remember(row.uid) { icon.toImageBitmap(96, 96) }), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + } else { + Box(modifier = Modifier.size(40.dp)) + } + }, + trailingContent = { + Switch( + checked = row.proxyed, + onCheckedChange = { checked -> + val idx = rows.indexOfFirst { it.uid == row.uid } + if (idx >= 0) { + rows[idx] = rows[idx].copy(proxyed = checked) + saveSelection(ctx, rows) + } + }, + ) + }, + ) + } + } + } + } + } +} + +private fun loadApps(ctx: Context): List { + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val raw = prefs.getString(AppManager.PREFS_KEY_PROXYED, "") ?: "" + val tokens = StringTokenizer(raw, "|").let { st -> + Array(st.countTokens()) { st.nextToken() }.also { it.sort() } + } + val pMgr = ctx.packageManager + val list = mutableListOf() + for (info: ApplicationInfo in pMgr.getInstalledApplications(0)) { + if (info.uid < 10000) continue + if (info.processName == null) continue + val label = pMgr.getApplicationLabel(info)?.toString().orEmpty() + if (label.isBlank()) continue + val icon = runCatching { pMgr.getApplicationIcon(info) }.getOrNull() + val username = pMgr.getNameForUid(info.uid) + val proxyed = username != null && tokens.binarySearch(username) >= 0 + list.add(AppRow(uid = info.uid, username = username, name = label, icon = icon, proxyed = proxyed)) + } + return list.sortedWith(compareByDescending { it.proxyed }.thenBy { it.name.lowercase() }) +} + +private fun saveSelection(ctx: Context, rows: List) { + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + val sb = StringBuilder() + for (r in rows) { + if (r.proxyed && r.username != null) { + sb.append(r.username).append('|') + } + } + prefs.edit().putString(AppManager.PREFS_KEY_PROXYED, sb.toString()).apply() +} + diff --git a/app/src/main/java/org/proxydroid/BypassListActivity.kt b/app/src/main/java/org/proxydroid/BypassListActivity.kt index 38e44b6b..c2ad5a36 100644 --- a/app/src/main/java/org/proxydroid/BypassListActivity.kt +++ b/app/src/main/java/org/proxydroid/BypassListActivity.kt @@ -1,337 +1,386 @@ -/* proxydroid - Global / Individual Proxy App for Android - * Copyright (C) 2011 Max Lv - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.proxydroid - -import android.app.AlertDialog -import android.app.ProgressDialog -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.util.Log -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.widget.* -import androidx.appcompat.app.AppCompatActivity -import android.preference.PreferenceManager -import org.proxydroid.utils.Constraints -import org.proxydroid.utils.Utils -import java.io.* - -class BypassListActivity : AppCompatActivity(), View.OnClickListener, - AdapterView.OnItemClickListener, AdapterView.OnItemLongClickListener { - - companion object { - private val TAG = BypassListActivity::class.java.name - private const val MSG_ERR_ADDR = 0 - private const val MSG_ADD_ADDR = 1 - private const val MSG_EDIT_ADDR = 2 - private const val MSG_DEL_ADDR = 3 - private const val MSG_PRESET_ADDR = 4 - private const val MSG_IMPORT_ADDR = 5 - private const val MSG_EXPORT_ADDR = 6 - } - - private var adapter: ListAdapter? = null - private var bypassList: ArrayList = ArrayList() - private val profile = Profile() - - private val handler = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - when (msg.what) { - MSG_ERR_ADDR -> { - Toast.makeText(this@BypassListActivity, R.string.err_addr, Toast.LENGTH_LONG).show() - } - MSG_ADD_ADDR -> { - val addr = msg.obj as? String ?: return - bypassList.add(addr) - } - MSG_EDIT_ADDR -> { - val addr = msg.obj as? String ?: return - bypassList[msg.arg1] = addr - } - MSG_DEL_ADDR -> { - bypassList.removeAt(msg.arg1) - } - MSG_PRESET_ADDR -> { - val list = Constraints.PRESETS[msg.arg1] - reset(list) - return - } - MSG_EXPORT_ADDR -> { - val path = msg.obj as? String ?: return - Toast.makeText(this@BypassListActivity, "${getString(R.string.exporting)} $path", Toast.LENGTH_LONG).show() - return - } - } - refreshList() - super.handleMessage(msg) - } - } - - override fun onClick(arg0: View) { - when (arg0.id) { - R.id.addBypassAddr -> editAddr(MSG_ADD_ADDR, -1) - R.id.presetBypassAddr -> presetAddr() - R.id.importBypassAddr -> importAddr() - R.id.exportBypassAddr -> exportAddr() - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - setContentView(R.layout.bypass_list) - - findViewById(R.id.addBypassAddr).setOnClickListener(this) - findViewById(R.id.presetBypassAddr).setOnClickListener(this) - findViewById(R.id.importBypassAddr).setOnClickListener(this) - findViewById(R.id.exportBypassAddr).setOnClickListener(this) - - refreshList() - } - - override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - editAddr(MSG_EDIT_ADDR, position) - } - - override fun onItemLongClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long): Boolean { - delAddr(position) - return true - } - - private fun presetAddr() { - AlertDialog.Builder(this) - .setTitle(R.string.preset_button) - .setNegativeButton(R.string.alert_dialog_cancel) { _, _ -> } - .setSingleChoiceItems(R.array.presets_list, -1) { dialog, which -> - if (which >= 0 && which < Constraints.PRESETS.size) { - val msg = Message.obtain().apply { - what = MSG_PRESET_ADDR - arg1 = which - } - handler.sendMessage(msg) - } - dialog.dismiss() - } - .create() - .show() - } - - @Deprecated("Deprecated in Java") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == Constraints.IMPORT_REQUEST && resultCode == RESULT_OK) { - val path = data?.getStringExtra(Constraints.FILE_PATH) - if (path.isNullOrEmpty()) return - - val pd = ProgressDialog.show(this, "", getString(R.string.importing), true, true) - - val h = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - refreshList() - pd?.dismiss() - } - } - - Thread { - try { - FileInputStream(path).use { input -> - BufferedReader(InputStreamReader(input)).use { br -> - bypassList.clear() - var line: String? - while (br.readLine().also { line = it } != null) { - Profile.validateAddr(line)?.let { bypassList.add(it) } - } - } - } - } catch (e: FileNotFoundException) { - Log.e(TAG, "error to open file", e) - } catch (e: IOException) { - Log.e(TAG, "error to read file", e) - } - h.sendEmptyMessage(MSG_IMPORT_ADDR) - }.start() - } - } - - private fun importAddr() { - startActivityForResult(Intent(this, FileChooser::class.java), Constraints.IMPORT_REQUEST) - } - - private fun exportAddr() { - val factory = LayoutInflater.from(this) - val textEntryView = factory.inflate(R.layout.alert_dialog_text_entry, null) - val path = textEntryView.findViewById(R.id.text_edit) - - path.setText("${Utils.getDataPath(this)}/${profile.host}.opt") - - AlertDialog.Builder(this) - .setTitle(R.string.export_button) - .setView(textEntryView) - .setPositiveButton(R.string.alert_dialog_ok) { _, _ -> - val pathText = path.text?.toString() ?: return@setPositiveButton - - Thread { - try { - val file = File(pathText) - if (!file.exists()) file.createNewFile() - - FileOutputStream(file).use { output -> - BufferedOutputStream(output).use { bw -> - for (addr in bypassList) { - Profile.validateAddr(addr)?.let { - bw.write("$it\n".toByteArray()) - } - } - bw.flush() - } - output.flush() - } - } catch (e: FileNotFoundException) { - Log.e(TAG, "error to open file", e) - } catch (e: IOException) { - Log.e(TAG, "error to write file", e) - } - - val msg = Message.obtain().apply { - what = MSG_EXPORT_ADDR - obj = pathText - } - handler.sendMessage(msg) - }.start() - } - .setNegativeButton(R.string.alert_dialog_cancel) { _, _ -> } - .create() - .show() - } - - private fun delAddr(idx: Int) { - val addr = bypassList[idx] - - AlertDialog.Builder(this) - .setTitle(addr) - .setMessage(R.string.bypass_del_text) - .setPositiveButton(R.string.alert_dialog_ok) { _, _ -> - val msg = Message.obtain().apply { - what = MSG_DEL_ADDR - arg1 = idx - obj = addr - } - handler.sendMessage(msg) - } - .setNegativeButton(R.string.alert_dialog_cancel) { _, _ -> } - .create() - .show() - } - - private fun editAddr(msgType: Int, idx: Int) { - val factory = LayoutInflater.from(this) - val textEntryView = factory.inflate(R.layout.alert_dialog_text_entry, null) - val addrText = textEntryView.findViewById(R.id.text_edit) - - when (msgType) { - MSG_EDIT_ADDR -> addrText.setText(bypassList[idx]) - MSG_ADD_ADDR -> addrText.setText("0.0.0.0/0") - } - - AlertDialog.Builder(this) - .setTitle(R.string.bypass_edit_title) - .setView(textEntryView) - .setPositiveButton(R.string.alert_dialog_ok) { _, _ -> - Thread { - val addr = addrText.text.toString() - val validated = Profile.validateAddr(addr) - if (validated != null) { - val msg = Message.obtain().apply { - what = msgType - arg1 = idx - obj = validated - } - handler.sendMessage(msg) - } else { - handler.sendEmptyMessage(MSG_ERR_ADDR) - } - }.start() - } - .setNegativeButton(R.string.alert_dialog_cancel) { _, _ -> } - .create() - .show() - } - - private fun reset(list: Array) { - val pd = ProgressDialog.show(this, "", getString(R.string.reseting), true, true) - - val h = object : Handler(Looper.getMainLooper()) { - override fun handleMessage(msg: Message) { - refreshList() - pd?.dismiss() - } - } - - Thread { - bypassList.clear() - for (addr in list) { - Profile.validateAddr(addr)?.let { bypassList.add(it) } - } - h.sendEmptyMessage(0) - }.start() - } - - private fun refreshList() { - val settings = PreferenceManager.getDefaultSharedPreferences(this) - profile.getProfile(settings) - - if (bypassList.isNotEmpty()) { - profile.bypassAddrs = Profile.encodeAddrs(bypassList.toTypedArray()) - profile.setProfile(settings) - } - - val addrs = Profile.decodeAddrs(profile.bypassAddrs) - bypassList = ArrayList(addrs.toList()) - - val inflater = layoutInflater - - adapter = object : ArrayAdapter(this, R.layout.bypass_list_item, R.id.bypasslistItemText, bypassList) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = convertView ?: inflater.inflate(R.layout.bypass_list_item, parent, false) - val item = view.findViewById(R.id.bypasslistItemText) - bypassList.getOrNull(position)?.let { item.text = it } - return view - } - } - - val list = findViewById(R.id.BypassListView) - list.adapter = adapter - list.onItemClickListener = this - list.onItemLongClickListener = this - } -} +/* proxydroid - Global / Individual Proxy App for Android + * Copyright (C) 2011 Max Lv + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package org.proxydroid + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.preference.PreferenceManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import org.proxydroid.ui.theme.ProxyDroidTheme +import org.proxydroid.utils.Constraints +import java.io.BufferedOutputStream +import java.io.BufferedReader +import java.io.InputStreamReader + +class BypassListActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + ProxyDroidTheme { + BypassListScreen( + onBack = { finish() }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BypassListScreen(onBack: () -> Unit) { + val ctx = LocalContext.current + val settings = remember { PreferenceManager.getDefaultSharedPreferences(ctx) } + val profile = remember { Profile() } + val items = remember { mutableStateListOfBypass(settings, profile) } + + var addrToEdit by remember { mutableStateOf?>(null) } + var addrToDelete by remember { mutableStateOf(null) } + var showAdd by remember { mutableStateOf(false) } + var showPreset by remember { mutableStateOf(false) } + + val importLauncher = rememberImportLauncher { uri -> + importFromUri(ctx, uri, items) + persist(settings, profile, items) + } + val exportLauncher = rememberExportLauncher { uri -> + exportToUri(ctx, uri, items) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Bypass addresses") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .fillMaxSize(), + ) { + ActionBar( + onAdd = { showAdd = true }, + onPreset = { showPreset = true }, + onImport = { importLauncher.launch(arrayOf("text/*", "application/octet-stream")) }, + onExport = { + val name = (profile.host.takeIf { it.isNotBlank() } ?: "bypass") + ".opt" + exportLauncher.launch(name) + }, + ) + if (items.isEmpty()) { + EmptyState() + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items.size, key = { i -> "$i:${items[i]}" }) { i -> + ListItem( + headlineContent = { Text(items[i]) }, + trailingContent = { + IconButton(onClick = { addrToDelete = i }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + }, + modifier = Modifier.clickable { addrToEdit = i to items[i] }, + ) + } + } + } + } + } + + if (showAdd) { + AddrEditDialog( + title = "Add bypass address", + initial = "0.0.0.0/0", + onDismiss = { showAdd = false }, + onConfirm = { input -> + showAdd = false + val v = Profile.validateAddr(input) + if (v == null) { + Toast.makeText(ctx, ctx.getString(R.string.err_addr), Toast.LENGTH_LONG).show() + } else { + items.add(v) + persist(settings, profile, items) + } + }, + ) + } + addrToEdit?.let { (idx, current) -> + AddrEditDialog( + title = "Edit bypass address", + initial = current, + onDismiss = { addrToEdit = null }, + onConfirm = { input -> + addrToEdit = null + val v = Profile.validateAddr(input) + if (v == null) { + Toast.makeText(ctx, ctx.getString(R.string.err_addr), Toast.LENGTH_LONG).show() + } else if (idx in items.indices) { + items[idx] = v + persist(settings, profile, items) + } + }, + ) + } + addrToDelete?.let { idx -> + AlertDialog( + onDismissRequest = { addrToDelete = null }, + title = { Text(items.getOrNull(idx) ?: "") }, + text = { Text("Delete this bypass address?") }, + confirmButton = { + TextButton(onClick = { + addrToDelete = null + if (idx in items.indices) { + items.removeAt(idx) + persist(settings, profile, items) + } + }) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { addrToDelete = null }) { Text("Cancel") } + }, + ) + } + if (showPreset) { + PresetDialog( + onDismiss = { showPreset = false }, + onConfirm = { which -> + showPreset = false + if (which in Constraints.PRESETS.indices) { + items.clear() + Constraints.PRESETS[which].forEach { addr -> + Profile.validateAddr(addr)?.let { items.add(it) } + } + persist(settings, profile, items) + } + }, + ) + } +} + +private fun mutableStateListOfBypass( + settings: android.content.SharedPreferences, + profile: Profile, +): SnapshotStateList { + profile.getProfile(settings) + val list = androidx.compose.runtime.mutableStateListOf() + Profile.decodeAddrs(profile.bypassAddrs).forEach { list.add(it) } + return list +} + +private fun persist( + settings: android.content.SharedPreferences, + profile: Profile, + items: List, +) { + profile.getProfile(settings) + profile.bypassAddrs = Profile.encodeAddrs(items.toTypedArray()) + profile.setProfile(settings) +} + +private fun importFromUri(ctx: Context, uri: Uri?, items: SnapshotStateList) { + if (uri == null) return + try { + ctx.contentResolver.openInputStream(uri)?.use { input -> + BufferedReader(InputStreamReader(input)).use { br -> + val fresh = mutableListOf() + var line: String? + while (br.readLine().also { line = it } != null) { + Profile.validateAddr(line)?.let { fresh.add(it) } + } + items.clear() + items.addAll(fresh) + } + } + Toast.makeText(ctx, "Imported ${items.size} entries", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(ctx, "Import failed: ${e.message}", Toast.LENGTH_LONG).show() + } +} + +private fun exportToUri(ctx: Context, uri: Uri?, items: List) { + if (uri == null) return + try { + ctx.contentResolver.openOutputStream(uri)?.use { out -> + BufferedOutputStream(out).use { bw -> + items.forEach { bw.write("$it\n".toByteArray()) } + bw.flush() + } + } + Toast.makeText(ctx, "Exported ${items.size} entries", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Toast.makeText(ctx, "Export failed: ${e.message}", Toast.LENGTH_LONG).show() + } +} + +@Composable +private fun rememberImportLauncher(onResult: (Uri?) -> Unit) = + androidx.activity.compose.rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { onResult(it) } + +@Composable +private fun rememberExportLauncher(onResult: (Uri?) -> Unit) = + androidx.activity.compose.rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("text/plain"), + ) { onResult(it) } + +@Composable +private fun ActionBar( + onAdd: () -> Unit, + onPreset: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AssistChip(onClick = onAdd, + label = { Text("Add") }, + leadingIcon = { Icon(Icons.Default.Add, null) }) + AssistChip(onClick = onPreset, + label = { Text("Preset") }, + leadingIcon = { Icon(Icons.Default.Tune, null) }) + AssistChip(onClick = onImport, + label = { Text("Import") }, + leadingIcon = { Icon(Icons.Default.Download, null) }) + AssistChip(onClick = onExport, + label = { Text("Export") }, + leadingIcon = { Icon(Icons.Default.Upload, null) }) + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "No bypass addresses yet.\nUse Add or Preset to get started.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun AddrEditDialog( + title: String, + initial: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var v by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = v, + onValueChange = { v = it }, + singleLine = true, + label = { Text("e.g. 192.168.1.0/24") }, + ) + }, + confirmButton = { TextButton(onClick = { onConfirm(v.trim()) }) { Text("OK") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun PresetDialog( + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit, +) { + val ctx = LocalContext.current + val labels = remember { ctx.resources.getStringArray(R.array.presets_list) } + var picked by remember { mutableStateOf(-1) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Choose a preset") }, + text = { + Column { + labels.forEachIndexed { i, label -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = picked == i, onClick = { picked = i }) + Text(label, modifier = Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(picked) }, + enabled = picked >= 0, + ) { Text("Apply") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + diff --git a/app/src/main/java/org/proxydroid/ConnectivityBroadcastReceiver.kt b/app/src/main/java/org/proxydroid/ConnectivityBroadcastReceiver.kt index b45d0fcb..3424a86b 100644 --- a/app/src/main/java/org/proxydroid/ConnectivityBroadcastReceiver.kt +++ b/app/src/main/java/org/proxydroid/ConnectivityBroadcastReceiver.kt @@ -91,7 +91,7 @@ class ConnectivityBroadcastReceiver : BroadcastReceiver() { lastSSID != Constraints.ONLY_WIFI ) { if (Utils.isWorking()) { - context.stopService(Intent(context, ProxyDroidService::class.java)) + org.proxydroid.utils.ProxyController.stop(context) } } } else { @@ -106,7 +106,7 @@ class ConnectivityBroadcastReceiver : BroadcastReceiver() { current = current?.replace("\"", "") if (current != null && current != lastSSID) { if (Utils.isWorking()) { - context.stopService(Intent(context, ProxyDroidService::class.java)) + org.proxydroid.utils.ProxyController.stop(context) } } } @@ -114,7 +114,7 @@ class ConnectivityBroadcastReceiver : BroadcastReceiver() { } else { if (lastSSID != Constraints.ONLY_3G && lastSSID != Constraints.WIFI_AND_3G) { if (Utils.isWorking()) { - context.stopService(Intent(context, ProxyDroidService::class.java)) + org.proxydroid.utils.ProxyController.stop(context) } } } diff --git a/app/src/main/java/org/proxydroid/FileArrayAdapter.kt b/app/src/main/java/org/proxydroid/FileArrayAdapter.kt deleted file mode 100644 index f8a09a6e..00000000 --- a/app/src/main/java/org/proxydroid/FileArrayAdapter.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.proxydroid - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView -import org.proxydroid.utils.Option - -class FileArrayAdapter( - context: Context, - private val resourceId: Int, - private val items: List