Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 67 additions & 5 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
}
Expand All @@ -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
}
}
}

Expand All @@ -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 {
Expand All @@ -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'

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <b64>`.
*
* 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<String, String>?): Pair<Int, String> {
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()
}
}
Loading
Loading