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