diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index a9542573..d6493f27 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -2,9 +2,9 @@ name: Android Build on: push: - branches: [ master, main, 'claude/**' ] + branches: [ master, main, 'claude/**', 'feature/**', 'test/**' ] pull_request: - branches: [ master, main ] + branches: [ master, main, 'feature/**' ] jobs: build: @@ -27,6 +27,23 @@ jobs: run: | sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + echo "NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Setup Rust toolchain (Android targets for proxydroid-tun2socks) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/src/main/rust/proxydroid-tun2socks/target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Cache Gradle packages uses: actions/cache@v4 @@ -111,6 +128,23 @@ jobs: run: | sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + echo "NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Setup Rust toolchain (Android targets for proxydroid-tun2socks) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/src/main/rust/proxydroid-tun2socks/target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Cache Gradle packages uses: actions/cache@v4 @@ -162,6 +196,23 @@ jobs: run: | sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + echo "NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Setup Rust toolchain (Android targets for proxydroid-tun2socks) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/src/main/rust/proxydroid-tun2socks/target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Cache Gradle packages uses: actions/cache@v4 @@ -250,6 +301,23 @@ jobs: run: | sdkmanager --install "ndk;25.1.8937393" "cmake;3.22.1" echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + echo "NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV + + - name: Setup Rust toolchain (Android targets for proxydroid-tun2socks) + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + app/src/main/rust/proxydroid-tun2socks/target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo- - name: Cache Gradle packages uses: actions/cache@v4 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/build.gradle b/app/build.gradle index 4126af96..b1ea3550 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,25 +1,42 @@ 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 36 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 - targetSdk 33 + minSdk 24 + targetSdk 36 versionCode 74 versionName "3.4.0" 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' } } @@ -28,6 +45,9 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + if (localProps.getProperty('KEYSTORE_PATH')) { + signingConfig signingConfigs.release + } } } @@ -50,6 +70,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 = ["arm", "arm64", "x86", "x86_64"] + 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 +113,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/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt b/app/src/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt new file mode 100644 index 00000000..e418b066 --- /dev/null +++ b/app/src/androidTest/java/org/proxydroid/HostHttpConnectProxyIntegrationTest.kt @@ -0,0 +1,125 @@ +package org.proxydroid + +import android.util.Base64 +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.io.IOException +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Integration tests: from inside the Android emulator, drive raw HTTP CONNECT + * requests against host-side fake HTTP CONNECT proxies — both an auth-less + * variant and one requiring `Proxy-Authorization: Basic `. + * + * Overrides via instrumentation args, e.g.: + * ./gradlew connectedAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyHost=10.0.2.2 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyPort=8081 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPort=8082 \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthUser=alice \ + * -Pandroid.testInstrumentationRunnerArguments.httpProxyAuthPass=s3cret \ + * -Pandroid.testInstrumentationRunnerArguments.httpsTargetHost=example.com \ + * -Pandroid.testInstrumentationRunnerArguments.httpsTargetPort=443 + */ +@RunWith(AndroidJUnit4::class) +class HostHttpConnectProxyIntegrationTest { + + private val args = InstrumentationRegistry.getArguments() + private val proxyHost: String = args.getString("httpProxyHost", "10.0.2.2") + private val proxyNoAuthPort: Int = args.getString("httpProxyPort", "8081").toInt() + private val proxyAuthPort: Int = args.getString("httpProxyAuthPort", "8082").toInt() + private val authUser: String = args.getString("httpProxyAuthUser", "alice") + private val authPass: String = args.getString("httpProxyAuthPass", "s3cret") + private val targetHost: String = args.getString("httpsTargetHost", "example.com") + private val targetPort: Int = args.getString("httpsTargetPort", "443").toInt() + private val connectTimeoutMs: Int = args.getString("connectTimeoutMs", "10000").toInt() + private val readTimeoutMs: Int = args.getString("readTimeoutMs", "15000").toInt() + + @Test + fun httpConnectThroughHostProxyNoAuth() { + val (code, _) = doConnect(proxyNoAuthPort, creds = null) + assertEquals("Expected 200 from auth-less CONNECT", 200, code) + } + + @Test + fun httpConnectBasicAuthSucceedsWithCorrectCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = authUser to authPass) + assertEquals("Expected 200 from auth'd CONNECT", 200, code) + } + + @Test + fun httpConnectBasicAuthRejectsWrongCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = "nobody" to "definitelywrong") + assertEquals( + "Expected 407 Proxy Authentication Required from wrong creds", + 407, + code, + ) + } + + @Test + fun httpConnectBasicAuthRejectsMissingCredentials() { + val (code, _) = doConnect(proxyAuthPort, creds = null) + assertEquals( + "Expected 407 Proxy Authentication Required from no creds", + 407, + code, + ) + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** Send a CONNECT, return (status_code, full_status_line). */ + private fun doConnect(port: Int, creds: Pair?): Pair { + Socket().use { socket -> + socket.connect(InetSocketAddress(proxyHost, port), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + val hostPort = "$targetHost:$targetPort" + val sb = StringBuilder() + .append("CONNECT ").append(hostPort).append(" HTTP/1.1\r\n") + .append("Host: ").append(hostPort).append("\r\n") + .append("Proxy-Connection: keep-alive\r\n") + if (creds != null) { + val raw = "${creds.first}:${creds.second}".toByteArray(Charsets.UTF_8) + val b64 = Base64.encodeToString(raw, Base64.NO_WRAP) + sb.append("Proxy-Authorization: Basic ").append(b64).append("\r\n") + } + sb.append("\r\n") + out.write(sb.toString().toByteArray(Charsets.US_ASCII)) + out.flush() + + val statusLine = readHttpStatusLine(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 + return code to statusLine + } + } + + private fun readHttpStatusLine(input: DataInputStream): String { + val buf = StringBuilder() + while (true) { + val b = try { input.read() } catch (_: IOException) { -1 } + if (b == -1) break + if (b == '\n'.code) break + if (b != '\r'.code) buf.append(b.toChar()) + } + return buf.toString() + } +} 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..ccd03908 --- /dev/null +++ b/app/src/androidTest/java/org/proxydroid/HostSocks5ProxyIntegrationTest.kt @@ -0,0 +1,220 @@ +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.io.IOException +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Integration tests: from inside the Android emulator, drive raw SOCKS5 + * handshakes against host-side fake SOCKS5 proxies (NO_AUTH and RFC 1929 + * user/password). + * + * The emulator reaches the host loopback via the alias 10.0.2.2, so host + * proxies bound to 0.0.0.0: are reachable as 10.0.2.2:. + * + * Overrides via instrumentation args, e.g.: + * ./gradlew connectedAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.socksHost=10.0.2.2 \ + * -Pandroid.testInstrumentationRunnerArguments.socksPort=1080 \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthPort=1081 \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthUser=alice \ + * -Pandroid.testInstrumentationRunnerArguments.socksAuthPass=s3cret \ + * -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 socksAuthPort: Int = args.getString("socksAuthPort", "1081").toInt() + private val socksAuthUser: String = args.getString("socksAuthUser", "alice") + private val socksAuthPass: String = args.getString("socksAuthPass", "s3cret") + 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, user = null, pass = null) + socks5ConnectByDomain(out, input, targetHost, targetPort) + assertHttpGetSucceeds(out, input) + } + } + + @Test + fun httpGetThroughHostSocks5ProxyWithBasicAuth() { + Socket().use { socket -> + socket.connect(InetSocketAddress(socksHost, socksAuthPort), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + socks5Greet(out, input, user = socksAuthUser, pass = socksAuthPass) + socks5ConnectByDomain(out, input, targetHost, targetPort) + assertHttpGetSucceeds(out, input) + } + } + + @Test + fun socks5BasicAuthRejectsWrongCredentials() { + Socket().use { socket -> + socket.connect(InetSocketAddress(socksHost, socksAuthPort), connectTimeoutMs) + socket.soTimeout = readTimeoutMs + val out = DataOutputStream(socket.getOutputStream()) + val input = DataInputStream(socket.getInputStream()) + + // Offer user/pass auth and supply credentials that don't match. + out.write(byteArrayOf(0x05, 0x01, 0x02)) + out.flush() + assertEquals("VER mismatch on greeting", 0x05, input.readUnsignedByte()) + assertEquals("Server should select USER/PASS method", 0x02, input.readUnsignedByte()) + + val badUser = "nobody" + val badPass = "definitelywrong" + out.write(byteArrayOf(0x01)) + out.write(byteArrayOf(badUser.length.toByte())) + out.write(badUser.toByteArray(Charsets.US_ASCII)) + out.write(byteArrayOf(badPass.length.toByte())) + out.write(badPass.toByteArray(Charsets.US_ASCII)) + out.flush() + + val subVer = input.readUnsignedByte() + val status = input.readUnsignedByte() + assertEquals("RFC 1929 sub-negotiation VER mismatch", 0x01, subVer) + assertTrue( + "Expected non-zero auth status (rejection), got $status", + status != 0x00 + ) + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + private fun socks5Greet( + out: DataOutputStream, + input: DataInputStream, + user: String?, + pass: String?, + ) { + if (user == null) { + // VER=5, NMETHODS=1, METHOD=0 (NO AUTH) + out.write(byteArrayOf(0x05, 0x01, 0x00)) + out.flush() + assertEquals("SOCKS version mismatch", 0x05, input.readUnsignedByte()) + assertEquals( + "Proxy did not accept NO_AUTH (auth-less test variant)", + 0x00, + input.readUnsignedByte() + ) + return + } + + // Offer NO_AUTH and USER/PASS, then perform RFC 1929 sub-negotiation. + out.write(byteArrayOf(0x05, 0x02, 0x00, 0x02)) + out.flush() + assertEquals("SOCKS version mismatch", 0x05, input.readUnsignedByte()) + val method = input.readUnsignedByte() + assertEquals("Proxy did not select USER/PASS auth", 0x02, method) + + val u = user.toByteArray(Charsets.US_ASCII) + val p = (pass ?: "").toByteArray(Charsets.US_ASCII) + require(u.size in 1..255 && p.size in 0..255) { "creds out of range" } + out.write(byteArrayOf(0x01)) + out.write(byteArrayOf(u.size.toByte())) + out.write(u) + out.write(byteArrayOf(p.size.toByte())) + out.write(p) + out.flush() + + assertEquals("RFC 1929 sub-negotiation VER mismatch", 0x01, input.readUnsignedByte()) + assertEquals( + "RFC 1929 auth failed (status != 0)", + 0x00, + input.readUnsignedByte() + ) + } + + 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" } + 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) + + when (atyp) { + 0x01 -> input.skipBytes(4) + 0x03 -> { + val len = input.readUnsignedByte() + input.skipBytes(len) + } + 0x04 -> input.skipBytes(16) + else -> throw AssertionError("Unknown SOCKS ATYP=$atyp") + } + input.skipBytes(2) // BND.PORT + } + + private fun assertHttpGetSucceeds(out: DataOutputStream, input: DataInputStream) { + 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 = readHttpStatusLine(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, got: $statusLine", code in 200..399) + } + + private fun readHttpStatusLine(input: DataInputStream): String { + val buf = StringBuilder() + while (true) { + val b = try { input.read() } catch (_: IOException) { -1 } + if (b == -1) break + if (b == '\n'.code) break + if (b != '\r'.code) buf.append(b.toChar()) + } + return buf.toString() + } +} 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" /> - - - - * - * 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. - */ - -#include "tun2socks.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef __ANDROID__ -#include -#define LOG_TAG "tun2socks" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) -#else -#define LOGI(...) fprintf(stdout, __VA_ARGS__) -#define LOGD(...) fprintf(stdout, __VA_ARGS__) -#define LOGE(...) fprintf(stderr, __VA_ARGS__) -#endif - -#define MAX_CONNECTIONS 256 -#define BUFFER_SIZE 65536 -#define SOCKS5_VERSION 0x05 -#define SOCKS5_AUTH_NONE 0x00 -#define SOCKS5_AUTH_PASSWORD 0x02 -#define SOCKS5_CMD_CONNECT 0x01 -#define SOCKS5_ATYP_IPV4 0x01 -#define SOCKS5_ATYP_DOMAIN 0x03 -#define SOCKS5_ATYP_IPV6 0x04 - -static volatile int g_running = 0; -static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; - -typedef struct { - int active; - int tun_fd; - int socks_fd; - uint32_t src_ip; - uint32_t dst_ip; - uint16_t src_port; - uint16_t dst_port; - uint8_t protocol; - time_t last_activity; -} connection_t; - -static connection_t g_connections[MAX_CONNECTIONS]; -static tun2socks_config_t *g_config = NULL; - -static int set_nonblocking(int fd) { - int flags = fcntl(fd, F_GETFL, 0); - if (flags == -1) return -1; - return fcntl(fd, F_SETFL, flags | O_NONBLOCK); -} - -static int connect_to_socks(const char *host, int port) { - int fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) { - LOGE("Failed to create socket: %s\n", strerror(errno)); - return -1; - } - - struct sockaddr_in addr; - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - - if (inet_pton(AF_INET, host, &addr.sin_addr) <= 0) { - LOGE("Invalid SOCKS host: %s\n", host); - close(fd); - return -1; - } - - if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { - LOGE("Failed to connect to SOCKS proxy: %s\n", strerror(errno)); - close(fd); - return -1; - } - - return fd; -} - -static int socks5_handshake(int fd, uint32_t dst_ip, uint16_t dst_port, - const char *user, const char *password) { - uint8_t buf[512]; - ssize_t n; - - // Send auth methods - int has_auth = (user != NULL && password != NULL && strlen(user) > 0); - if (has_auth) { - buf[0] = SOCKS5_VERSION; - buf[1] = 2; - buf[2] = SOCKS5_AUTH_NONE; - buf[3] = SOCKS5_AUTH_PASSWORD; - if (send(fd, buf, 4, 0) != 4) { - LOGE("Failed to send auth methods\n"); - return -1; - } - } else { - buf[0] = SOCKS5_VERSION; - buf[1] = 1; - buf[2] = SOCKS5_AUTH_NONE; - if (send(fd, buf, 3, 0) != 3) { - LOGE("Failed to send auth methods\n"); - return -1; - } - } - - // Receive auth response - n = recv(fd, buf, 2, 0); - if (n != 2) { - LOGE("Failed to receive auth response\n"); - return -1; - } - - if (buf[0] != SOCKS5_VERSION) { - LOGE("Invalid SOCKS version in response: %d\n", buf[0]); - return -1; - } - - if (buf[1] == SOCKS5_AUTH_PASSWORD && has_auth) { - // Send username/password auth - size_t user_len = strlen(user); - size_t pass_len = strlen(password); - buf[0] = 0x01; // Auth version - buf[1] = (uint8_t)user_len; - memcpy(buf + 2, user, user_len); - buf[2 + user_len] = (uint8_t)pass_len; - memcpy(buf + 3 + user_len, password, pass_len); - - if (send(fd, buf, 3 + user_len + pass_len, 0) != (ssize_t)(3 + user_len + pass_len)) { - LOGE("Failed to send auth credentials\n"); - return -1; - } - - n = recv(fd, buf, 2, 0); - if (n != 2 || buf[1] != 0x00) { - LOGE("SOCKS auth failed\n"); - return -1; - } - } else if (buf[1] != SOCKS5_AUTH_NONE) { - LOGE("Unsupported auth method: %d\n", buf[1]); - return -1; - } - - // Send connect request - buf[0] = SOCKS5_VERSION; - buf[1] = SOCKS5_CMD_CONNECT; - buf[2] = 0x00; // Reserved - buf[3] = SOCKS5_ATYP_IPV4; - memcpy(buf + 4, &dst_ip, 4); - uint16_t port_be = htons(dst_port); - memcpy(buf + 8, &port_be, 2); - - if (send(fd, buf, 10, 0) != 10) { - LOGE("Failed to send connect request\n"); - return -1; - } - - // Receive connect response - n = recv(fd, buf, 10, 0); - if (n < 4) { - LOGE("Failed to receive connect response\n"); - return -1; - } - - if (buf[1] != 0x00) { - LOGE("SOCKS connect failed with error: %d\n", buf[1]); - return -1; - } - - // Skip remaining response bytes based on address type - if (buf[3] == SOCKS5_ATYP_IPV4) { - if (n < 10) { - recv(fd, buf, 10 - n, 0); - } - } else if (buf[3] == SOCKS5_ATYP_DOMAIN) { - uint8_t domain_len = buf[4]; - recv(fd, buf, domain_len + 2, 0); - } else if (buf[3] == SOCKS5_ATYP_IPV6) { - recv(fd, buf, 18 - n, 0); - } - - return 0; -} - -static connection_t *find_connection(uint32_t src_ip, uint16_t src_port, - uint32_t dst_ip, uint16_t dst_port, - uint8_t protocol) { - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (g_connections[i].active && - g_connections[i].src_ip == src_ip && - g_connections[i].src_port == src_port && - g_connections[i].dst_ip == dst_ip && - g_connections[i].dst_port == dst_port && - g_connections[i].protocol == protocol) { - return &g_connections[i]; - } - } - return NULL; -} - -static connection_t *create_connection(int tun_fd, uint32_t src_ip, uint16_t src_port, - uint32_t dst_ip, uint16_t dst_port, - uint8_t protocol) { - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (!g_connections[i].active) { - int socks_fd = connect_to_socks(g_config->socks_host, g_config->socks_port); - if (socks_fd < 0) { - return NULL; - } - - if (socks5_handshake(socks_fd, dst_ip, dst_port, - g_config->socks_user, g_config->socks_password) < 0) { - close(socks_fd); - return NULL; - } - - set_nonblocking(socks_fd); - - g_connections[i].active = 1; - g_connections[i].tun_fd = tun_fd; - g_connections[i].socks_fd = socks_fd; - g_connections[i].src_ip = src_ip; - g_connections[i].dst_ip = dst_ip; - g_connections[i].src_port = src_port; - g_connections[i].dst_port = dst_port; - g_connections[i].protocol = protocol; - g_connections[i].last_activity = time(NULL); - - LOGD("Created connection %d: %s:%d -> %08x:%d via SOCKS\n", - i, inet_ntoa(*(struct in_addr *)&src_ip), src_port, - dst_ip, dst_port); - - return &g_connections[i]; - } - } - LOGE("No free connection slots\n"); - return NULL; -} - -static void close_connection(connection_t *conn) { - if (conn && conn->active) { - LOGD("Closing connection: %08x:%d -> %08x:%d\n", - conn->src_ip, conn->src_port, conn->dst_ip, conn->dst_port); - if (conn->socks_fd >= 0) { - close(conn->socks_fd); - } - conn->active = 0; - conn->socks_fd = -1; - } -} - -static void cleanup_idle_connections(void) { - time_t now = time(NULL); - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (g_connections[i].active && (now - g_connections[i].last_activity) > 300) { - LOGD("Cleaning up idle connection %d\n", i); - close_connection(&g_connections[i]); - } - } -} - -static void process_tun_packet(int tun_fd, uint8_t *packet, ssize_t len) { - if (len < 20) return; - - struct iphdr *ip = (struct iphdr *)packet; - if (ip->version != 4) return; - - int ip_hdr_len = ip->ihl * 4; - if (len < ip_hdr_len) return; - - uint32_t src_ip = ip->saddr; - uint32_t dst_ip = ip->daddr; - - if (ip->protocol == IPPROTO_TCP) { - if (len < ip_hdr_len + 20) return; - - struct tcphdr *tcp = (struct tcphdr *)(packet + ip_hdr_len); - uint16_t src_port = ntohs(tcp->source); - uint16_t dst_port = ntohs(tcp->dest); - - connection_t *conn = find_connection(src_ip, src_port, dst_ip, dst_port, IPPROTO_TCP); - - if (tcp->syn && !tcp->ack) { - // New connection - if (!conn) { - conn = create_connection(tun_fd, src_ip, src_port, dst_ip, dst_port, IPPROTO_TCP); - } - } else if (conn) { - // Forward data to SOCKS - int tcp_hdr_len = tcp->doff * 4; - int payload_len = len - ip_hdr_len - tcp_hdr_len; - - if (payload_len > 0) { - uint8_t *payload = packet + ip_hdr_len + tcp_hdr_len; - ssize_t sent = send(conn->socks_fd, payload, payload_len, MSG_NOSIGNAL); - if (sent > 0) { - conn->last_activity = time(NULL); - } else if (sent < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - close_connection(conn); - } - } - - if (tcp->fin || tcp->rst) { - close_connection(conn); - } - } - } else if (ip->protocol == IPPROTO_UDP) { - // UDP handling - forward DNS queries - if (len < ip_hdr_len + 8) return; - - struct udphdr *udp = (struct udphdr *)(packet + ip_hdr_len); - uint16_t dst_port = ntohs(udp->dest); - - // Only handle DNS (port 53) - if (dst_port == 53) { - LOGD("DNS query intercepted\n"); - // DNS queries are handled by routing to DNS server directly - } - } -} - -static void process_socks_data(connection_t *conn) { - uint8_t buffer[BUFFER_SIZE]; - ssize_t n = recv(conn->socks_fd, buffer, sizeof(buffer), 0); - - if (n > 0) { - conn->last_activity = time(NULL); - // In a full implementation, we would construct IP packets and write to TUN - // For now, the data flows through the SOCKS proxy - LOGD("Received %zd bytes from SOCKS\n", n); - } else if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) { - close_connection(conn); - } -} - -int tun2socks_start(tun2socks_config_t *config) { - if (!config || config->tun_fd < 0) { - LOGE("Invalid configuration\n"); - return -1; - } - - pthread_mutex_lock(&g_lock); - if (g_running) { - pthread_mutex_unlock(&g_lock); - LOGE("tun2socks is already running\n"); - return -1; - } - g_running = 1; - g_config = config; - pthread_mutex_unlock(&g_lock); - - // Initialize connections - memset(g_connections, 0, sizeof(g_connections)); - for (int i = 0; i < MAX_CONNECTIONS; i++) { - g_connections[i].socks_fd = -1; - } - - set_nonblocking(config->tun_fd); - - LOGI("tun2socks started: TUN fd=%d, SOCKS=%s:%d\n", - config->tun_fd, config->socks_host, config->socks_port); - - uint8_t buffer[BUFFER_SIZE]; - time_t last_cleanup = time(NULL); - - while (g_running) { - fd_set read_fds; - FD_ZERO(&read_fds); - FD_SET(config->tun_fd, &read_fds); - - int max_fd = config->tun_fd; - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (g_connections[i].active && g_connections[i].socks_fd >= 0) { - FD_SET(g_connections[i].socks_fd, &read_fds); - if (g_connections[i].socks_fd > max_fd) { - max_fd = g_connections[i].socks_fd; - } - } - } - - struct timeval timeout = {1, 0}; - int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout); - - if (ret < 0) { - if (errno == EINTR) continue; - LOGE("select error: %s\n", strerror(errno)); - break; - } - - if (ret == 0) { - // Timeout - cleanup idle connections - time_t now = time(NULL); - if (now - last_cleanup > 60) { - cleanup_idle_connections(); - last_cleanup = now; - } - continue; - } - - // Check TUN interface - if (FD_ISSET(config->tun_fd, &read_fds)) { - ssize_t n = read(config->tun_fd, buffer, sizeof(buffer)); - if (n > 0) { - process_tun_packet(config->tun_fd, buffer, n); - } else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) { - LOGE("TUN read error: %s\n", strerror(errno)); - } - } - - // Check SOCKS connections - for (int i = 0; i < MAX_CONNECTIONS; i++) { - if (g_connections[i].active && g_connections[i].socks_fd >= 0 && - FD_ISSET(g_connections[i].socks_fd, &read_fds)) { - process_socks_data(&g_connections[i]); - } - } - } - - // Cleanup - for (int i = 0; i < MAX_CONNECTIONS; i++) { - close_connection(&g_connections[i]); - } - - pthread_mutex_lock(&g_lock); - g_running = 0; - g_config = NULL; - pthread_mutex_unlock(&g_lock); - - LOGI("tun2socks stopped\n"); - return 0; -} - -void tun2socks_stop(void) { - pthread_mutex_lock(&g_lock); - g_running = 0; - pthread_mutex_unlock(&g_lock); -} - -int tun2socks_is_running(void) { - int running; - pthread_mutex_lock(&g_lock); - running = g_running; - pthread_mutex_unlock(&g_lock); - return running; -} diff --git a/app/src/main/cpp/tun2socks/core/tun2socks.h b/app/src/main/cpp/tun2socks/core/tun2socks.h deleted file mode 100644 index f77a3fc5..00000000 --- a/app/src/main/cpp/tun2socks/core/tun2socks.h +++ /dev/null @@ -1,58 +0,0 @@ -/* - * tun2socks - TUN interface to SOCKS5 proxy converter - * 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. - */ - -#ifndef TUN2SOCKS_H -#define TUN2SOCKS_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/** - * Configuration structure for tun2socks - */ -typedef struct { - int tun_fd; // TUN interface file descriptor - int mtu; // Maximum transmission unit - char *socks_host; // SOCKS5 proxy host - int socks_port; // SOCKS5 proxy port - char *socks_user; // SOCKS5 username (can be NULL) - char *socks_password; // SOCKS5 password (can be NULL) -} tun2socks_config_t; - -/** - * Start the tun2socks event loop. - * This function blocks until tun2socks_stop() is called. - * - * @param config Configuration structure - * @return 0 on success, -1 on error - */ -int tun2socks_start(tun2socks_config_t *config); - -/** - * Stop the tun2socks event loop. - * This function can be called from any thread. - */ -void tun2socks_stop(void); - -/** - * Check if tun2socks is running. - * - * @return 1 if running, 0 otherwise - */ -int tun2socks_is_running(void); - -#ifdef __cplusplus -} -#endif - -#endif /* TUN2SOCKS_H */ diff --git a/app/src/main/cpp/tun2socks/tun2socks_jni.cpp b/app/src/main/cpp/tun2socks/tun2socks_jni.cpp deleted file mode 100644 index c06b1ac8..00000000 --- a/app/src/main/cpp/tun2socks/tun2socks_jni.cpp +++ /dev/null @@ -1,100 +0,0 @@ -/* - * tun2socks JNI wrapper - * 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. - */ - -#include -#include -#include - -#include "core/tun2socks.h" - -#define LOG_TAG "tun2socks-jni" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) - -static tun2socks_config_t g_config; -static char g_socks_host[256]; -static char g_socks_user[256]; -static char g_socks_password[256]; - -extern "C" { - -JNIEXPORT jint JNICALL -Java_org_proxydroid_utils_Tun2SocksHelper_nativeStart( - JNIEnv *env, - jobject thiz, - jint tun_fd, - jint mtu, - jstring socks_host, - jint socks_port, - jstring socks_user, - jstring socks_password) { - - LOGI("nativeStart called: tun_fd=%d, mtu=%d, port=%d", tun_fd, mtu, socks_port); - - // Get SOCKS host - const char *host = env->GetStringUTFChars(socks_host, nullptr); - if (host == nullptr) { - LOGE("Failed to get SOCKS host string"); - return -1; - } - strncpy(g_socks_host, host, sizeof(g_socks_host) - 1); - g_socks_host[sizeof(g_socks_host) - 1] = '\0'; - env->ReleaseStringUTFChars(socks_host, host); - - // Get SOCKS user (optional) - g_socks_user[0] = '\0'; - if (socks_user != nullptr) { - const char *user = env->GetStringUTFChars(socks_user, nullptr); - if (user != nullptr) { - strncpy(g_socks_user, user, sizeof(g_socks_user) - 1); - g_socks_user[sizeof(g_socks_user) - 1] = '\0'; - env->ReleaseStringUTFChars(socks_user, user); - } - } - - // Get SOCKS password (optional) - g_socks_password[0] = '\0'; - if (socks_password != nullptr) { - const char *password = env->GetStringUTFChars(socks_password, nullptr); - if (password != nullptr) { - strncpy(g_socks_password, password, sizeof(g_socks_password) - 1); - g_socks_password[sizeof(g_socks_password) - 1] = '\0'; - env->ReleaseStringUTFChars(socks_password, password); - } - } - - // Configure tun2socks - memset(&g_config, 0, sizeof(g_config)); - g_config.tun_fd = tun_fd; - g_config.mtu = mtu; - g_config.socks_host = g_socks_host; - g_config.socks_port = socks_port; - g_config.socks_user = strlen(g_socks_user) > 0 ? g_socks_user : nullptr; - g_config.socks_password = strlen(g_socks_password) > 0 ? g_socks_password : nullptr; - - LOGI("Starting tun2socks with host=%s, port=%d", g_socks_host, socks_port); - - // Start tun2socks (this will block) - int result = tun2socks_start(&g_config); - - LOGI("tun2socks_start returned: %d", result); - return result; -} - -JNIEXPORT void JNICALL -Java_org_proxydroid_utils_Tun2SocksHelper_nativeStop( - JNIEnv *env, - jobject thiz) { - - LOGI("nativeStop called"); - tun2socks_stop(); -} - -} // extern "C" diff --git a/app/src/main/java/org/proxydroid/AppManager.kt b/app/src/main/java/org/proxydroid/AppManager.kt index b9506e49..77c915e6 100644 --- a/app/src/main/java/org/proxydroid/AppManager.kt +++ b/app/src/main/java/org/proxydroid/AppManager.kt @@ -1,278 +1,256 @@ -/* Copyright (c) 2009, Nathan Freitas, Orbot / The Guardian Project - http://openideals.com/guardian */ -/* See LICENSE for licensing information */ - -package org.proxydroid - -import android.app.Activity -import android.app.ProgressDialog -import android.content.Context -import android.graphics.PixelFormat -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.Message -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewGroup -import android.view.WindowManager -import android.widget.* -import androidx.appcompat.app.AppCompatActivity -import android.preference.PreferenceManager -import org.proxydroid.utils.ImageLoader -import org.proxydroid.utils.ImageLoaderFactory -import java.util.* - -class AppManager : AppCompatActivity(), CompoundButton.OnCheckedChangeListener, View.OnClickListener { - - private var apps: Array? = 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