add kotlin project
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Android Native Slice
|
||||||
|
|
||||||
|
This folder contains a separate Kotlin/Android rewrite scaffold for running a local proxy on Android.
|
||||||
|
|
||||||
|
What works in this first slice:
|
||||||
|
|
||||||
|
- Foreground service
|
||||||
|
- Local HTTP proxy listener
|
||||||
|
- Local SOCKS5 listener
|
||||||
|
- `CONNECT` tunneling
|
||||||
|
- Plain HTTP proxying with request-line rewrite
|
||||||
|
- JSON config editor in the app
|
||||||
|
|
||||||
|
Current limits:
|
||||||
|
|
||||||
|
- This is not yet a full port of the Python domain-fronting logic.
|
||||||
|
- No MITM CA handling on Android.
|
||||||
|
- No Apps Script relay or Google-fronted TLS transport yet.
|
||||||
|
- No UDP associate for SOCKS5.
|
||||||
|
|
||||||
|
How to use:
|
||||||
|
|
||||||
|
1. Open `android-native` in Android Studio.
|
||||||
|
2. Let Android Studio sync Gradle files.
|
||||||
|
3. Run the app on a device or emulator.
|
||||||
|
4. Edit the JSON config if needed.
|
||||||
|
5. Start the service and point your browser/app to `127.0.0.1:8085` or `127.0.0.1:1080`.
|
||||||
|
|
||||||
|
Suggested next steps if you want this to become the real Android version:
|
||||||
|
|
||||||
|
- Port the `domain_fronter.py` transport layer to Kotlin
|
||||||
|
- Add a real config model for `apps_script`, `domain_fronting`, and `google_fronting`
|
||||||
|
- Add log streaming in the UI
|
||||||
|
- Add optional `VpnService` mode for device-wide capture
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ir.masteralidns.androidproxy"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ir.masteralidns.androidproxy"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
# Intentionally minimal for the first Android-native slice.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.MasterAliDnsAndroid">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ProxyService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package ir.masteralidns.androidproxy
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.URI
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class LocalProxyEngine(
|
||||||
|
private val config: ProxyConfig,
|
||||||
|
private val log: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val listeners = mutableListOf<ServerSocket>()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (config.listenPort == config.socks5Port && config.socks5Enabled) {
|
||||||
|
throw IllegalArgumentException("HTTP and SOCKS5 ports must be different.")
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners += bindListener(config.listenHost, config.listenPort)
|
||||||
|
scope.launch { acceptLoop(listeners[0], "HTTP", ::handleHttpClient) }
|
||||||
|
|
||||||
|
if (config.socks5Enabled) {
|
||||||
|
val socksListener = bindListener(config.listenHost, config.socks5Port)
|
||||||
|
listeners += socksListener
|
||||||
|
scope.launch { acceptLoop(socksListener, "SOCKS5", ::handleSocks5Client) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
listeners.forEach(::closeQuietly)
|
||||||
|
listeners.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindListener(host: String, port: Int): ServerSocket {
|
||||||
|
val server = ServerSocket()
|
||||||
|
server.reuseAddress = true
|
||||||
|
server.bind(InetSocketAddress(InetAddress.getByName(host), port))
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun acceptLoop(
|
||||||
|
server: ServerSocket,
|
||||||
|
label: String,
|
||||||
|
handler: suspend (Socket) -> Unit,
|
||||||
|
) {
|
||||||
|
log("$label listener on ${server.inetAddress.hostAddress}:${server.localPort}")
|
||||||
|
while (scope.isActive && !server.isClosed) {
|
||||||
|
try {
|
||||||
|
val client = server.accept()
|
||||||
|
client.tcpNoDelay = true
|
||||||
|
scope.launch { handler(client) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (!server.isClosed) {
|
||||||
|
log("$label listener stopped unexpectedly")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleHttpClient(client: Socket) {
|
||||||
|
client.use { socket ->
|
||||||
|
socket.soTimeout = 30_000
|
||||||
|
val input = BufferedInputStream(socket.getInputStream())
|
||||||
|
val output = BufferedOutputStream(socket.getOutputStream())
|
||||||
|
|
||||||
|
val headerBytes = readHeaderBlock(input) ?: return
|
||||||
|
val requestText = headerBytes.toString(StandardCharsets.ISO_8859_1)
|
||||||
|
val lines = requestText.split("\r\n")
|
||||||
|
val requestLine = lines.firstOrNull()?.trim().orEmpty()
|
||||||
|
if (requestLine.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val parts = requestLine.split(" ", limit = 3)
|
||||||
|
if (parts.size < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val method = parts[0].uppercase(Locale.US)
|
||||||
|
if (method == "CONNECT") {
|
||||||
|
val (host, port) = splitHostPort(parts[1], 443)
|
||||||
|
handleConnectTunnel(host, port, socket, input, output, "HTTP CONNECT")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = resolveHttpTarget(parts[1], lines)
|
||||||
|
if (target == null) {
|
||||||
|
sendSimpleResponse(output, 400, "Bad Request", "No target host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("HTTP ${target.host}:${target.port} ${target.path}")
|
||||||
|
Socket().use { remote ->
|
||||||
|
remote.connect(InetSocketAddress(target.host, target.port), 10_000)
|
||||||
|
remote.tcpNoDelay = true
|
||||||
|
val remoteOut = BufferedOutputStream(remote.getOutputStream())
|
||||||
|
|
||||||
|
val rebuilt = rebuildHttpRequest(parts, lines, target.path)
|
||||||
|
remoteOut.write(rebuilt.toByteArray(StandardCharsets.ISO_8859_1))
|
||||||
|
remoteOut.flush()
|
||||||
|
|
||||||
|
tunnel(socket, input, output, remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSocks5Client(client: Socket) {
|
||||||
|
client.use { socket ->
|
||||||
|
socket.soTimeout = 30_000
|
||||||
|
val input = BufferedInputStream(socket.getInputStream())
|
||||||
|
val output = BufferedOutputStream(socket.getOutputStream())
|
||||||
|
|
||||||
|
val version = input.read()
|
||||||
|
val methodCount = input.read()
|
||||||
|
if (version != 0x05 || methodCount <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val methods = ByteArray(methodCount)
|
||||||
|
input.readFully(methods)
|
||||||
|
|
||||||
|
if (methods.none { it.toInt() == 0x00 }) {
|
||||||
|
output.write(byteArrayOf(0x05, 0xFF.toByte()))
|
||||||
|
output.flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
output.write(byteArrayOf(0x05, 0x00))
|
||||||
|
output.flush()
|
||||||
|
|
||||||
|
val reqHead = ByteArray(4)
|
||||||
|
input.readFully(reqHead)
|
||||||
|
if (reqHead[0].toInt() != 0x05) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cmd = reqHead[1].toInt() and 0xFF
|
||||||
|
val atyp = reqHead[3].toInt() and 0xFF
|
||||||
|
if (cmd != 0x01) {
|
||||||
|
sendSocksReply(output, 0x07)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val host = readSocksAddress(input, atyp) ?: run {
|
||||||
|
sendSocksReply(output, 0x08)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val port = ((input.read() and 0xFF) shl 8) or (input.read() and 0xFF)
|
||||||
|
log("SOCKS5 $host:$port")
|
||||||
|
|
||||||
|
Socket().use { remote ->
|
||||||
|
remote.connect(InetSocketAddress(host, port), 10_000)
|
||||||
|
remote.tcpNoDelay = true
|
||||||
|
sendSocksReply(output, 0x00)
|
||||||
|
tunnel(socket, input, output, remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleConnectTunnel(
|
||||||
|
host: String,
|
||||||
|
port: Int,
|
||||||
|
client: Socket,
|
||||||
|
clientInput: InputStream,
|
||||||
|
clientOutput: OutputStream,
|
||||||
|
label: String,
|
||||||
|
) {
|
||||||
|
log("$label $host:$port")
|
||||||
|
Socket().use { remote ->
|
||||||
|
remote.connect(InetSocketAddress(host, port), 10_000)
|
||||||
|
remote.tcpNoDelay = true
|
||||||
|
clientOutput.write("HTTP/1.1 200 Connection Established\r\n\r\n".toByteArray())
|
||||||
|
clientOutput.flush()
|
||||||
|
tunnel(client, clientInput, clientOutput, remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tunnel(
|
||||||
|
client: Socket,
|
||||||
|
clientInput: InputStream,
|
||||||
|
clientOutput: OutputStream,
|
||||||
|
remote: Socket,
|
||||||
|
) {
|
||||||
|
val remoteInput = BufferedInputStream(remote.getInputStream())
|
||||||
|
val remoteOutput = BufferedOutputStream(remote.getOutputStream())
|
||||||
|
|
||||||
|
val aToB: Job = scope.launch {
|
||||||
|
copyStream(clientInput, remoteOutput)
|
||||||
|
closeQuietly(remoteOutput)
|
||||||
|
}
|
||||||
|
val bToA: Job = scope.launch {
|
||||||
|
copyStream(remoteInput, clientOutput)
|
||||||
|
closeQuietly(clientOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
aToB.join()
|
||||||
|
bToA.join()
|
||||||
|
closeQuietly(client)
|
||||||
|
closeQuietly(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyStream(input: InputStream, output: OutputStream) {
|
||||||
|
val buffer = ByteArray(16 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val count = try {
|
||||||
|
input.read(buffer)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
if (count <= 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
output.flush()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readHeaderBlock(input: InputStream): ByteArray? {
|
||||||
|
val buffer = ByteArrayOutputStream()
|
||||||
|
var matched = 0
|
||||||
|
val sentinel = byteArrayOf('\r'.code.toByte(), '\n'.code.toByte(), '\r'.code.toByte(), '\n'.code.toByte())
|
||||||
|
while (buffer.size() < 64 * 1024) {
|
||||||
|
val b = input.read()
|
||||||
|
if (b < 0) {
|
||||||
|
return if (buffer.size() == 0) null else buffer.toByteArray()
|
||||||
|
}
|
||||||
|
val value = b.toByte()
|
||||||
|
buffer.write(value.toInt())
|
||||||
|
matched = if (value == sentinel[matched]) matched + 1 else if (value == sentinel[0]) 1 else 0
|
||||||
|
if (matched == sentinel.size) {
|
||||||
|
return buffer.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitHostPort(target: String, defaultPort: Int): Pair<String, Int> {
|
||||||
|
val value = target.trim()
|
||||||
|
if (value.startsWith("[")) {
|
||||||
|
val host = value.substringAfter("[").substringBefore("]")
|
||||||
|
val port = value.substringAfter("]:", defaultPort.toString()).toIntOrNull() ?: defaultPort
|
||||||
|
return host to port
|
||||||
|
}
|
||||||
|
val idx = value.lastIndexOf(':')
|
||||||
|
if (idx > 0 && value.indexOf(':') == idx) {
|
||||||
|
val host = value.substring(0, idx)
|
||||||
|
val port = value.substring(idx + 1).toIntOrNull() ?: defaultPort
|
||||||
|
return host to port
|
||||||
|
}
|
||||||
|
return value to defaultPort
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveHttpTarget(rawTarget: String, lines: List<String>): HttpTarget? {
|
||||||
|
return if (rawTarget.startsWith("http://") || rawTarget.startsWith("https://")) {
|
||||||
|
val uri = URI(rawTarget)
|
||||||
|
val host = uri.host ?: return null
|
||||||
|
val port = if (uri.port > 0) uri.port else if (uri.scheme == "https") 443 else 80
|
||||||
|
val path = buildString {
|
||||||
|
append(uri.rawPath ?: "/")
|
||||||
|
if (!uri.rawQuery.isNullOrBlank()) {
|
||||||
|
append('?')
|
||||||
|
append(uri.rawQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HttpTarget(host, port, path)
|
||||||
|
} else {
|
||||||
|
val hostHeader = lines.firstOrNull { it.startsWith("Host:", ignoreCase = true) }
|
||||||
|
?.substringAfter(':')
|
||||||
|
?.trim()
|
||||||
|
?: return null
|
||||||
|
val (host, port) = splitHostPort(hostHeader, 80)
|
||||||
|
HttpTarget(host, port, rawTarget.ifBlank { "/" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildHttpRequest(parts: List<String>, lines: List<String>, path: String): String {
|
||||||
|
val requestLine = buildString {
|
||||||
|
append(parts[0])
|
||||||
|
append(' ')
|
||||||
|
append(path)
|
||||||
|
if (parts.size >= 3) {
|
||||||
|
append(' ')
|
||||||
|
append(parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val rewrittenHeaders = buildList {
|
||||||
|
add(requestLine)
|
||||||
|
for (line in lines.drop(1)) {
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val lower = line.substringBefore(':').trim().lowercase(Locale.US)
|
||||||
|
if (lower == "proxy-connection") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewrittenHeaders.joinToString("\r\n", postfix = "\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSimpleResponse(output: OutputStream, status: Int, reason: String, body: String) {
|
||||||
|
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
val response = buildString {
|
||||||
|
append("HTTP/1.1 ")
|
||||||
|
append(status)
|
||||||
|
append(' ')
|
||||||
|
append(reason)
|
||||||
|
append("\r\nContent-Type: text/plain; charset=utf-8")
|
||||||
|
append("\r\nContent-Length: ")
|
||||||
|
append(bodyBytes.size)
|
||||||
|
append("\r\nConnection: close\r\n\r\n")
|
||||||
|
}.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
output.write(response)
|
||||||
|
output.write(bodyBytes)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSocksReply(output: OutputStream, code: Int) {
|
||||||
|
output.write(byteArrayOf(0x05, code.toByte(), 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00))
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readSocksAddress(input: InputStream, atyp: Int): String? {
|
||||||
|
return when (atyp) {
|
||||||
|
0x01 -> {
|
||||||
|
val bytes = ByteArray(4)
|
||||||
|
input.readFully(bytes)
|
||||||
|
bytes.joinToString(".") { (it.toInt() and 0xFF).toString() }
|
||||||
|
}
|
||||||
|
0x03 -> {
|
||||||
|
val len = input.read()
|
||||||
|
if (len <= 0) return null
|
||||||
|
val bytes = ByteArray(len)
|
||||||
|
input.readFully(bytes)
|
||||||
|
bytes.toString(StandardCharsets.US_ASCII)
|
||||||
|
}
|
||||||
|
0x04 -> {
|
||||||
|
val bytes = ByteArray(16)
|
||||||
|
input.readFully(bytes)
|
||||||
|
InetAddress.getByAddress(bytes).hostAddress
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readFully(dst: ByteArray) {
|
||||||
|
var offset = 0
|
||||||
|
while (offset < dst.size) {
|
||||||
|
val count = read(dst, offset, dst.size - offset)
|
||||||
|
if (count < 0) {
|
||||||
|
throw IllegalStateException("Unexpected EOF")
|
||||||
|
}
|
||||||
|
offset += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeQuietly(closeable: Any?) {
|
||||||
|
when (closeable) {
|
||||||
|
is Closeable -> runCatching { closeable.close() }
|
||||||
|
is Socket -> runCatching { closeable.close() }
|
||||||
|
is ServerSocket -> runCatching { closeable.close() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class HttpTarget(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val path: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package ir.masteralidns.androidproxy
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
private lateinit var configEditor: EditText
|
||||||
|
private lateinit var statusView: TextView
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
configEditor = findViewById(R.id.configEditor)
|
||||||
|
statusView = findViewById(R.id.statusView)
|
||||||
|
val startButton: Button = findViewById(R.id.startButton)
|
||||||
|
val stopButton: Button = findViewById(R.id.stopButton)
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
|
||||||
|
configEditor.setText(prefs.getString(KEY_CONFIG, ProxyConfig.defaultJson()))
|
||||||
|
|
||||||
|
startButton.setOnClickListener {
|
||||||
|
val rawConfig = configEditor.text.toString().trim()
|
||||||
|
if (rawConfig.isEmpty()) {
|
||||||
|
Toast.makeText(this, "Config is empty", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { ProxyConfig.fromJson(rawConfig) }
|
||||||
|
.onFailure {
|
||||||
|
Toast.makeText(this, "Config error: ${it.message}", Toast.LENGTH_LONG).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.edit().putString(KEY_CONFIG, rawConfig).apply()
|
||||||
|
val intent = Intent(this, ProxyService::class.java).apply {
|
||||||
|
action = ProxyService.ACTION_START
|
||||||
|
putExtra(ProxyService.EXTRA_CONFIG, rawConfig)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(this, intent)
|
||||||
|
statusView.setText(R.string.status_running)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopButton.setOnClickListener {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
this,
|
||||||
|
Intent(this, ProxyService::class.java).apply {
|
||||||
|
action = ProxyService.ACTION_STOP
|
||||||
|
},
|
||||||
|
)
|
||||||
|
statusView.setText(R.string.status_idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "proxy_prefs"
|
||||||
|
private const val KEY_CONFIG = "config_json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package ir.masteralidns.androidproxy
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class ProxyConfig(
|
||||||
|
val listenHost: String = "127.0.0.1",
|
||||||
|
val listenPort: Int = 8085,
|
||||||
|
val socks5Enabled: Boolean = true,
|
||||||
|
val socks5Port: Int = 1080,
|
||||||
|
val directDomains: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
fun toJsonString(): String {
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("listen_host", listenHost)
|
||||||
|
obj.put("listen_port", listenPort)
|
||||||
|
obj.put("socks5_enabled", socks5Enabled)
|
||||||
|
obj.put("socks5_port", socks5Port)
|
||||||
|
obj.put("direct_domains", JSONArray(directDomains))
|
||||||
|
return obj.toString(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun defaultConfig(): ProxyConfig = ProxyConfig(
|
||||||
|
directDomains = listOf(
|
||||||
|
"chatgpt.com",
|
||||||
|
"openai.com",
|
||||||
|
"oaistatic.com",
|
||||||
|
"oaiusercontent.com",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun defaultJson(): String = defaultConfig().toJsonString()
|
||||||
|
|
||||||
|
fun fromJson(raw: String): ProxyConfig {
|
||||||
|
val obj = JSONObject(raw)
|
||||||
|
val directDomains = mutableListOf<String>()
|
||||||
|
val arr = obj.optJSONArray("direct_domains") ?: JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val value = arr.optString(i).trim()
|
||||||
|
if (value.isNotEmpty()) {
|
||||||
|
directDomains += value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ProxyConfig(
|
||||||
|
listenHost = obj.optString("listen_host", "127.0.0.1"),
|
||||||
|
listenPort = obj.optInt("listen_port", 8085),
|
||||||
|
socks5Enabled = obj.optBoolean("socks5_enabled", true),
|
||||||
|
socks5Port = obj.optInt("socks5_port", 1080),
|
||||||
|
directDomains = directDomains,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package ir.masteralidns.androidproxy
|
||||||
|
|
||||||
|
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 androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
class ProxyService : Service() {
|
||||||
|
private var engine: LocalProxyEngine? = null
|
||||||
|
private var currentConfig: ProxyConfig? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ensureNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopEngine()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_START -> {
|
||||||
|
val rawConfig = intent.getStringExtra(EXTRA_CONFIG) ?: ProxyConfig.defaultJson()
|
||||||
|
return runCatching {
|
||||||
|
startWithConfig(ProxyConfig.fromJson(rawConfig))
|
||||||
|
START_STICKY
|
||||||
|
}.getOrElse { error ->
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification("Config error: ${error.message}"))
|
||||||
|
stopSelf()
|
||||||
|
START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopEngine()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun startWithConfig(config: ProxyConfig) {
|
||||||
|
stopEngine()
|
||||||
|
currentConfig = config
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification(summary(config)))
|
||||||
|
|
||||||
|
engine = LocalProxyEngine(config) { message ->
|
||||||
|
val active = currentConfig ?: return@LocalProxyEngine
|
||||||
|
val text = summary(active) + " | $message"
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.notify(NOTIFICATION_ID, buildNotification(text))
|
||||||
|
}.also { it.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopEngine() {
|
||||||
|
engine?.stop()
|
||||||
|
engine = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summary(config: ProxyConfig): String {
|
||||||
|
return if (config.socks5Enabled) {
|
||||||
|
"HTTP ${config.listenHost}:${config.listenPort} | SOCKS5 ${config.listenHost}:${config.socks5Port}"
|
||||||
|
} else {
|
||||||
|
"HTTP ${config.listenHost}:${config.listenPort}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(text: String): Notification {
|
||||||
|
val openIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
2,
|
||||||
|
Intent(this, ProxyService::class.java).apply {
|
||||||
|
action = ACTION_STOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.notification_title))
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setContentIntent(openIntent)
|
||||||
|
.addAction(0, getString(R.string.stop_proxy), stopIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = getString(R.string.notification_channel_desc)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "proxy_service"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
const val ACTION_START = "ir.masteralidns.androidproxy.START"
|
||||||
|
const val ACTION_STOP = "ir.masteralidns.androidproxy.STOP"
|
||||||
|
const val EXTRA_CONFIG = "config"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/titleView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/title_main"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/subtitleView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/subtitle_main"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/status_idle"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/configEditor"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="320dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="top|start"
|
||||||
|
android:hint="@string/config_hint"
|
||||||
|
android:inputType="textMultiLine|textNoSuggestions"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:scrollbars="vertical" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/startButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/start_proxy" />
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="1dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/stopButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/stop_proxy" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="proxy_primary">#0F766E</color>
|
||||||
|
<color name="proxy_primary_dark">#134E4A</color>
|
||||||
|
<color name="proxy_surface">#F6F6F2</color>
|
||||||
|
<color name="proxy_text">#172321</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">MasterAliDns Android</string>
|
||||||
|
<string name="title_main">Android Native Proxy</string>
|
||||||
|
<string name="subtitle_main">First Kotlin slice: local HTTP and SOCKS5 proxy service on-device.</string>
|
||||||
|
<string name="status_idle">Status: stopped</string>
|
||||||
|
<string name="status_running">Status: running</string>
|
||||||
|
<string name="config_hint">Paste JSON config here</string>
|
||||||
|
<string name="start_proxy">Start</string>
|
||||||
|
<string name="stop_proxy">Stop</string>
|
||||||
|
<string name="notification_title">MasterAliDns proxy is running</string>
|
||||||
|
<string name="notification_channel_name">Proxy service</string>
|
||||||
|
<string name="notification_channel_desc">Keeps the local proxy alive in the foreground.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.MasterAliDnsAndroid" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||||
|
<item name="colorPrimary">@color/proxy_primary</item>
|
||||||
|
<item name="colorPrimaryVariant">@color/proxy_primary_dark</item>
|
||||||
|
<item name="colorOnPrimary">@android:color/white</item>
|
||||||
|
<item name="android:windowBackground">@color/proxy_surface</item>
|
||||||
|
<item name="android:textColor">@color/proxy_text</item>
|
||||||
|
<item name="colorSurface">@color/proxy_surface</item>
|
||||||
|
<item name="colorOnSurface">@color/proxy_text</item>
|
||||||
|
<item name="android:statusBarColor" tools:targetApi="l">@color/proxy_primary_dark</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.5.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "MasterAliDnsAndroid"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user