diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
new file mode 100644
index 0000000..6d77c74
--- /dev/null
+++ b/.github/workflows/android-build.yml
@@ -0,0 +1,33 @@
+name: Android Build
+
+on:
+ push:
+ branches: [ main, "copilot/**" ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build Debug APK
+ run: ./gradlew assembleDebug --stacktrace
+
+ - name: Upload APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: debug-apk
+ path: app/build/outputs/apk/debug/*.apk
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0c27dd8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+app/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..5e5b54c
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+}
+
+android {
+ namespace = "com.veynko.proxyserver"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.veynko.proxyserver"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.kotlinx.coroutines.android)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..8199cc4
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Add project specific ProGuard rules here.
+-keep class com.veynko.proxyserver.** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8ce15ad
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/MainActivity.kt b/app/src/main/kotlin/com/veynko/proxyserver/MainActivity.kt
new file mode 100644
index 0000000..c1abce7
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/MainActivity.kt
@@ -0,0 +1,29 @@
+package com.veynko.proxyserver
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
+import androidx.compose.ui.Modifier
+import com.veynko.proxyserver.ui.ProxyScreen
+import com.veynko.proxyserver.ui.theme.ProxyServerTheme
+
+/**
+ * Entry point activity for the SOCKS5 Proxy Server app.
+ */
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ ProxyServerTheme {
+ Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
+ ProxyScreen(modifier = Modifier.padding(innerPadding))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Handler.kt b/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Handler.kt
new file mode 100644
index 0000000..1fb1971
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Handler.kt
@@ -0,0 +1,322 @@
+package com.veynko.proxyserver.network
+
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * Handles a single SOCKS5 client connection.
+ *
+ * Implements the SOCKS5 protocol per RFC 1928 and optional
+ * username/password authentication per RFC 1929.
+ */
+class Socks5Handler(
+ private val clientSocket: Socket,
+ private val authEnabled: Boolean,
+ private val username: String,
+ private val password: String
+) {
+ companion object {
+ private const val TAG = "Socks5Handler"
+
+ // SOCKS5 constants
+ private const val SOCKS_VERSION: Byte = 0x05
+ private const val AUTH_VERSION: Byte = 0x01
+
+ // Authentication methods
+ private const val METHOD_NO_AUTH: Byte = 0x00
+ private const val METHOD_USERNAME_PASSWORD: Byte = 0x02
+ private const val METHOD_NO_ACCEPTABLE: Byte = 0xFF.toByte()
+
+ // Commands
+ private const val CMD_CONNECT: Byte = 0x01
+
+ // Address types
+ private const val ATYP_IPV4: Byte = 0x01
+ private const val ATYP_DOMAIN: Byte = 0x03
+ private const val ATYP_IPV6: Byte = 0x04
+
+ // Reply codes
+ private const val REP_SUCCESS: Byte = 0x00
+ private const val REP_GENERAL_FAILURE: Byte = 0x01
+ private const val REP_NOT_ALLOWED: Byte = 0x02
+ private const val REP_HOST_UNREACHABLE: Byte = 0x04
+ private const val REP_CONN_REFUSED: Byte = 0x05
+ private const val REP_CMD_NOT_SUPPORTED: Byte = 0x07
+ private const val REP_ATYP_NOT_SUPPORTED: Byte = 0x08
+
+ // Buffer size for data forwarding
+ private const val BUFFER_SIZE = 8192
+ }
+
+ /**
+ * Handles the full SOCKS5 handshake and then proxies data
+ * between client and remote host.
+ */
+ suspend fun handle() = withContext(Dispatchers.IO) {
+ val clientAddr = clientSocket.remoteSocketAddress
+ Log.d(TAG, "New connection from $clientAddr")
+ try {
+ clientSocket.use { socket ->
+ val input = socket.getInputStream()
+ val output = socket.getOutputStream()
+
+ // Step 1: Method negotiation
+ if (!negotiateMethod(input, output)) {
+ Log.w(TAG, "Method negotiation failed for $clientAddr")
+ return@withContext
+ }
+
+ // Step 2: Authentication (if required)
+ if (authEnabled && !authenticate(input, output)) {
+ Log.w(TAG, "Authentication failed for $clientAddr")
+ return@withContext
+ }
+
+ // Step 3: Handle the CONNECT request
+ val remoteSocket = processRequest(input, output) ?: return@withContext
+
+ // Step 4: Forward data between client and remote
+ Log.i(TAG, "Tunnel established: $clientAddr <-> ${remoteSocket.remoteSocketAddress}")
+ remoteSocket.use {
+ forwardData(input, output, it.getInputStream(), it.getOutputStream())
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling connection from $clientAddr: ${e.message}")
+ }
+ }
+
+ /**
+ * SOCKS5 greeting/method negotiation (RFC 1928 §3).
+ *
+ * Client sends: [VER=0x05, NMETHODS, METHODS...]
+ * Server responds: [VER=0x05, METHOD]
+ */
+ private fun negotiateMethod(input: InputStream, output: OutputStream): Boolean {
+ val version = input.read()
+ if (version != SOCKS_VERSION.toInt()) {
+ Log.w(TAG, "Unsupported SOCKS version: $version")
+ return false
+ }
+
+ val nMethods = input.read()
+ if (nMethods <= 0) return false
+
+ val methods = ByteArray(nMethods)
+ readFully(input, methods)
+
+ val selectedMethod = if (authEnabled) {
+ if (methods.contains(METHOD_USERNAME_PASSWORD)) METHOD_USERNAME_PASSWORD
+ else METHOD_NO_ACCEPTABLE
+ } else {
+ if (methods.contains(METHOD_NO_AUTH)) METHOD_NO_AUTH
+ else METHOD_NO_ACCEPTABLE
+ }
+
+ output.write(byteArrayOf(SOCKS_VERSION, selectedMethod))
+ output.flush()
+
+ return selectedMethod != METHOD_NO_ACCEPTABLE
+ }
+
+ /**
+ * Username/password authentication (RFC 1929).
+ *
+ * Client: [VER=0x01, ULEN, UNAME, PLEN, PASSWD]
+ * Server: [VER=0x01, STATUS] where STATUS=0x00 means success
+ */
+ private fun authenticate(input: InputStream, output: OutputStream): Boolean {
+ val authVersion = input.read()
+ if (authVersion != AUTH_VERSION.toInt()) {
+ Log.w(TAG, "Unsupported auth sub-version: $authVersion")
+ output.write(byteArrayOf(AUTH_VERSION, 0x01))
+ output.flush()
+ return false
+ }
+
+ val uLen = input.read()
+ val uBytes = ByteArray(uLen)
+ readFully(input, uBytes)
+
+ val pLen = input.read()
+ val pBytes = ByteArray(pLen)
+ readFully(input, pBytes)
+
+ val providedUser = String(uBytes, Charsets.UTF_8)
+ val providedPass = String(pBytes, Charsets.UTF_8)
+
+ val success = providedUser == username && providedPass == password
+ val status: Byte = if (success) 0x00 else 0x01
+ output.write(byteArrayOf(AUTH_VERSION, status))
+ output.flush()
+
+ if (!success) {
+ Log.w(TAG, "Auth failed: user='$providedUser'")
+ }
+ return success
+ }
+
+ /**
+ * Parses the SOCKS5 CONNECT request (RFC 1928 §4).
+ *
+ * Client: [VER, CMD, RSV=0x00, ATYP, DST.ADDR, DST.PORT]
+ * Server: [VER, REP, RSV=0x00, ATYP, BND.ADDR, BND.PORT]
+ *
+ * @return the connected remote [Socket], or null on failure
+ */
+ private fun processRequest(input: InputStream, output: OutputStream): Socket? {
+ val version = input.read()
+ if (version != SOCKS_VERSION.toInt()) {
+ sendReply(output, REP_GENERAL_FAILURE)
+ return null
+ }
+
+ val cmd = input.read().toByte()
+ input.read() // reserved byte
+
+ if (cmd != CMD_CONNECT) {
+ Log.w(TAG, "Unsupported command: $cmd")
+ sendReply(output, REP_CMD_NOT_SUPPORTED)
+ return null
+ }
+
+ val atyp = input.read().toByte()
+ val host: String = when (atyp) {
+ ATYP_IPV4 -> {
+ val addrBytes = ByteArray(4)
+ readFully(input, addrBytes)
+ InetAddress.getByAddress(addrBytes).hostAddress ?: ""
+ }
+ ATYP_DOMAIN -> {
+ val len = input.read()
+ val domainBytes = ByteArray(len)
+ readFully(input, domainBytes)
+ String(domainBytes, Charsets.UTF_8)
+ }
+ ATYP_IPV6 -> {
+ val addrBytes = ByteArray(16)
+ readFully(input, addrBytes)
+ InetAddress.getByAddress(addrBytes).hostAddress ?: ""
+ }
+ else -> {
+ Log.w(TAG, "Unsupported address type: $atyp")
+ sendReply(output, REP_ATYP_NOT_SUPPORTED)
+ return null
+ }
+ }
+
+ val portHigh = input.read()
+ val portLow = input.read()
+ val port = (portHigh shl 8) or portLow
+
+ Log.i(TAG, "CONNECT $host:$port")
+
+ return try {
+ val remoteSocket = Socket()
+ remoteSocket.connect(InetSocketAddress(host, port), 10_000)
+ sendReply(output, REP_SUCCESS)
+ remoteSocket
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to connect to $host:$port - ${e.message}")
+ val rep = when (e) {
+ is java.net.ConnectException -> REP_CONN_REFUSED
+ is java.net.UnknownHostException -> REP_HOST_UNREACHABLE
+ else -> REP_GENERAL_FAILURE
+ }
+ sendReply(output, rep)
+ null
+ }
+ }
+
+ /**
+ * Sends a SOCKS5 reply to the client.
+ * Uses a fixed IPv4 bound address of 0.0.0.0:0.
+ */
+ private fun sendReply(output: OutputStream, rep: Byte) {
+ // [VER, REP, RSV, ATYP=IPv4, 0.0.0.0, port=0]
+ val reply = byteArrayOf(
+ SOCKS_VERSION, rep, 0x00, ATYP_IPV4,
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00
+ )
+ try {
+ output.write(reply)
+ output.flush()
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to send reply: ${e.message}")
+ }
+ }
+
+ /**
+ * Full-duplex data forwarding between client and remote host.
+ * Two coroutines run concurrently, each copying in one direction.
+ * As soon as either direction closes, the other is cancelled so
+ * both sides tear down symmetrically.
+ */
+ private suspend fun forwardData(
+ clientIn: InputStream,
+ clientOut: OutputStream,
+ remoteIn: InputStream,
+ remoteOut: OutputStream
+ ) = coroutineScope {
+ val clientToRemote = launch(Dispatchers.IO) {
+ try {
+ val buf = ByteArray(BUFFER_SIZE)
+ var n: Int
+ while (clientIn.read(buf).also { n = it } != -1) {
+ remoteOut.write(buf, 0, n)
+ remoteOut.flush()
+ }
+ } catch (_: Exception) {
+ // Connection closed
+ }
+ }
+
+ val remoteToClient = launch(Dispatchers.IO) {
+ try {
+ val buf = ByteArray(BUFFER_SIZE)
+ var n: Int
+ while (remoteIn.read(buf).also { n = it } != -1) {
+ clientOut.write(buf, 0, n)
+ clientOut.flush()
+ }
+ } catch (_: Exception) {
+ // Connection closed
+ }
+ }
+
+ // Cancel both jobs as soon as either direction finishes
+ launch {
+ clientToRemote.join()
+ remoteToClient.cancel()
+ }
+ launch {
+ remoteToClient.join()
+ clientToRemote.cancel()
+ }
+
+ clientToRemote.join()
+ remoteToClient.join()
+ }
+
+ /**
+ * Reads exactly [buf.size] bytes from [input] into [buf].
+ * Throws [java.io.EOFException] if the stream ends prematurely.
+ */
+ private fun readFully(input: InputStream, buf: ByteArray) {
+ var offset = 0
+ while (offset < buf.size) {
+ val read = input.read(buf, offset, buf.size - offset)
+ if (read == -1) throw java.io.EOFException("Stream ended prematurely")
+ offset += read
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Server.kt b/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Server.kt
new file mode 100644
index 0000000..8168ae1
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Server.kt
@@ -0,0 +1,92 @@
+package com.veynko.proxyserver.network
+
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.net.InetSocketAddress
+import java.net.ServerSocket
+
+/**
+ * SOCKS5 proxy server that listens on all interfaces (0.0.0.0)
+ * at the given [port].
+ *
+ * Handles multiple concurrent client connections using coroutines.
+ */
+class Socks5Server(
+ private val port: Int,
+ private val authEnabled: Boolean,
+ private val username: String = "",
+ private val password: String = ""
+) {
+ companion object {
+ private const val TAG = "Socks5Server"
+ }
+
+ private var serverSocket: ServerSocket? = null
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var acceptJob: Job? = null
+
+ /**
+ * Starts the server. Blocks until the server socket is bound.
+ * Returns true on success, false if the port is already in use.
+ */
+ fun start(): Boolean {
+ return try {
+ val ss = ServerSocket()
+ ss.reuseAddress = true
+ ss.bind(InetSocketAddress("0.0.0.0", port))
+ serverSocket = ss
+
+ acceptJob = scope.launch {
+ Log.i(TAG, "SOCKS5 server started on port $port (auth=$authEnabled)")
+ acceptLoop(ss)
+ }
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start server: ${e.message}")
+ false
+ }
+ }
+
+ /**
+ * Stops the server and closes all resources.
+ */
+ fun stop() {
+ Log.i(TAG, "Stopping SOCKS5 server")
+ acceptJob?.cancel()
+ serverSocket?.close()
+ serverSocket = null
+ }
+
+ /**
+ * Main accept loop – waits for new client connections and
+ * dispatches each one to a new coroutine.
+ */
+ private suspend fun acceptLoop(ss: ServerSocket) = withContext(Dispatchers.IO) {
+ while (isActive && !ss.isClosed) {
+ try {
+ val clientSocket = ss.accept()
+ // Handle each connection in its own coroutine
+ scope.launch {
+ Socks5Handler(
+ clientSocket = clientSocket,
+ authEnabled = authEnabled,
+ username = username,
+ password = password
+ ).handle()
+ }
+ } catch (e: Exception) {
+ if (!ss.isClosed) {
+ Log.e(TAG, "Accept error: ${e.message}")
+ }
+ // If the socket is closed, the loop will exit naturally
+ }
+ }
+ Log.i(TAG, "Accept loop terminated")
+ }
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/service/ProxyForegroundService.kt b/app/src/main/kotlin/com/veynko/proxyserver/service/ProxyForegroundService.kt
new file mode 100644
index 0000000..70ce71f
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/service/ProxyForegroundService.kt
@@ -0,0 +1,181 @@
+package com.veynko.proxyserver.service
+
+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.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.veynko.proxyserver.MainActivity
+import com.veynko.proxyserver.R
+import com.veynko.proxyserver.network.Socks5Server
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+/**
+ * Foreground service that hosts the SOCKS5 proxy server.
+ *
+ * Start via [start] and stop via [stop] companion functions.
+ * Observe [isRunning] to track current state.
+ */
+class ProxyForegroundService : Service() {
+
+ companion object {
+ private const val TAG = "ProxyForegroundService"
+ private const val CHANNEL_ID = "proxy_server_channel"
+ private const val NOTIFICATION_ID = 1
+
+ // Intent actions
+ const val ACTION_START = "com.veynko.proxyserver.action.START"
+ const val ACTION_STOP = "com.veynko.proxyserver.action.STOP"
+
+ // Intent extras
+ const val EXTRA_PORT = "extra_port"
+ const val EXTRA_AUTH_ENABLED = "extra_auth_enabled"
+ const val EXTRA_USERNAME = "extra_username"
+ const val EXTRA_PASSWORD = "extra_password"
+
+ /** Publicly observable running state. */
+ private val _isRunning = MutableStateFlow(false)
+ val isRunning: StateFlow = _isRunning
+
+ /** Start the proxy service with the given configuration. */
+ fun start(
+ context: Context,
+ port: Int,
+ authEnabled: Boolean,
+ username: String,
+ password: String
+ ) {
+ val intent = Intent(context, ProxyForegroundService::class.java).apply {
+ action = ACTION_START
+ putExtra(EXTRA_PORT, port)
+ putExtra(EXTRA_AUTH_ENABLED, authEnabled)
+ putExtra(EXTRA_USERNAME, username)
+ putExtra(EXTRA_PASSWORD, password)
+ }
+ context.startForegroundService(intent)
+ }
+
+ /** Stop the proxy service. */
+ fun stop(context: Context) {
+ val intent = Intent(context, ProxyForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ context.startService(intent)
+ }
+ }
+
+ private var proxyServer: Socks5Server? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> {
+ val port = intent.getIntExtra(EXTRA_PORT, 1080)
+ val authEnabled = intent.getBooleanExtra(EXTRA_AUTH_ENABLED, false)
+ val username = intent.getStringExtra(EXTRA_USERNAME) ?: ""
+ val password = intent.getStringExtra(EXTRA_PASSWORD) ?: ""
+ startProxy(port, authEnabled, username, password)
+ }
+ ACTION_STOP -> {
+ stopProxy()
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun startProxy(port: Int, authEnabled: Boolean, username: String, password: String) {
+ Log.i(TAG, "Starting proxy on port $port, auth=$authEnabled")
+
+ // Start as a foreground service immediately
+ startForeground(NOTIFICATION_ID, buildNotification(port))
+
+ // Stop any existing server instance
+ proxyServer?.stop()
+
+ val server = Socks5Server(
+ port = port,
+ authEnabled = authEnabled,
+ username = username,
+ password = password
+ )
+ val started = server.start()
+ if (started) {
+ proxyServer = server
+ _isRunning.value = true
+ Log.i(TAG, "Proxy started successfully on port $port")
+ } else {
+ Log.e(TAG, "Failed to start proxy on port $port")
+ _isRunning.value = false
+ stopSelf()
+ }
+ }
+
+ private fun stopProxy() {
+ Log.i(TAG, "Stopping proxy")
+ proxyServer?.stop()
+ proxyServer = null
+ _isRunning.value = false
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ override fun onDestroy() {
+ proxyServer?.stop()
+ proxyServer = null
+ _isRunning.value = false
+ super.onDestroy()
+ }
+
+ // ----- Notification helpers -----
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Proxy Server",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "SOCKS5 Proxy Server running notification"
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun buildNotification(port: Int): Notification {
+ val openIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, openIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val stopIntent = Intent(this, ProxyForegroundService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val stopPendingIntent = PendingIntent.getService(
+ this, 1, stopIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("SOCKS5 Proxy Running")
+ .setContentText("Listening on port $port")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setContentIntent(pendingIntent)
+ .addAction(0, "Stop", stopPendingIntent)
+ .setOngoing(true)
+ .build()
+ }
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/ui/ProxyScreen.kt b/app/src/main/kotlin/com/veynko/proxyserver/ui/ProxyScreen.kt
new file mode 100644
index 0000000..3c61d51
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/ui/ProxyScreen.kt
@@ -0,0 +1,234 @@
+package com.veynko.proxyserver.ui
+
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.veynko.proxyserver.R
+import com.veynko.proxyserver.ui.theme.GreenRunning
+import com.veynko.proxyserver.ui.theme.RedStopped
+import com.veynko.proxyserver.viewmodel.ProxyViewModel
+
+/**
+ * Main UI screen for controlling the SOCKS5 proxy server.
+ */
+@Composable
+fun ProxyScreen(
+ modifier: Modifier = Modifier,
+ viewModel: ProxyViewModel = viewModel()
+) {
+ val state by viewModel.uiState.collectAsState()
+
+ // Request POST_NOTIFICATIONS permission on Android 13+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { /* result ignored */ }
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.Top,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Title
+ Text(
+ text = "SOCKS5 Proxy Server",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Status indicator card
+ StatusCard(isRunning = state.isRunning)
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Configuration card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(4.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+
+ Text(
+ text = "Configuration",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Port field
+ OutlinedTextField(
+ value = state.port,
+ onValueChange = viewModel::onPortChange,
+ label = { Text("Port") },
+ placeholder = { Text("1080") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ singleLine = true,
+ enabled = !state.isRunning,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // Auth toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Enable Authentication")
+ Switch(
+ checked = state.authEnabled,
+ onCheckedChange = viewModel::onAuthEnabledChange,
+ enabled = !state.isRunning
+ )
+ }
+
+ // Username / Password (visible only when auth is enabled)
+ AnimatedVisibility(visible = state.authEnabled) {
+ Column {
+ Spacer(modifier = Modifier.height(12.dp))
+
+ OutlinedTextField(
+ value = state.username,
+ onValueChange = viewModel::onUsernameChange,
+ label = { Text("Username") },
+ singleLine = true,
+ enabled = !state.isRunning,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ OutlinedTextField(
+ value = state.password,
+ onValueChange = viewModel::onPasswordChange,
+ label = { Text("Password") },
+ singleLine = true,
+ enabled = !state.isRunning,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ // Error message
+ if (state.errorMessage != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = state.errorMessage!!,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Start / Stop button
+ Button(
+ onClick = {
+ if (state.isRunning) viewModel.stopServer()
+ else viewModel.startServer()
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (state.isRunning) RedStopped else GreenRunning
+ )
+ ) {
+ Text(
+ text = if (state.isRunning) "Stop Server" else "Start Server",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+}
+
+/**
+ * Card that shows the current running status of the proxy server.
+ */
+@Composable
+private fun StatusCard(isRunning: Boolean) {
+ val statusText = if (isRunning) "Running" else "Stopped"
+ val statusColor = if (isRunning) GreenRunning else RedStopped
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(2.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = statusColor.copy(alpha = 0.1f)
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painter = painterResource(
+ id = if (isRunning) R.drawable.ic_circle_filled else R.drawable.ic_circle_outline
+ ),
+ contentDescription = null,
+ tint = statusColor,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(
+ text = "Status: $statusText",
+ color = statusColor,
+ fontWeight = FontWeight.SemiBold,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Color.kt b/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Color.kt
new file mode 100644
index 0000000..9a6f8cf
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Color.kt
@@ -0,0 +1,15 @@
+package com.veynko.proxyserver.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650A4)
+val PurpleGrey40 = Color(0xFF625B71)
+val Pink40 = Color(0xFF7D5260)
+
+// Status colors
+val GreenRunning = Color(0xFF4CAF50)
+val RedStopped = Color(0xFFF44336)
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Theme.kt b/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Theme.kt
new file mode 100644
index 0000000..03afe89
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.veynko.proxyserver.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+)
+
+@Composable
+fun ProxyServerTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+ val view = LocalView.current
+ if (!view.isInEditMode) {
+ SideEffect {
+ val window = (view.context as Activity).window
+ window.statusBarColor = colorScheme.primary.toArgb()
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ content = content
+ )
+}
diff --git a/app/src/main/kotlin/com/veynko/proxyserver/viewmodel/ProxyViewModel.kt b/app/src/main/kotlin/com/veynko/proxyserver/viewmodel/ProxyViewModel.kt
new file mode 100644
index 0000000..8140f23
--- /dev/null
+++ b/app/src/main/kotlin/com/veynko/proxyserver/viewmodel/ProxyViewModel.kt
@@ -0,0 +1,91 @@
+package com.veynko.proxyserver.viewmodel
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.veynko.proxyserver.service.ProxyForegroundService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * Holds all UI state for the proxy configuration screen.
+ */
+data class ProxyUiState(
+ val port: String = "1080",
+ val authEnabled: Boolean = false,
+ val username: String = "",
+ val password: String = "",
+ val isRunning: Boolean = false,
+ val errorMessage: String? = null
+)
+
+/**
+ * ViewModel that bridges the UI and [ProxyForegroundService].
+ */
+class ProxyViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val _uiState = MutableStateFlow(ProxyUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ // Mirror the service running state into the UI state
+ viewModelScope.launch {
+ ProxyForegroundService.isRunning.collect { running ->
+ _uiState.update { it.copy(isRunning = running) }
+ }
+ }
+ }
+
+ fun onPortChange(value: String) {
+ _uiState.update { it.copy(port = value, errorMessage = null) }
+ }
+
+ fun onAuthEnabledChange(enabled: Boolean) {
+ _uiState.update { it.copy(authEnabled = enabled) }
+ }
+
+ fun onUsernameChange(value: String) {
+ _uiState.update { it.copy(username = value) }
+ }
+
+ fun onPasswordChange(value: String) {
+ _uiState.update { it.copy(password = value) }
+ }
+
+ /** Validates input and starts the proxy service. */
+ fun startServer() {
+ val state = _uiState.value
+ val port = state.port.toIntOrNull()
+ if (port == null || port !in 1..65535) {
+ _uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
+ return
+ }
+ if (state.authEnabled) {
+ if (state.username.isBlank()) {
+ _uiState.update { it.copy(errorMessage = "Username cannot be empty") }
+ return
+ }
+ if (state.password.isBlank()) {
+ _uiState.update { it.copy(errorMessage = "Password cannot be empty") }
+ return
+ }
+ }
+
+ _uiState.update { it.copy(errorMessage = null) }
+ ProxyForegroundService.start(
+ context = getApplication(),
+ port = port,
+ authEnabled = state.authEnabled,
+ username = state.username,
+ password = state.password
+ )
+ }
+
+ /** Stops the running proxy service. */
+ fun stopServer() {
+ ProxyForegroundService.stop(getApplication())
+ }
+}
diff --git a/app/src/main/res/drawable/ic_circle_filled.xml b/app/src/main/res/drawable/ic_circle_filled.xml
new file mode 100644
index 0000000..85a76de
--- /dev/null
+++ b/app/src/main/res/drawable/ic_circle_filled.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_circle_outline.xml b/app/src/main/res/drawable/ic_circle_outline.xml
new file mode 100644
index 0000000..b712c4c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_circle_outline.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 0000000..756d2fb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..2a280b9
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..2a280b9
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..82decc6
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #FF6200EE
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..edd81f2
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ SOCKS5 Proxy
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..966a7a5
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..5c98ad0
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..18ac581
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,35 @@
+[versions]
+agp = "8.5.2"
+kotlin = "2.0.21"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.2.1"
+espressoCore = "3.6.1"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.9.3"
+composeBom = "2024.09.00"
+coroutines = "1.9.0"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d997cfc
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..739907d
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..e509b2d
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..79b0cde
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "ProxyServer"
+include(":app")