diff --git a/android-native/README.md b/android-native/README.md
new file mode 100644
index 0000000..cd31710
--- /dev/null
+++ b/android-native/README.md
@@ -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
diff --git a/android-native/app/build.gradle.kts b/android-native/app/build.gradle.kts
new file mode 100644
index 0000000..cf8aa6e
--- /dev/null
+++ b/android-native/app/build.gradle.kts
@@ -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")
+}
diff --git a/android-native/app/proguard-rules.pro b/android-native/app/proguard-rules.pro
new file mode 100644
index 0000000..a48c986
--- /dev/null
+++ b/android-native/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Intentionally minimal for the first Android-native slice.
diff --git a/android-native/app/src/main/AndroidManifest.xml b/android-native/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fff41b3
--- /dev/null
+++ b/android-native/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt
new file mode 100644
index 0000000..7df3ba2
--- /dev/null
+++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/LocalProxyEngine.kt
@@ -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()
+
+ 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 {
+ 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): 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, lines: List, 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,
+ )
+}
diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt
new file mode 100644
index 0000000..73f56b0
--- /dev/null
+++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/MainActivity.kt
@@ -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"
+ }
+}
diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt
new file mode 100644
index 0000000..740c77a
--- /dev/null
+++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyConfig.kt
@@ -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 = 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()
+ 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,
+ )
+ }
+ }
+}
diff --git a/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt
new file mode 100644
index 0000000..95c50e8
--- /dev/null
+++ b/android-native/app/src/main/java/ir/masteralidns/androidproxy/ProxyService.kt
@@ -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"
+ }
+}
diff --git a/android-native/app/src/main/res/layout/activity_main.xml b/android-native/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..cd49c2c
--- /dev/null
+++ b/android-native/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-native/app/src/main/res/values/colors.xml b/android-native/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..6b967dd
--- /dev/null
+++ b/android-native/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+
+ #0F766E
+ #134E4A
+ #F6F6F2
+ #172321
+
diff --git a/android-native/app/src/main/res/values/strings.xml b/android-native/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..689ed2e
--- /dev/null
+++ b/android-native/app/src/main/res/values/strings.xml
@@ -0,0 +1,14 @@
+
+
+ MasterAliDns Android
+ Android Native Proxy
+ First Kotlin slice: local HTTP and SOCKS5 proxy service on-device.
+ Status: stopped
+ Status: running
+ Paste JSON config here
+ Start
+ Stop
+ MasterAliDns proxy is running
+ Proxy service
+ Keeps the local proxy alive in the foreground.
+
diff --git a/android-native/app/src/main/res/values/themes.xml b/android-native/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..490c197
--- /dev/null
+++ b/android-native/app/src/main/res/values/themes.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/android-native/build.gradle.kts b/android-native/build.gradle.kts
new file mode 100644
index 0000000..017d909
--- /dev/null
+++ b/android-native/build.gradle.kts
@@ -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
+}
diff --git a/android-native/gradle.properties b/android-native/gradle.properties
new file mode 100644
index 0000000..e696167
--- /dev/null
+++ b/android-native/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
diff --git a/android-native/settings.gradle.kts b/android-native/settings.gradle.kts
new file mode 100644
index 0000000..de073b1
--- /dev/null
+++ b/android-native/settings.gradle.kts
@@ -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")