From 5f97d2c424b1c9f158f738f53999dc36c80aee3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 19:31:47 +0000 Subject: [PATCH 1/4] Initial plan From b405af1a5076d11dee6ae1bc44efc0c5ad7e971e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 19:41:16 +0000 Subject: [PATCH 2/4] Add complete Android SOCKS5 proxy server application - settings.gradle.kts: project config with plugin management and dependency resolution - build.gradle.kts: top-level build file with AGP 8.5.2, Kotlin 2.0.21 - gradle/libs.versions.toml: version catalog with all dependencies - app/build.gradle.kts: module build config (compileSdk 35, minSdk 26) - AndroidManifest.xml: permissions, activity, foreground service declaration - Socks5Handler.kt: RFC 1928/1929 SOCKS5 protocol handler (connect + auth) - Socks5Server.kt: ServerSocket accept loop with coroutine-per-connection - ProxyForegroundService.kt: foreground service hosting the proxy server - ProxyViewModel.kt: AndroidViewModel with UI state and service control - ProxyScreen.kt: Compose UI with port, auth toggle, start/stop button - Theme/Color.kt: Material3 theming with status colors - MainActivity.kt: edge-to-edge Compose entry point - Resources: strings, themes, colors, vector drawables, adaptive icons Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: veynko <49876227+veynko@users.noreply.github.com> --- .gitignore | 10 + app/build.gradle.kts | 61 ++++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 36 ++ .../com/veynko/proxyserver/MainActivity.kt | 28 ++ .../proxyserver/network/Socks5Handler.kt | 312 ++++++++++++++++++ .../proxyserver/network/Socks5Server.kt | 92 ++++++ .../service/ProxyForegroundService.kt | 181 ++++++++++ .../com/veynko/proxyserver/ui/ProxyScreen.kt | 231 +++++++++++++ .../com/veynko/proxyserver/ui/theme/Color.kt | 15 + .../com/veynko/proxyserver/ui/theme/Theme.kt | 58 ++++ .../proxyserver/viewmodel/ProxyViewModel.kt | 91 +++++ .../main/res/drawable/ic_circle_filled.xml | 10 + .../main/res/drawable/ic_circle_outline.xml | 10 + app/src/main/res/drawable/ic_notification.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/values/colors.xml | 4 + app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/themes.xml | 4 + build.gradle.kts | 6 + gradle/libs.versions.toml | 35 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++ gradlew.bat | 93 ++++++ settings.gradle.kts | 23 ++ 27 files changed, 1580 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/MainActivity.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Handler.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Server.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/service/ProxyForegroundService.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/ui/ProxyScreen.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Color.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/ui/theme/Theme.kt create mode 100644 app/src/main/kotlin/com/veynko/proxyserver/viewmodel/ProxyViewModel.kt create mode 100644 app/src/main/res/drawable/ic_circle_filled.xml create mode 100644 app/src/main/res/drawable/ic_circle_outline.xml create mode 100644 app/src/main/res/drawable/ic_notification.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/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..dbd0f4b --- /dev/null +++ b/app/src/main/kotlin/com/veynko/proxyserver/MainActivity.kt @@ -0,0 +1,28 @@ +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.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() + } + } + } + } +} 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..9da9d2b --- /dev/null +++ b/app/src/main/kotlin/com/veynko/proxyserver/network/Socks5Handler.kt @@ -0,0 +1,312 @@ +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. + */ + 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 + } + } + + // Wait for either direction to finish, then cancel the other + clientToRemote.join() + remoteToClient.cancel() + 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..ebfe97a --- /dev/null +++ b/app/src/main/kotlin/com/veynko/proxyserver/ui/ProxyScreen.kt @@ -0,0 +1,231 @@ +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(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 @@ + + +