From 3ed3a8e79094940ec774511ede5f714968ba11d7 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 18:47:38 +0300 Subject: [PATCH 001/366] Create debug.yml --- .github/workflows/debug.yml | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/debug.yml diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 0000000..5928ff8 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,38 @@ +name: Build Debug APK + +on: + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master" ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Build debug APK + run: ./gradlew assembleDebug --no-daemon + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/*.apk + retention-days: 14 From 5a4fc64929196def44f12c548d80aeae640581bc Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 18:50:46 +0300 Subject: [PATCH 002/366] Create PayloadProcessor.java --- .../sshproxy/payload/PayloadProcessor.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java diff --git a/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java b/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java new file mode 100644 index 0000000..3c4707e --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/payload/PayloadProcessor.java @@ -0,0 +1,54 @@ +package com.example.sshproxy.payload; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +public class PayloadProcessor { + + private static int rotateIndex = 0; + + public static String processPayload(String template, String host, String port, String proxy, String userAgent) { + String payload = template; + + // 1. Replace [crlf] with \r\n + payload = payload.replace("[crlf]", "\r\n"); + + // 2. Replace [host] and [rlb] with actual host + payload = payload.replace("[host]", host); + payload = payload.replace("[rlb]", host); + + // 3. Replace [port] with actual port + payload = payload.replace("[port]", port); + + // 4. Replace [proxy] with proxy host:port + if (proxy != null && !proxy.isEmpty()) { + payload = payload.replace("[proxy]", proxy); + } + + // 5. Replace [ua] with User-Agent + if (userAgent == null || userAgent.isEmpty()) { + userAgent = "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"; + } + payload = payload.replace("[ua]", userAgent); + + // 6. Handle [rotate=host1;host2;host3] - Sequential failover + Pattern rotatePattern = Pattern.compile("\\[rotate=([^\\]]+)\\]"); + Matcher rotateMatcher = rotatePattern.matcher(payload); + if (rotateMatcher.find()) { + String[] hosts = rotateMatcher.group(1).split(";"); + String selectedHost = hosts[rotateIndex % hosts.length]; + payload = payload.replace(rotateMatcher.group(0), selectedHost); + rotateIndex++; + } + + return payload; + } + + public static String[] splitPayload(String payload) { + return payload.split("\\[split\\]"); + } + + public static void resetRotateIndex() { + rotateIndex = 0; + } +} From 08ad679435be22b245c3ce216515bcda2296a628 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 18:54:30 +0300 Subject: [PATCH 003/366] Update build.gradle --- app/build.gradle | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 801f663..347d10f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -59,25 +59,19 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7' implementation 'androidx.navigation:navigation-ui-ktx:2.7.7' - implementation "androidx.fragment:fragment-ktx:1.6.2" // Add this line for by viewModels() + implementation "androidx.fragment:fragment-ktx:1.6.2" + + // SSH Library + implementation 'com.github.mwiede:jsch:0.2.16' - // SSH libraries - implementation 'com.jcraft:jsch:0.1.55' implementation 'com.hierynomus:sshj:0.38.0' - - // Bouncycastle for cryptography implementation 'org.bouncycastle:bcprov-jdk18on:1.75' implementation 'org.bouncycastle:bcpkix-jdk18on:1.75' - - // Gson for JSON implementation 'com.google.code.gson:gson:2.10.1' - - // Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' - - // Room database + def room_version = "2.6.1" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" kapt "androidx.room:room-compiler:$room_version" -} \ No newline at end of file +} From e8842abe7fd3da0d0569e78b93c4c295a454e174 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 18:58:19 +0300 Subject: [PATCH 004/366] Create activity_http_custom.xml --- .../main/res/layout/activity_http_custom.xml | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 app/src/main/res/layout/activity_http_custom.xml diff --git a/app/src/main/res/layout/activity_http_custom.xml b/app/src/main/res/layout/activity_http_custom.xml new file mode 100644 index 0000000..80458cb --- /dev/null +++ b/app/src/main/res/layout/activity_http_custom.xml @@ -0,0 +1,276 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 283f6ce6823858540ac3c7b51277a099cd4a4ad4 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:00:15 +0300 Subject: [PATCH 005/366] Create HttpCustomActivity.kt --- .../example/sshproxy/HttpCustomActivity.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt diff --git a/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt b/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt new file mode 100644 index 0000000..2a76a23 --- /dev/null +++ b/app/src/main/res/layout/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt @@ -0,0 +1,102 @@ +package com.example.sshproxy + +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity + +class HttpCustomActivity : AppCompatActivity() { + + private lateinit var sshHostInput: EditText + private lateinit var sshPortInput: EditText + private lateinit var sshUsernameInput: EditText + private lateinit var sshPasswordInput: EditText + private lateinit var proxyHostInput: EditText + private lateinit var proxyPortInput: EditText + private lateinit var payloadInput: EditText + private lateinit var connectButton: Button + private lateinit var disconnectButton: Button + private lateinit var statusText: TextView + private lateinit var logText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_http_custom) + + sshHostInput = findViewById(R.id.sshHostInput) + sshPortInput = findViewById(R.id.sshPortInput) + sshUsernameInput = findViewById(R.id.sshUsernameInput) + sshPasswordInput = findViewById(R.id.sshPasswordInput) + proxyHostInput = findViewById(R.id.proxyHostInput) + proxyPortInput = findViewById(R.id.proxyPortInput) + payloadInput = findViewById(R.id.payloadInput) + connectButton = findViewById(R.id.connectButton) + disconnectButton = findViewById(R.id.disconnectButton) + statusText = findViewById(R.id.statusText) + logText = findViewById(R.id.logText) + + connectButton.setOnClickListener { + addLog("[UI] Connect button pressed") + startVpnService() + } + + disconnectButton.setOnClickListener { + addLog("[UI] Disconnect button pressed") + stopVpnService() + } + } + + private fun startVpnService() { + val sshHost = sshHostInput.text.toString().trim() + val sshPort = sshPortInput.text.toString().trim() + val sshUser = sshUsernameInput.text.toString().trim() + val sshPass = sshPasswordInput.text.toString().trim() + val proxyHost = proxyHostInput.text.toString().trim() + val proxyPort = proxyPortInput.text.toString().trim() + val payload = payloadInput.text.toString().trim() + + if (sshHost.isEmpty() || sshPort.isEmpty() || sshUser.isEmpty() || sshPass.isEmpty()) { + addLog("[ERROR] Please fill in SSH details") + statusText.text = "Status: Error - Missing SSH details" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + return + } + + addLog("[Config] SSH: $sshHost:$sshPort") + addLog("[Config] Proxy: $proxyHost:$proxyPort") + addLog("[Config] Payload: ${if (payload.length > 50) payload.substring(0, 50) + "..." else payload}") + + val intent = android.content.Intent(this, CustomVpnService::class.java) + intent.putExtra("sshHost", sshHost) + intent.putExtra("sshPort", sshPort) + intent.putExtra("sshUser", sshUser) + intent.putExtra("sshPass", sshPass) + intent.putExtra("proxyHost", proxyHost) + intent.putExtra("proxyPort", proxyPort) + intent.putExtra("payload", payload) + + startService(intent) + statusText.text = "Status: Connecting..." + statusText.setTextColor(resources.getColor(android.R.color.holo_orange_dark)) + disconnectButton.isEnabled = true + } + + private fun stopVpnService() { + val intent = android.content.Intent(this, CustomVpnService::class.java) + stopService(intent) + statusText.text = "Status: Disconnected" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + disconnectButton.isEnabled = false + } + + fun addLog(message: String) { + runOnUiThread { + logText.append("\n$message") + val scrollAmount = logText.layout?.getLineTop(logText.lineCount) ?: 0 + if (scrollAmount > logText.height) { + logText.scrollTo(0, scrollAmount - logText.height) + } + } + } +} From 6c237a36a1f2ca774e736a809127cb95c92cde89 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:02:19 +0300 Subject: [PATCH 006/366] Create CustomVpnService.kt --- .../com/example/sshproxy/CustomVpnService.kt | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 app/src/main/java/com/example/sshproxy/CustomVpnService.kt diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt new file mode 100644 index 0000000..74d97b7 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -0,0 +1,214 @@ +package com.example.sshproxy + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import androidx.core.app.NotificationCompat +import com.example.sshproxy.payload.PayloadProcessor +import com.jcraft.jsch.JSch +import com.jcraft.jsch.Session +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.Socket + +class CustomVpnService : VpnService() { + + companion object { + private const val CHANNEL_ID = "vpn_channel" + private const val NOTIFICATION_ID = 1 + } + + private var sshSession: Session? = null + private var tunnelSocket: Socket? = null + private var vpnInterface: ParcelFileDescriptor? = null + private var isConnected = false + + private var sshHost: String = "" + private var sshPort: String = "" + private var sshUser: String = "" + private var sshPass: String = "" + private var proxyHost: String = "" + private var proxyPort: String = "" + private var payload: String = "" + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.let { + sshHost = it.getStringExtra("sshHost") ?: "" + sshPort = it.getStringExtra("sshPort") ?: "" + sshUser = it.getStringExtra("sshUser") ?: "" + sshPass = it.getStringExtra("sshPass") ?: "" + proxyHost = it.getStringExtra("proxyHost") ?: "" + proxyPort = it.getStringExtra("proxyPort") ?: "" + payload = it.getStringExtra("payload") ?: "" + } + + if (sshHost.isEmpty() || sshPort.isEmpty() || sshUser.isEmpty() || sshPass.isEmpty()) { + stopSelf() + return START_NOT_STICKY + } + + createNotificationChannel() + showNotification("Connecting...") + + Thread { + connectToServer() + }.start() + + return START_STICKY + } + + private fun connectToServer() { + try { + // 1. Process the payload + val processedPayload = PayloadProcessor.processPayload( + payload, + sshHost, + sshPort, + if (proxyHost.isNotEmpty()) "$proxyHost:$proxyPort" else "", + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36" + ) + + addLog("[1] Payload processed") + + // 2. Connect through proxy + val proxyAddress = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + addLog("[2] Connecting via proxy: $proxyHost:$proxyPort") + proxyHost + } else { + addLog("[2] Connecting directly to: $sshHost:$sshPort") + sshHost + } + val proxyPortNumber = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + proxyPort.toInt() + } else { + sshPort.toInt() + } + + tunnelSocket = Socket(proxyAddress, proxyPortNumber) + addLog("[3] Socket connected") + + // 3. Send payload + tunnelSocket?.getOutputStream()?.write(processedPayload.toByteArray()) + tunnelSocket?.getOutputStream()?.flush() + addLog("[4] Payload sent") + + // 4. Read response + val reader = BufferedReader(InputStreamReader(tunnelSocket?.getInputStream())) + val responseLine = reader.readLine() + addLog("[5] Response: $responseLine") + + if (responseLine != null && (responseLine.contains("200 OK") || responseLine.contains("101 Switching Protocols"))) { + addLog("[6] Payload accepted! Establishing SSH...") + establishSSH() + } else { + addLog("[ERROR] Payload rejected: $responseLine") + showNotification("Connection failed") + stopSelf() + } + + } catch (e: Exception) { + addLog("[ERROR] ${e.message}") + e.printStackTrace() + showNotification("Error: ${e.message}") + stopSelf() + } + } + + private fun establishSSH() { + try { + val jsch = JSch() + sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) + sshSession?.password = sshPass + sshSession?.setConfig("StrictHostKeyChecking", "no") + + // Use the existing socket + sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { + override fun createSocket(host: String?, port: Int): Socket { + return tunnelSocket ?: Socket(host, port) + } + + override fun getInputStream(socket: Socket): java.io.InputStream { + return socket.getInputStream() + } + + override fun getOutputStream(socket: Socket): java.io.OutputStream { + return socket.getOutputStream() + } + }) + + sshSession?.connect(15000) + addLog("[7] SSH connected successfully!") + + isConnected = true + showNotification("Connected ✓") + + // Setup VPN + setupVpn() + + } catch (e: Exception) { + addLog("[ERROR] SSH failed: ${e.message}") + e.printStackTrace() + showNotification("SSH failed") + stopSelf() + } + } + + private fun setupVpn() { + try { + vpnInterface = Builder() + .setAddresses("10.0.0.2", 32) + .addRoute("0.0.0.0", 0) + .setSession("HTTP Custom Clone") + .establish() + + addLog("[8] VPN ready! Tunnel is live.") + showNotification("Connected ✓") + + // Keep service alive + while (isConnected) { + Thread.sleep(1000) + } + + } catch (e: Exception) { + addLog("[ERROR] VPN setup failed: ${e.message}") + e.printStackTrace() + showNotification("VPN failed") + stopSelf() + } + } + + private fun addLog(message: String) { + android.util.Log.d("CustomVpnService", message) + } + + private fun showNotification(message: String) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("HTTP Custom Clone") + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_menu_share) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + override fun onDestroy() { + super.onDestroy() + isConnected = false + sshSession?.disconnect() + tunnelSocket?.close() + vpnInterface?.close() + stopForeground(true) + } +} From 80efb6075a69d18d8199c850d4d51ec9956d89c9 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:06:45 +0300 Subject: [PATCH 007/366] Update CustomVpnService.kt --- .../com/example/sshproxy/CustomVpnService.kt | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt index 74d97b7..2db4057 100644 --- a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -119,6 +119,160 @@ class CustomVpnService : VpnService() { } } + private fun establishSSH() { + try { + val jsch = JSch() + sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) + + // FIX 1 & 2: Use setPassword() instead of direct assignment + sshSession?.setPassword(sshPass) + + sshSession?.setConfig("StrictHostKeyChecking", "no") + + // Use the existing socket + sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { + override fun createSocket(host: String?, port: Int): Socket { + return tunnelSocket ?: Socket(host, port) + } + + override fun getInputStream(socket: Socket): java.io.InputStream { + return socket.getInputStream() + } + + override fun getOutputStream(socket: Socket): java.io.OutputStream { + return socket.getOutputStream() + } + }) + + sshSession?.connect(15000) + addLog("[7] SSH connected successfully!") + + isConnected = true + showNotification("Connected ✓") + + // Setup VPN + setupVpn() + + } catch (e: Exception) { + addLog("[ERROR] SSH failed: ${e.message}") + e.printStackTrace() + showNotification("SSH failed") + stopSelf() + } + } + + private fun setupVpn() { + try { + // FIX 3: Use addAddress() instead of setAddresses() + vpnInterface = Builder() + .addAddress("10.0.0.2", 32) + .addRoute("0.0.0.0", 0) + .setSession("HTTP Custom Clone") + .establish() + + addLog("[8] VPN ready! Tunnel is live.") + showNotification("Connected ✓") + + // Keep service alive + while (isConnected) { + Thread.sleep(1000) + } + + } catch (e: Exception) { + addLog("[ERROR] VPN setup failed: ${e.message}") + e.printStackTrace() + showNotification("VPN failed") + stopSelf() + } + } + + private fun addLog(message: String) { + android.util.Log.d("CustomVpnService", message) + } + + private fun showNotification(message: String) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("HTTP Custom Clone") + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_menu_share) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + override fun onDestroy() { + super.onDestroy() + isConnected = false + sshSession?.disconnect() + tunnelSocket?.close() + vpnInterface?.close() + stopForeground(true) + } +} + private fun connectToServer() { + try { + // 1. Process the payload + val processedPayload = PayloadProcessor.processPayload( + payload, + sshHost, + sshPort, + if (proxyHost.isNotEmpty()) "$proxyHost:$proxyPort" else "", + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36" + ) + + addLog("[1] Payload processed") + + // 2. Connect through proxy + val proxyAddress = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + addLog("[2] Connecting via proxy: $proxyHost:$proxyPort") + proxyHost + } else { + addLog("[2] Connecting directly to: $sshHost:$sshPort") + sshHost + } + val proxyPortNumber = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + proxyPort.toInt() + } else { + sshPort.toInt() + } + + tunnelSocket = Socket(proxyAddress, proxyPortNumber) + addLog("[3] Socket connected") + + // 3. Send payload + tunnelSocket?.getOutputStream()?.write(processedPayload.toByteArray()) + tunnelSocket?.getOutputStream()?.flush() + addLog("[4] Payload sent") + + // 4. Read response + val reader = BufferedReader(InputStreamReader(tunnelSocket?.getInputStream())) + val responseLine = reader.readLine() + addLog("[5] Response: $responseLine") + + if (responseLine != null && (responseLine.contains("200 OK") || responseLine.contains("101 Switching Protocols"))) { + addLog("[6] Payload accepted! Establishing SSH...") + establishSSH() + } else { + addLog("[ERROR] Payload rejected: $responseLine") + showNotification("Connection failed") + stopSelf() + } + + } catch (e: Exception) { + addLog("[ERROR] ${e.message}") + e.printStackTrace() + showNotification("Error: ${e.message}") + stopSelf() + } + } + private fun establishSSH() { try { val jsch = JSch() From 475cbc79d0a1ec61040b30193d5947ccc0019629 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:11:04 +0300 Subject: [PATCH 008/366] Update CustomVpnService.kt --- .../com/example/sshproxy/CustomVpnService.kt | 76 +------------------ 1 file changed, 2 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt index 2db4057..9c0771e 100644 --- a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -124,7 +124,7 @@ class CustomVpnService : VpnService() { val jsch = JSch() sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) - // FIX 1 & 2: Use setPassword() instead of direct assignment + // Use setPassword() instead of direct assignment sshSession?.setPassword(sshPass) sshSession?.setConfig("StrictHostKeyChecking", "no") @@ -163,7 +163,6 @@ class CustomVpnService : VpnService() { private fun setupVpn() { try { - // FIX 3: Use addAddress() instead of setAddresses() vpnInterface = Builder() .addAddress("10.0.0.2", 32) .addRoute("0.0.0.0", 0) @@ -215,78 +214,7 @@ class CustomVpnService : VpnService() { vpnInterface?.close() stopForeground(true) } -} - private fun connectToServer() { - try { - // 1. Process the payload - val processedPayload = PayloadProcessor.processPayload( - payload, - sshHost, - sshPort, - if (proxyHost.isNotEmpty()) "$proxyHost:$proxyPort" else "", - "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36" - ) - - addLog("[1] Payload processed") - - // 2. Connect through proxy - val proxyAddress = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { - addLog("[2] Connecting via proxy: $proxyHost:$proxyPort") - proxyHost - } else { - addLog("[2] Connecting directly to: $sshHost:$sshPort") - sshHost - } - val proxyPortNumber = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { - proxyPort.toInt() - } else { - sshPort.toInt() - } - - tunnelSocket = Socket(proxyAddress, proxyPortNumber) - addLog("[3] Socket connected") - - // 3. Send payload - tunnelSocket?.getOutputStream()?.write(processedPayload.toByteArray()) - tunnelSocket?.getOutputStream()?.flush() - addLog("[4] Payload sent") - - // 4. Read response - val reader = BufferedReader(InputStreamReader(tunnelSocket?.getInputStream())) - val responseLine = reader.readLine() - addLog("[5] Response: $responseLine") - - if (responseLine != null && (responseLine.contains("200 OK") || responseLine.contains("101 Switching Protocols"))) { - addLog("[6] Payload accepted! Establishing SSH...") - establishSSH() - } else { - addLog("[ERROR] Payload rejected: $responseLine") - showNotification("Connection failed") - stopSelf() - } - - } catch (e: Exception) { - addLog("[ERROR] ${e.message}") - e.printStackTrace() - showNotification("Error: ${e.message}") - stopSelf() - } - } - - private fun establishSSH() { - try { - val jsch = JSch() - sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) - sshSession?.password = sshPass - sshSession?.setConfig("StrictHostKeyChecking", "no") - - // Use the existing socket - sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { - override fun createSocket(host: String?, port: Int): Socket { - return tunnelSocket ?: Socket(host, port) - } - - override fun getInputStream(socket: Socket): java.io.InputStream { +} // <-- Only ONE closing brace here override fun getInputStream(socket: Socket): java.io.InputStream { return socket.getInputStream() } From 0e025e336e15cdf67638354e319639fc6186ee9d Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:16:35 +0300 Subject: [PATCH 009/366] Update CustomVpnService.kt --- .../com/example/sshproxy/CustomVpnService.kt | 154 +++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt index 9c0771e..0a8ab0a 100644 --- a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -3,7 +3,6 @@ package com.example.sshproxy import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager -import android.app.PendingIntent import android.content.Intent import android.net.VpnService import android.os.Build @@ -206,6 +205,159 @@ class CustomVpnService : VpnService() { } } + override fun onDestroy() { + super.onDestroy() + isConnected = false + sshSession?.disconnect() + tunnelSocket?.close() + vpnInterface?.close() + stopForeground(true) + } +} + private fun connectToServer() { + try { + // 1. Process the payload + val processedPayload = PayloadProcessor.processPayload( + payload, + sshHost, + sshPort, + if (proxyHost.isNotEmpty()) "$proxyHost:$proxyPort" else "", + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36" + ) + + addLog("[1] Payload processed") + + // 2. Connect through proxy + val proxyAddress = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + addLog("[2] Connecting via proxy: $proxyHost:$proxyPort") + proxyHost + } else { + addLog("[2] Connecting directly to: $sshHost:$sshPort") + sshHost + } + val proxyPortNumber = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { + proxyPort.toInt() + } else { + sshPort.toInt() + } + + tunnelSocket = Socket(proxyAddress, proxyPortNumber) + addLog("[3] Socket connected") + + // 3. Send payload + tunnelSocket?.getOutputStream()?.write(processedPayload.toByteArray()) + tunnelSocket?.getOutputStream()?.flush() + addLog("[4] Payload sent") + + // 4. Read response + val reader = BufferedReader(InputStreamReader(tunnelSocket?.getInputStream())) + val responseLine = reader.readLine() + addLog("[5] Response: $responseLine") + + if (responseLine != null && (responseLine.contains("200 OK") || responseLine.contains("101 Switching Protocols"))) { + addLog("[6] Payload accepted! Establishing SSH...") + establishSSH() + } else { + addLog("[ERROR] Payload rejected: $responseLine") + showNotification("Connection failed") + stopSelf() + } + + } catch (e: Exception) { + addLog("[ERROR] ${e.message}") + e.printStackTrace() + showNotification("Error: ${e.message}") + stopSelf() + } + } + + private fun establishSSH() { + try { + val jsch = JSch() + sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) + + // Use setPassword() instead of direct assignment + sshSession?.setPassword(sshPass) + + sshSession?.setConfig("StrictHostKeyChecking", "no") + + // Use the existing socket + sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { + override fun createSocket(host: String?, port: Int): Socket { + return tunnelSocket ?: Socket(host, port) + } + + override fun getInputStream(socket: Socket): java.io.InputStream { + return socket.getInputStream() + } + + override fun getOutputStream(socket: Socket): java.io.OutputStream { + return socket.getOutputStream() + } + }) + + sshSession?.connect(15000) + addLog("[7] SSH connected successfully!") + + isConnected = true + showNotification("Connected ✓") + + // Setup VPN + setupVpn() + + } catch (e: Exception) { + addLog("[ERROR] SSH failed: ${e.message}") + e.printStackTrace() + showNotification("SSH failed") + stopSelf() + } + } + + private fun setupVpn() { + try { + vpnInterface = Builder() + .addAddress("10.0.0.2", 32) + .addRoute("0.0.0.0", 0) + .setSession("HTTP Custom Clone") + .establish() + + addLog("[8] VPN ready! Tunnel is live.") + showNotification("Connected ✓") + + // Keep service alive + while (isConnected) { + Thread.sleep(1000) + } + + } catch (e: Exception) { + addLog("[ERROR] VPN setup failed: ${e.message}") + e.printStackTrace() + showNotification("VPN failed") + stopSelf() + } + } + + private fun addLog(message: String) { + android.util.Log.d("CustomVpnService", message) + } + + private fun showNotification(message: String) { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("HTTP Custom Clone") + .setContentText(message) + .setSmallIcon(android.R.drawable.ic_menu_share) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + startForeground(NOTIFICATION_ID, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + override fun onDestroy() { super.onDestroy() isConnected = false From 3a0b0992125631b672b6fab66f55ea181290c3dd Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:21:25 +0300 Subject: [PATCH 010/366] Update CustomVpnService.kt --- .../com/example/sshproxy/CustomVpnService.kt | 239 ------------------ 1 file changed, 239 deletions(-) diff --git a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt index 0a8ab0a..df096b9 100644 --- a/app/src/main/java/com/example/sshproxy/CustomVpnService.kt +++ b/app/src/main/java/com/example/sshproxy/CustomVpnService.kt @@ -122,13 +122,9 @@ class CustomVpnService : VpnService() { try { val jsch = JSch() sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) - - // Use setPassword() instead of direct assignment sshSession?.setPassword(sshPass) - sshSession?.setConfig("StrictHostKeyChecking", "no") - // Use the existing socket sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { override fun createSocket(host: String?, port: Int): Socket { return tunnelSocket ?: Socket(host, port) @@ -148,8 +144,6 @@ class CustomVpnService : VpnService() { isConnected = true showNotification("Connected ✓") - - // Setup VPN setupVpn() } catch (e: Exception) { @@ -171,239 +165,6 @@ class CustomVpnService : VpnService() { addLog("[8] VPN ready! Tunnel is live.") showNotification("Connected ✓") - // Keep service alive - while (isConnected) { - Thread.sleep(1000) - } - - } catch (e: Exception) { - addLog("[ERROR] VPN setup failed: ${e.message}") - e.printStackTrace() - showNotification("VPN failed") - stopSelf() - } - } - - private fun addLog(message: String) { - android.util.Log.d("CustomVpnService", message) - } - - private fun showNotification(message: String) { - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("HTTP Custom Clone") - .setContentText(message) - .setSmallIcon(android.R.drawable.ic_menu_share) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - startForeground(NOTIFICATION_ID, notification) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - } - - override fun onDestroy() { - super.onDestroy() - isConnected = false - sshSession?.disconnect() - tunnelSocket?.close() - vpnInterface?.close() - stopForeground(true) - } -} - private fun connectToServer() { - try { - // 1. Process the payload - val processedPayload = PayloadProcessor.processPayload( - payload, - sshHost, - sshPort, - if (proxyHost.isNotEmpty()) "$proxyHost:$proxyPort" else "", - "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36" - ) - - addLog("[1] Payload processed") - - // 2. Connect through proxy - val proxyAddress = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { - addLog("[2] Connecting via proxy: $proxyHost:$proxyPort") - proxyHost - } else { - addLog("[2] Connecting directly to: $sshHost:$sshPort") - sshHost - } - val proxyPortNumber = if (proxyHost.isNotEmpty() && proxyPort.isNotEmpty()) { - proxyPort.toInt() - } else { - sshPort.toInt() - } - - tunnelSocket = Socket(proxyAddress, proxyPortNumber) - addLog("[3] Socket connected") - - // 3. Send payload - tunnelSocket?.getOutputStream()?.write(processedPayload.toByteArray()) - tunnelSocket?.getOutputStream()?.flush() - addLog("[4] Payload sent") - - // 4. Read response - val reader = BufferedReader(InputStreamReader(tunnelSocket?.getInputStream())) - val responseLine = reader.readLine() - addLog("[5] Response: $responseLine") - - if (responseLine != null && (responseLine.contains("200 OK") || responseLine.contains("101 Switching Protocols"))) { - addLog("[6] Payload accepted! Establishing SSH...") - establishSSH() - } else { - addLog("[ERROR] Payload rejected: $responseLine") - showNotification("Connection failed") - stopSelf() - } - - } catch (e: Exception) { - addLog("[ERROR] ${e.message}") - e.printStackTrace() - showNotification("Error: ${e.message}") - stopSelf() - } - } - - private fun establishSSH() { - try { - val jsch = JSch() - sshSession = jsch.getSession(sshUser, sshHost, sshPort.toInt()) - - // Use setPassword() instead of direct assignment - sshSession?.setPassword(sshPass) - - sshSession?.setConfig("StrictHostKeyChecking", "no") - - // Use the existing socket - sshSession?.setSocketFactory(object : com.jcraft.jsch.SocketFactory { - override fun createSocket(host: String?, port: Int): Socket { - return tunnelSocket ?: Socket(host, port) - } - - override fun getInputStream(socket: Socket): java.io.InputStream { - return socket.getInputStream() - } - - override fun getOutputStream(socket: Socket): java.io.OutputStream { - return socket.getOutputStream() - } - }) - - sshSession?.connect(15000) - addLog("[7] SSH connected successfully!") - - isConnected = true - showNotification("Connected ✓") - - // Setup VPN - setupVpn() - - } catch (e: Exception) { - addLog("[ERROR] SSH failed: ${e.message}") - e.printStackTrace() - showNotification("SSH failed") - stopSelf() - } - } - - private fun setupVpn() { - try { - vpnInterface = Builder() - .addAddress("10.0.0.2", 32) - .addRoute("0.0.0.0", 0) - .setSession("HTTP Custom Clone") - .establish() - - addLog("[8] VPN ready! Tunnel is live.") - showNotification("Connected ✓") - - // Keep service alive - while (isConnected) { - Thread.sleep(1000) - } - - } catch (e: Exception) { - addLog("[ERROR] VPN setup failed: ${e.message}") - e.printStackTrace() - showNotification("VPN failed") - stopSelf() - } - } - - private fun addLog(message: String) { - android.util.Log.d("CustomVpnService", message) - } - - private fun showNotification(message: String) { - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("HTTP Custom Clone") - .setContentText(message) - .setSmallIcon(android.R.drawable.ic_menu_share) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - startForeground(NOTIFICATION_ID, notification) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(CHANNEL_ID, "VPN", NotificationManager.IMPORTANCE_LOW) - getSystemService(NotificationManager::class.java).createNotificationChannel(channel) - } - } - - override fun onDestroy() { - super.onDestroy() - isConnected = false - sshSession?.disconnect() - tunnelSocket?.close() - vpnInterface?.close() - stopForeground(true) - } -} // <-- Only ONE closing brace here override fun getInputStream(socket: Socket): java.io.InputStream { - return socket.getInputStream() - } - - override fun getOutputStream(socket: Socket): java.io.OutputStream { - return socket.getOutputStream() - } - }) - - sshSession?.connect(15000) - addLog("[7] SSH connected successfully!") - - isConnected = true - showNotification("Connected ✓") - - // Setup VPN - setupVpn() - - } catch (e: Exception) { - addLog("[ERROR] SSH failed: ${e.message}") - e.printStackTrace() - showNotification("SSH failed") - stopSelf() - } - } - - private fun setupVpn() { - try { - vpnInterface = Builder() - .setAddresses("10.0.0.2", 32) - .addRoute("0.0.0.0", 0) - .setSession("HTTP Custom Clone") - .establish() - - addLog("[8] VPN ready! Tunnel is live.") - showNotification("Connected ✓") - - // Keep service alive while (isConnected) { Thread.sleep(1000) } From d50929554c1797518bae7e57254e8b7d62d06cec Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:50:39 +0300 Subject: [PATCH 011/366] Update AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 53 ++++++++------------------------ 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dda0fc..a05f7cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,69 +4,40 @@ - - - - - - - + android:theme="@style/Theme.AppCompat.Light.DarkActionBar" + android:usesCleartextTraffic="true" + tools:targetApi="31"> + + + android:launchMode="singleTop"> - - - - + + android:exported="false" + android:foregroundServiceType="dataSync"> - - - - - - - - - + From de096ff62d3abe243654a5090f666dc7726c5e15 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:55:37 +0300 Subject: [PATCH 012/366] Delete app/src/main/java/com/example/sshproxy/MainActivity.kt --- .../java/com/example/sshproxy/MainActivity.kt | 268 ------------------ 1 file changed, 268 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/MainActivity.kt diff --git a/app/src/main/java/com/example/sshproxy/MainActivity.kt b/app/src/main/java/com/example/sshproxy/MainActivity.kt deleted file mode 100644 index 2618e24..0000000 --- a/app/src/main/java/com/example/sshproxy/MainActivity.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.example.sshproxy - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import com.example.sshproxy.databinding.ActivityMainNewBinding -import com.example.sshproxy.ui.home.HomeFragment -import com.example.sshproxy.ui.keys.KeysFragment -import com.example.sshproxy.ui.servers.ServersFragment -import com.example.sshproxy.ui.settings.SettingsFragment -import com.example.sshproxy.ui.setup.* -import com.example.sshproxy.ui.dialogs.HostKeyChangeDialog -import com.example.sshproxy.security.SecurityNotificationManager -import com.google.android.material.navigation.NavigationBarView - -import com.example.sshproxy.data.PreferencesManager -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import android.app.Activity -import android.content.Intent -import android.net.VpnService -import android.os.Build -import androidx.activity.result.contract.ActivityResultContracts -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.ServerRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainNewBinding - private lateinit var setupManager: SetupManager - - override fun onCreate(savedInstanceState: Bundle?) { - val preferencesManager = PreferencesManager(this) - val theme = preferencesManager.getTheme() - - android.util.Log.d("MainActivity", "onCreate - Current theme: $theme") - - // Применяем тему ПЕРЕД вызовом super.onCreate() - applyTheme(theme) - - super.onCreate(savedInstanceState) - - applyLanguage(preferencesManager.getLanguage()) - - binding = ActivityMainNewBinding.inflate(layoutInflater) - setContentView(binding.root) - - setupManager = SetupManager(this) - - // Auto-select server if there is only one - lifecycleScope.launch { - val serverRepository = ServerRepository(this@MainActivity) - val servers = serverRepository.getAllServers().first() - if (servers.size == 1) { - preferencesManager.setActiveServerId(servers.first().id) - } - } - - // Handle security alerts from notifications - handleSecurityIntent() - - // Check if first launch - if (setupManager.isFirstLaunch()) { - startSetupFlow() - } else { - setupNavigation() - } - } - - private fun applyTheme(theme: String) { - android.util.Log.d("MainActivity", "Applying theme: $theme") - when (theme) { - "light" -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_NO") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - "dark" -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_YES") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - else -> { - android.util.Log.d("MainActivity", "Setting MODE_NIGHT_FOLLOW_SYSTEM") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } - } - - private fun applyLanguage(language: String) { - val localeList = if (language == "system") { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(language) - } - AppCompatDelegate.setApplicationLocales(localeList) - } - - private fun setupNavigation() { - binding.bottomNavigation.setOnItemSelectedListener(NavigationBarView.OnItemSelectedListener { item -> - when (item.itemId) { - R.id.navigation_home -> { - loadFragment(HomeFragment()) - true - } - R.id.navigation_servers -> { - loadFragment(ServersFragment()) - true - } - R.id.navigation_keys -> { - loadFragment(KeysFragment()) - true - } - R.id.navigation_settings -> { - loadFragment(SettingsFragment()) - true - } - else -> false - } - }) - - // Load home fragment by default - if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { - binding.bottomNavigation.selectedItemId = R.id.navigation_home - } - } - - - private fun loadFragment(fragment: Fragment) { - supportFragmentManager.beginTransaction() - .replace(R.id.fragmentContainer, fragment) - .commit() - } - - fun onSetupComplete() { - setupNavigation() - } - - private fun startSetupFlow() { - // Hide bottom navigation during setup - binding.bottomNavigation.visibility = android.view.View.GONE - loadFragment(WelcomeFragment()) - } - - fun navigateToKeySetup() { - loadFragment(KeyChoiceFragment()) - } - - fun navigateToKeyGeneration() { - loadFragment(KeyGenerationFragment()) - } - - - fun navigateToAddServer() { - loadFragment(AddFirstServerFragment()) - } - - fun completeSetup() { - setupManager.completeSetup() - // Show bottom navigation and go to main app - binding.bottomNavigation.visibility = android.view.View.VISIBLE - setupNavigation() - // Load the home fragment immediately after setup - loadFragment(HomeFragment()) - // Set the home item as selected in bottom navigation - binding.bottomNavigation.selectedItemId = R.id.navigation_home - } - - fun showSetupFlow() { - loadFragment(KeySetupFragment()) - } - - fun navigateToHome() { - setupNavigation() - } - - private fun handleSecurityIntent() { - val showSecurityAlert = intent.getBooleanExtra("show_security_alert", false) - if (showSecurityAlert) { - val hostname = intent.getStringExtra("hostname") ?: return - val port = intent.getIntExtra("port", 22) - - // Clear the security notification since user opened the app - SecurityNotificationManager(this).clearSecurityNotifications() - - // Show the host key change dialog - // For now, we'll just log it since we need the actual fingerprints - android.util.Log.d("MainActivity", "Host key change detected for $hostname:$port") - // TODO: Get actual fingerprints and show HostKeyChangeDialog - } - } - - private val vpnPermissionLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == Activity.RESULT_OK) { - // VPN permission granted, start VPN - startVpnFromWidget() - } else { - // VPN permission denied - android.util.Log.d("MainActivity", "VPN permission denied") - } - } - - private fun handleVpnStartIntent() { - android.util.Log.d("MainActivity", "handleVpnStartIntent: action=${intent?.action}") - if (intent?.action == "com.example.sshproxy.ACTION_START_VPN_FROM_WIDGET") { - val serverId = intent.getLongExtra("server_id", -1) - android.util.Log.d("MainActivity", "handleVpnStartIntent: serverId=$serverId") - if (serverId != -1L) { - // Save server ID for later use - getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE).edit() - .putLong("pending_widget_server_id", serverId) - .apply() - - // Check if VPN permission is needed - val vpnIntent = VpnService.prepare(this) - if (vpnIntent != null) { - // Permission needed - vpnPermissionLauncher.launch(vpnIntent) - } else { - // Permission already granted - startVpnFromWidget() - } - } - } - } - - private fun startVpnFromWidget() { - android.util.Log.d("MainActivity", "startVpnFromWidget called") - val prefs = getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE) - val serverId = prefs.getLong("pending_widget_server_id", -1) - android.util.Log.d("MainActivity", "startVpnFromWidget: serverId=$serverId") - - if (serverId != -1L) { - // Clear pending server ID and set connecting state - prefs.edit() - .remove("pending_widget_server_id") - .putBoolean("vpn_connecting", true) - .apply() - - // Update widget to show connecting state - val updateIntent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - updateIntent.setPackage(packageName) - sendBroadcast(updateIntent) - - // Start VPN service - val serviceIntent = Intent(this, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, serverId) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } - - // Close activity to return to previous app - finish() - } - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - setIntent(intent) - handleVpnStartIntent() - } -} \ No newline at end of file From 245ccf1c08c75de6e8d8277365fd8ac2a67a3060 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:57:37 +0300 Subject: [PATCH 013/366] Delete app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt --- .../example/sshproxy/VpnPermissionActivity.kt | 93 ------------------- 1 file changed, 93 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt diff --git a/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt b/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt deleted file mode 100644 index be50716..0000000 --- a/app/src/main/java/com/example/sshproxy/VpnPermissionActivity.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.example.sshproxy - -import android.app.Activity -import android.content.Intent -import android.net.VpnService -import android.os.Build -import android.os.Bundle - -class VpnPermissionActivity : Activity() { - - companion object { - private const val VPN_REQUEST_CODE = 1 - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // No UI, just handle VPN permission - handleVpnStartIntent() - } - - private fun handleVpnStartIntent() { - val serverId = intent.getLongExtra("server_id", -1) - android.util.Log.d("VpnPermissionActivity", "handleVpnStartIntent: serverId=$serverId") - - if (serverId != -1L) { - // Save server ID for later use - getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE).edit() - .putLong("pending_widget_server_id", serverId) - .apply() - - // Check if VPN permission is needed - val vpnIntent = VpnService.prepare(this) - if (vpnIntent != null) { - // Permission needed - startActivityForResult(vpnIntent, VPN_REQUEST_CODE) - } else { - // Permission already granted - startVpnFromWidget() - finish() - } - } else { - finish() - } - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - - if (requestCode == VPN_REQUEST_CODE) { - if (resultCode == RESULT_OK) { - // VPN permission granted, start VPN - startVpnFromWidget() - } else { - // VPN permission denied - android.util.Log.d("VpnPermissionActivity", "VPN permission denied") - } - finish() - } - } - - private fun startVpnFromWidget() { - android.util.Log.d("VpnPermissionActivity", "startVpnFromWidget called") - val prefs = getSharedPreferences("ssh_proxy_prefs", MODE_PRIVATE) - val serverId = prefs.getLong("pending_widget_server_id", -1) - android.util.Log.d("VpnPermissionActivity", "startVpnFromWidget: serverId=$serverId") - - if (serverId != -1L) { - // Clear pending server ID and set connecting state - prefs.edit() - .remove("pending_widget_server_id") - .putBoolean("vpn_connecting", true) - .apply() - - // Update widget to show connecting state - val updateIntent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - updateIntent.setPackage(packageName) - sendBroadcast(updateIntent) - - // Start VPN service - val serviceIntent = Intent(this, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, serverId) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(serviceIntent) - } else { - startService(serviceIntent) - } - } - } -} \ No newline at end of file From 593571f97f84436c56b34432adc7c735ed6ea9a9 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 19:59:06 +0300 Subject: [PATCH 014/366] Delete app/src/main/res/layout/activity_main.xml --- app/src/main/res/layout/activity_main.xml | 323 ---------------------- 1 file changed, 323 deletions(-) delete mode 100644 app/src/main/res/layout/activity_main.xml diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 41dcc2f..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,323 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 89b1a631cf594f6f033d6fe2c57a1942029e7e42 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:03:56 +0300 Subject: [PATCH 015/366] Delete app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt --- .../security/SecurityNotificationManager.kt | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt diff --git a/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt b/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt deleted file mode 100644 index 53a2d96..0000000 --- a/app/src/main/java/com/example/sshproxy/security/SecurityNotificationManager.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.example.sshproxy.security - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationCompat -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R - -/** - * Manages security-related notifications for host key changes and other critical events - */ -class SecurityNotificationManager(private val context: Context) { - - companion object { - private const val SECURITY_CHANNEL_ID = "security_alerts" - private const val HOST_KEY_CHANGE_ID = 1001 - } - - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - init { - createSecurityChannel() - } - - /** - * Show notification about host key change that requires user attention - */ - fun showHostKeyChangeNotification(hostname: String, port: Int) { - val hostDisplay = if (port == 22) hostname else "$hostname:$port" - - val intent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - putExtra("show_security_alert", true) - putExtra("hostname", hostname) - putExtra("port", port) - } - - val pendingIntent = PendingIntent.getActivity( - context, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notification = NotificationCompat.Builder(context, SECURITY_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_dialog_alert) - .setContentTitle(context.getString(R.string.security_warning)) - .setContentText(context.getString(R.string.host_key_changed_notification, hostDisplay)) - .setStyle(NotificationCompat.BigTextStyle() - .bigText(context.getString(R.string.host_key_changed_notification_detail, hostDisplay))) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_ALARM) - .setAutoCancel(true) - .setContentIntent(pendingIntent) - .build() - - notificationManager.notify(HOST_KEY_CHANGE_ID, notification) - } - - /** - * Clear security notifications - */ - fun clearSecurityNotifications() { - notificationManager.cancel(HOST_KEY_CHANGE_ID) - } - - private fun createSecurityChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - SECURITY_CHANNEL_ID, - context.getString(R.string.security_alerts_channel), - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = context.getString(R.string.security_alerts_channel_description) - enableLights(true) - enableVibration(true) - } - - notificationManager.createNotificationChannel(channel) - } - } -} \ No newline at end of file From d4defb21fb65ec1f83b9076abbfee08a2939fcc5 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:04:37 +0300 Subject: [PATCH 016/366] Delete app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt --- .../ui/setup/AddFirstServerFragment.kt | 141 ------------------ 1 file changed, 141 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt deleted file mode 100644 index 071e83f..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/AddFirstServerFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.FragmentAddFirstServerBinding -import kotlinx.coroutines.launch - -class AddFirstServerFragment : Fragment() { - private var _binding: FragmentAddFirstServerBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentAddFirstServerBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupUI() - } - - private fun setupUI() { - // Auto-fill name from host with URL cleanup - binding.etHost.doAfterTextChanged { text -> - if (!text.isNullOrEmpty()) { - val cleanHost = cleanUrl(text.toString()) - - // Update host field if URL was cleaned - if (cleanHost != text.toString()) { - binding.etHost.setText(cleanHost) - binding.etHost.setSelection(cleanHost.length) // Move cursor to end - } - - // Auto-fill name only if it's empty - if (binding.etName.text.isNullOrEmpty()) { - binding.etName.setText(cleanHost) - } - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - binding.btnAddServer.setOnClickListener { - addServer() - } - - binding.btnSkip.setOnClickListener { - completeSetup() - } - } - - private fun addServer() { - val rawHost = binding.etHost.text.toString().trim() - val cleanHost = cleanUrl(rawHost) - val nameText = binding.etName.text?.toString()?.trim() - val name = if (!nameText.isNullOrEmpty()) nameText else cleanHost - val user = binding.etUser.text?.toString()?.trim() ?: "user" - val port = binding.etPort.text?.toString()?.toIntOrNull() ?: 22 - - if (cleanHost.isEmpty()) { - Toast.makeText(context, "Please enter a server host", Toast.LENGTH_SHORT).show() - return - } - - lifecycleScope.launch { - try { - val serverRepository = ServerRepository(requireContext()) - val server = Server( - name = name, - host = cleanHost, - port = port, - username = user - ) - serverRepository.insertServer(server) - Toast.makeText(context, getString(com.example.sshproxy.R.string.server_added), Toast.LENGTH_SHORT).show() - (activity as? MainActivity)?.completeSetup() - } catch (e: Exception) { - Toast.makeText(context, getString(com.example.sshproxy.R.string.error_adding_server, e.message), Toast.LENGTH_LONG).show() - } - } - } - - private fun completeSetup() { - (activity as? MainActivity)?.completeSetup() - } - - private fun cleanUrl(input: String): String { - var cleaned = input.trim() - - // Remove common URL prefixes - val prefixes = listOf("http://", "https://", "ftp://", "ftps://") - for (prefix in prefixes) { - if (cleaned.startsWith(prefix, ignoreCase = true)) { - cleaned = cleaned.substring(prefix.length) - break - } - } - - // Remove trailing slashes and paths - val slashIndex = cleaned.indexOf('/') - if (slashIndex != -1) { - cleaned = cleaned.substring(0, slashIndex) - } - - // Remove port from display (user can set it separately) - val colonIndex = cleaned.lastIndexOf(':') - if (colonIndex != -1) { - // Check if what follows the colon is a number (port) - val afterColon = cleaned.substring(colonIndex + 1) - if (afterColon.toIntOrNull() != null) { - cleaned = cleaned.substring(0, colonIndex) - } - } - - return cleaned - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} From 71a299332163edbbe1b42f73f3515c335d012205 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:05:40 +0300 Subject: [PATCH 017/366] Delete app/src/main/java/com/example/sshproxy/ui/home directory --- .../example/sshproxy/ui/home/HomeFragment.kt | 485 ------------------ .../example/sshproxy/ui/home/HomeViewModel.kt | 115 ----- .../sshproxy/ui/home/HomeViewModelFactory.kt | 19 - .../ui/home/TestConnectionBottomSheet.kt | 183 ------- 4 files changed, 802 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt deleted file mode 100644 index 55ee955..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeFragment.kt +++ /dev/null @@ -1,485 +0,0 @@ -package com.example.sshproxy.ui.home - - -import android.animation.ValueAnimator -import android.content.Intent -import android.net.VpnService -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.IpLocationService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.data.ConnectionStatus -import com.example.sshproxy.data.getDisplayStatus -import com.example.sshproxy.data.getConnectionDuration -import com.example.sshproxy.data.getPingDisplay -import com.example.sshproxy.data.getQualityColor -import com.example.sshproxy.databinding.FragmentHomeBinding -import com.example.sshproxy.network.HttpLatencyTester -import com.example.sshproxy.network.ConnectionQuality -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.HttpURLConnection -import java.net.URL - -class HomeFragment : Fragment() { - private var _binding: FragmentHomeBinding? = null - private val binding get() = _binding!! - - private val viewModel: HomeViewModel by viewModels { - HomeViewModelFactory( - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - private lateinit var keyManager: SshKeyManager - private var blinkAnimator: ValueAnimator? = null - private var durationUpdateHandler = Handler(Looper.getMainLooper()) - private var durationUpdateRunnable: Runnable? = null - - private val vpnPermissionLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - startVpnService() - } else { - Toast.makeText(context, getString(R.string.vpn_permission_denied), Toast.LENGTH_SHORT).show() - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentHomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - keyManager = SshKeyManager(requireContext(), KeyRepository(requireContext())) - - setupUI() - observeViewModel() - } - - override fun onResume() { - super.onResume() - // Resume duration updates if connected - val currentStatus = viewModel.connectionStatus.value - if (currentStatus.state == ConnectionState.CONNECTED) { - startDurationUpdates(currentStatus) - } - } - - override fun onPause() { - super.onPause() - // Pause duration updates to save battery - stopDurationUpdates() - } - - private fun setupUI() { - binding.btnConnect.setOnClickListener { - if (viewModel.isRunning.value) { - stopVpnService() - } else { - checkAndConnect() - } - } - - binding.tvSelectedServer.setOnClickListener { - showServerSelector() - } - - binding.btnTest.setOnClickListener { - testConnection() - } - - binding.btnRefreshIp.setOnClickListener { - // Force refresh when user explicitly requests it - IpLocationService.forceRefresh() - refreshIpInfo() - } - - // Показываем IP карточку и загружаем кэшированную информацию (без сетевых запросов) - binding.cardExternalIp.visibility = View.VISIBLE - loadCachedIpInfo() - } - - private fun observeViewModel() { - // Observe connection status with ping info - viewLifecycleOwner.lifecycleScope.launch { - viewModel.connectionStatus.collectLatest { status -> - if (_binding == null) return@collectLatest - - val isConnected = status.state == ConnectionState.CONNECTED - binding.btnConnect.isSelected = isConnected - - // Update connection status text - binding.tvConnectionStatus.text = status.getDisplayStatus(requireContext()) - - // Show/hide connection info card - binding.cardConnectionInfo.visibility = if (isConnected) View.VISIBLE else View.GONE - - // Update ping information - if (isConnected) { - binding.tvPing.text = status.getPingDisplay(requireContext()) ?: "--" - binding.tvQuality.text = status.connectionQuality.getDisplayName(requireContext()) - binding.tvConnectionDuration.text = status.getConnectionDuration() ?: "--" - - // Start periodic duration updates - startDurationUpdates(status) - - // Update quality indicator color - binding.viewQualityIndicator.setBackgroundTintList( - ContextCompat.getColorStateList(requireContext(), android.R.color.transparent) - ) - binding.viewQualityIndicator.setBackgroundColor(status.getQualityColor()) - - // Update ping text color based on HTTP latency (matched to quality logic) - val pingColor = when { - status.latestPing?.isSuccessful != true -> ContextCompat.getColor(requireContext(), R.color.error) - (status.latestPing.latencyMs) > 1500 -> ContextCompat.getColor(requireContext(), R.color.error) // >1500ms = Red (Poor) - (status.latestPing.latencyMs) > 1000 -> ContextCompat.getColor(requireContext(), R.color.warning) // 1000-1500ms = Orange (Fair) - (status.latestPing.latencyMs) > 600 -> ContextCompat.getColor(requireContext(), R.color.good) // 600-1000ms = Green (Good) - else -> ContextCompat.getColor(requireContext(), R.color.good) // <600ms = Green (Excellent) - } - binding.tvPing.setTextColor(pingColor) - - // Update quality text color matching ping color - binding.tvQuality.setTextColor(pingColor) - } else { - // Stop duration updates when disconnected - stopDurationUpdates() - } - - // Manage blinking animation - if (status.state == ConnectionState.CONNECTING || status.state == ConnectionState.DISCONNECTING || status.state == ConnectionState.RECONNECTING) { - startBlinking() - } else { - stopBlinking() - } - - // Update button icon - if (isConnected) { - binding.btnConnect.setIconResource(R.drawable.ic_stop) - } else { - binding.btnConnect.setIconResource(R.drawable.ic_power) - } - - // Update IP info on VPN connection state change (smart caching) - val sharedPrefs = requireActivity().getSharedPreferences("app_prefs", 0) - val lastKnownState = sharedPrefs.getString("last_connection_state", "") - val currentStateString = status.state.toString() - - if (lastKnownState != currentStateString) { - sharedPrefs.edit().putString("last_connection_state", currentStateString).apply() - - // Invalidate IP cache when VPN state changes - val isVpnConnected = status.state == ConnectionState.CONNECTED - IpLocationService.invalidateCacheOnVpnChange(isVpnConnected) - - when (status.state) { - ConnectionState.CONNECTED -> { - if (_binding != null) { - binding.tvCountryFlag.text = "⏳" - binding.tvCountryName.text = getString(R.string.vpn_connecting) - } - // Wait for VPN to stabilize, then refresh IP - viewLifecycleOwner.lifecycleScope.launch { - kotlinx.coroutines.delay(5000) - refreshIpInfo() - } - } - ConnectionState.DISCONNECTED -> { - if (_binding != null) { - binding.tvCountryFlag.text = "⏳" - binding.tvCountryName.text = getString(R.string.vpn_disconnecting) - } - // Shorter delay for disconnect, then refresh IP - viewLifecycleOwner.lifecycleScope.launch { - kotlinx.coroutines.delay(2000) - refreshIpInfo() - } - } - else -> { /* no action needed */ } - } - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.selectedServer.collectLatest { server -> - // Проверяем что binding еще валидный - if (_binding != null) { - binding.tvSelectedServer.text = server?.name ?: getString(R.string.no_server_selected) - } - } - } - } - - private fun showServerSelector() { - val servers = viewModel.servers.value - if (servers.isEmpty()) { - Toast.makeText(context, getString(R.string.no_servers_configured), Toast.LENGTH_SHORT).show() - return - } - - val serverNames = servers.map { it.name }.toTypedArray() - val currentIndex = servers.indexOfFirst { it.id == viewModel.selectedServer.value?.id }.takeIf { it >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(R.string.select_server)) - .setSingleChoiceItems(serverNames, currentIndex) { dialog, which -> - viewModel.selectServer(servers[which]) - dialog.dismiss() - } - .show() - } - - private fun checkAndConnect() { - lifecycleScope.launch { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - Toast.makeText(context, getString(R.string.requires_android_10), Toast.LENGTH_LONG).show() - return@launch - } - - if (!keyManager.hasKeyPair()) { - Toast.makeText(context, getString(R.string.no_ssh_key_configured), Toast.LENGTH_SHORT).show() - return@launch - } - - if (viewModel.selectedServer.value == null) { - Toast.makeText(context, getString(R.string.please_select_a_server), Toast.LENGTH_SHORT).show() - return@launch - } - - - val vpnIntent = VpnService.prepare(context) - if (vpnIntent != null) { - vpnPermissionLauncher.launch(vpnIntent) - } else { - startVpnService() - } - } - } - - private fun startVpnService() { - val server = viewModel.selectedServer.value ?: return - - val intent = Intent(context, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_START - putExtra(SshProxyService.EXTRA_SERVER_ID, server.id) - } - requireContext().startService(intent) - } - - private fun stopVpnService() { - val intent = Intent(context, SshProxyService::class.java).apply { - action = SshProxyService.ACTION_STOP - } - requireContext().startService(intent) - } - - private val blinkingHandler = android.os.Handler(android.os.Looper.getMainLooper()) - - private fun startBlinking() { - blinkingHandler.removeCallbacksAndMessages(null) - if (blinkAnimator?.isRunning == true) return - - // Мигание через изменение цвета между серым и белым для максимального контраста - val grayColor = ContextCompat.getColor(requireContext(), R.color.vpn_button_disconnected) - val whiteColor = ContextCompat.getColor(requireContext(), android.R.color.white) - - blinkAnimator = ValueAnimator.ofArgb(grayColor, whiteColor).apply { - duration = 400 // Быстрее для более заметного мигания - repeatCount = ValueAnimator.INFINITE - repeatMode = ValueAnimator.REVERSE - addUpdateListener { animator -> - val color = animator.animatedValue as Int - binding.btnConnect.backgroundTintList = ContextCompat.getColorStateList(requireContext(), android.R.color.transparent) - binding.btnConnect.setBackgroundColor(color) - } - start() - } - } - - private fun stopBlinking() { - blinkingHandler.postDelayed({ - // Check if binding is still valid before accessing it - if (_binding != null) { - blinkAnimator?.cancel() - blinkAnimator = null - binding.btnConnect.alpha = 1f - - // Возвращаем нормальный background - binding.btnConnect.backgroundTintList = null - binding.btnConnect.setBackgroundResource(R.drawable.vpn_button_background) - } else { - // Just cancel the animator if binding is null - blinkAnimator?.cancel() - blinkAnimator = null - } - }, 1000) - } - - private fun testConnection() { - val bottomSheet = TestConnectionBottomSheet(viewModel) - bottomSheet.show(parentFragmentManager, TestConnectionBottomSheet.TAG) - } - - private fun startDurationUpdates(@Suppress("UNUSED_PARAMETER") initialStatus: ConnectionStatus) { - // Stop any existing updates - stopDurationUpdates() - - // Create a runnable that updates the duration every second - durationUpdateRunnable = object : Runnable { - override fun run() { - if (_binding != null) { - // Get current status to ensure we have the latest connection info - val currentStatus = viewModel.connectionStatus.value - if (currentStatus.state == ConnectionState.CONNECTED && currentStatus.connectedSince != null) { - val currentDuration = currentStatus.getConnectionDuration() - if (currentDuration != null) { - binding.tvConnectionDuration.text = currentDuration - } - // Schedule next update - durationUpdateHandler.postDelayed(this, 1000) - } - } - } - } - // Start the updates - durationUpdateHandler.post(durationUpdateRunnable!!) - } - - private fun stopDurationUpdates() { - durationUpdateRunnable?.let { runnable -> - durationUpdateHandler.removeCallbacks(runnable) - } - durationUpdateRunnable = null - } - - private fun loadCachedIpInfo() { - // Load cached IP info without triggering network requests - val cachedLocation = IpLocationService.getCachedIpLocation() - if (cachedLocation != null && _binding != null) { - binding.tvExternalIp.text = cachedLocation.ip - binding.tvCountryName.text = cachedLocation.country - binding.tvCountryFlag.text = cachedLocation.flag - binding.btnRefreshIp.isEnabled = true - } else if (_binding != null) { - // No cache available, show placeholder - binding.tvExternalIp.text = "--" - binding.tvCountryName.text = getString(R.string.unknown_location) - binding.tvCountryFlag.text = "🌍" - binding.btnRefreshIp.isEnabled = true - } - } - - private fun refreshIpInfo(retryCount: Int = 0) { - android.util.Log.d("HomeFragment", "refreshIpInfo called, retryCount: $retryCount") - viewLifecycleOwner.lifecycleScope.launch { - try { - // Проверяем что binding еще валидный перед каждым обращением - if (_binding == null) return@launch - - // Показываем состояние загрузки только при первой попытке - if (retryCount == 0) { - binding.tvExternalIp.text = getString(R.string.checking_ip) - binding.tvCountryName.text = "" - binding.tvCountryFlag.text = "🔄" - binding.btnRefreshIp.isEnabled = false - } - - // Пытаемся получить полную информацию - val ipLocation = IpLocationService.getIpLocation() - - // Проверяем binding перед обновлением UI - if (_binding == null) return@launch - - if (ipLocation != null) { - binding.tvExternalIp.text = ipLocation.ip - binding.tvCountryName.text = ipLocation.country - binding.tvCountryFlag.text = ipLocation.flag - } else { - // Fallback: получаем только IP - val simpleIp = IpLocationService.getSimpleIp() - - // Снова проверяем binding - if (_binding == null) return@launch - - if (simpleIp != null) { - binding.tvExternalIp.text = simpleIp - binding.tvCountryName.text = getString(R.string.unknown_location) - binding.tvCountryFlag.text = "🌍" - } else if (retryCount < 2) { - // Повторяем через 2 секунды максимум 2 раза - kotlinx.coroutines.delay(2000) - refreshIpInfo(retryCount + 1) - return@launch - } else { - // Полная неудача после повторов - binding.tvExternalIp.text = getString(R.string.unable_to_fetch_ip) - binding.tvCountryName.text = getString(R.string.check_network_connection) - binding.tvCountryFlag.text = "❌" - } - } - } catch (e: Exception) { - // Проверяем binding перед обработкой ошибки - if (_binding == null) return@launch - - if (retryCount < 2) { - // Повторяем при ошибке - kotlinx.coroutines.delay(2000) - refreshIpInfo(retryCount + 1) - return@launch - } else { - binding.tvExternalIp.text = getString(R.string.network_error) - binding.tvCountryName.text = getString(R.string.network_error) - binding.tvCountryFlag.text = "❌" - } - } finally { - // Финальная проверка binding - if (_binding != null) { - binding.btnRefreshIp.isEnabled = true - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - // Cancel any pending callbacks and animations before destroying the view - blinkingHandler.removeCallbacksAndMessages(null) - blinkAnimator?.cancel() - blinkAnimator = null - - // Stop duration updates - stopDurationUpdates() - durationUpdateHandler.removeCallbacksAndMessages(null) - - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt deleted file mode 100644 index be0d420..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModel.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.example.sshproxy.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.ConnectionStatus -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.network.ConnectionQuality -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -class HomeViewModel( - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _servers = MutableStateFlow>(emptyList()) - val servers: StateFlow> = _servers.asStateFlow() - - private val _selectedServer = MutableStateFlow(null) - val selectedServer: StateFlow = _selectedServer.asStateFlow() - - val isRunning: StateFlow = SshProxyService.isRunning - - // Combine connection state with current server for full status - private val _connectionStatus = MutableStateFlow(ConnectionStatus()) - val connectionStatus: StateFlow = _connectionStatus.asStateFlow() - - init { - viewModelScope.launch { - serverRepository.getAllServers().collect { serverList -> - _servers.value = serverList - val activeServerId = preferencesManager.getActiveServerId() - val currentSelection = _selectedServer.value - - if (currentSelection == null || serverList.find { it.id == currentSelection.id } == null) { - _selectedServer.value = serverList.find { it.id == activeServerId } ?: serverList.firstOrNull() - } - } - } - - // Monitor connection state changes - viewModelScope.launch { - combine( - SshProxyService.connectionState, - SshProxyService.connectionStartTime, - _selectedServer - ) { connectionState, connectedSince, server -> - val newState = when (connectionState) { - SshProxyService.ConnectionState.DISCONNECTED -> ConnectionState.DISCONNECTED - SshProxyService.ConnectionState.CONNECTING -> ConnectionState.CONNECTING - SshProxyService.ConnectionState.CONNECTED -> ConnectionState.CONNECTED - SshProxyService.ConnectionState.DISCONNECTING -> ConnectionState.DISCONNECTING - } - - _connectionStatus.value = _connectionStatus.value.copy( - state = newState, - server = server, - connectedSince = connectedSince - ) - }.collect { } - } - - // Monitor ping data - viewModelScope.launch { - SshProxyService.currentPingMonitor.collect { pingMonitor -> - if (pingMonitor != null) { - // Collect ping results - launch { - pingMonitor.latestPing.collect { pingResult -> - val serverStats = pingMonitor.serverStats.value - val quality = pingMonitor.getConnectionQuality() - updatePingStats(pingResult, serverStats, quality) - } - } - } - } - } - } - - fun selectServer(server: Server) { - _selectedServer.value = server - preferencesManager.setActiveServerId(server.id) - } - - fun updatePingStats(pingResult: com.example.sshproxy.network.PingResult?, serverStats: com.example.sshproxy.network.ServerStats?, quality: ConnectionQuality) { - _connectionStatus.value = _connectionStatus.value.copy( - latestPing = pingResult, - serverStats = serverStats, - connectionQuality = quality - ) - } - - fun updateConnectionError(error: String) { - _connectionStatus.value = _connectionStatus.value.copy( - state = ConnectionState.ERROR, - errorMessage = error - ) - } - - fun updateReconnectionStatus(isReconnecting: Boolean, attempt: Int, maxAttempts: Int) { - _connectionStatus.value = _connectionStatus.value.copy( - isReconnecting = isReconnecting, - reconnectionAttempt = attempt, - maxReconnectionAttempts = maxAttempts, - state = if (isReconnecting) ConnectionState.RECONNECTING else _connectionStatus.value.state - ) - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt b/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt deleted file mode 100644 index 56b70e1..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/HomeViewModelFactory.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.sshproxy.ui.home - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository - -class HomeViewModelFactory( - private val repository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return HomeViewModel(repository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt b/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt deleted file mode 100644 index bac518e..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/home/TestConnectionBottomSheet.kt +++ /dev/null @@ -1,183 +0,0 @@ -package com.example.sshproxy.ui.home - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView -import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.ConnectionState -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.network.HttpLatencyTester -import kotlinx.coroutines.launch - -class TestConnectionBottomSheet(private val viewModel: HomeViewModel) : BottomSheetDialogFragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.bottom_sheet_test_connection, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val tvTestStatus = view.findViewById(R.id.tvTestStatus) - val progressBar = view.findViewById(R.id.progressBar) - val layoutResults = view.findViewById(R.id.layoutResults) - val layoutTestResults = view.findViewById(R.id.layoutTestResults) - val viewOverallIndicator = view.findViewById(R.id.viewOverallIndicator) - val tvOverallResult = view.findViewById(R.id.tvOverallResult) - val btnClose = view.findViewById(R.id.btnClose) - - btnClose.setOnClickListener { dismiss() } - - lifecycleScope.launch { - try { - // Create HTTP latency tester - val selectedServer = viewModel.selectedServer.value - val isVpnActive = SshProxyService.connectionState.value == SshProxyService.ConnectionState.CONNECTED - val latencyTester = if (isVpnActive) { - HttpLatencyTester( - proxyHost = "127.0.0.1", - proxyPort = selectedServer?.httpProxyPort ?: 8080, - timeoutMs = 10000 - ) - } else { - HttpLatencyTester( - proxyHost = null, - proxyPort = null, - timeoutMs = 10000 - ) - } - - // Start testing - tvTestStatus.text = getString(R.string.test_connection_checking) - progressBar.progress = 10 - - val result = latencyTester.performSingleTest() - - // Update progress - progressBar.progress = 100 - tvTestStatus.text = getString(R.string.test_connection_completed) - - // Show results after a short delay - kotlinx.coroutines.delay(500) - layoutResults.visibility = View.VISIBLE - - // Clear any existing results - layoutTestResults.removeAllViews() - - // Add individual test results - result.individualResults.forEach { testResult -> - val resultView = createTestResultView(testResult) - layoutTestResults.addView(resultView) - } - - // Determine overall quality - val overallQuality = when { - result.successRate < 50f -> ConnectionQuality.POOR - result.averageLatencyMs > 1500 -> ConnectionQuality.POOR - result.averageLatencyMs > 1000 -> ConnectionQuality.FAIR - result.averageLatencyMs > 600 -> ConnectionQuality.GOOD - else -> ConnectionQuality.EXCELLENT - } - - // Update overall result - viewOverallIndicator.setBackgroundColor(overallQuality.color) - tvOverallResult.text = "${overallQuality.getDisplayName(requireContext())} (${result.averageLatencyMs}ms)" - tvOverallResult.setTextColor(overallQuality.color) - - } catch (e: Exception) { - progressBar.progress = 100 - tvTestStatus.text = getString(R.string.test_connection_error) - layoutResults.visibility = View.VISIBLE - - val errorView = TextView(requireContext()).apply { - text = "Ошибка: ${e.message ?: "Неизвестная ошибка"}" - textSize = 14f - setTextColor(ContextCompat.getColor(requireContext(), R.color.error)) - setPadding(16, 8, 16, 8) - } - layoutTestResults.addView(errorView) - - viewOverallIndicator.setBackgroundColor(ConnectionQuality.POOR.color) - tvOverallResult.text = "Ошибка теста" - tvOverallResult.setTextColor(ConnectionQuality.POOR.color) - } - } - } - - private fun createTestResultView(testResult: com.example.sshproxy.network.HttpLatencyResult): View { - val resultLayout = LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.WRAP_CONTENT - ).apply { - setMargins(0, 8, 0, 8) - } - gravity = android.view.Gravity.CENTER_VERTICAL - setPadding(0, 8, 0, 8) - } - - // Status indicator - val indicator = View(requireContext()).apply { - layoutParams = LinearLayout.LayoutParams(8, 8).apply { - setMargins(0, 0, 12, 0) - } - background = ContextCompat.getDrawable(requireContext(), R.drawable.circle_shape) - setBackgroundColor( - if (testResult.isSuccessful) { - when { - testResult.latencyMs > 1500 -> ContextCompat.getColor(requireContext(), R.color.error) - testResult.latencyMs > 1000 -> ContextCompat.getColor(requireContext(), R.color.warning) - testResult.latencyMs > 600 -> ContextCompat.getColor(requireContext(), R.color.good) - else -> ContextCompat.getColor(requireContext(), R.color.good) - } - } else { - ContextCompat.getColor(requireContext(), R.color.error) - } - ) - } - - // URL and result text - val textView = TextView(requireContext()).apply { - layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) - textSize = 14f - val hostname = testResult.url.replace("https://", "").replace("http://", "").split("/")[0] - text = if (testResult.isSuccessful) { - "$hostname: ${testResult.latencyMs}ms" - } else { - "$hostname: ${testResult.errorMessage ?: "Ошибка"}" - } - setTextColor( - if (testResult.isSuccessful) { - // Get color from current theme - val typedValue = android.util.TypedValue() - requireContext().theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true) - typedValue.data - } else { - ContextCompat.getColor(requireContext(), R.color.error) - } - ) - } - - resultLayout.addView(indicator) - resultLayout.addView(textView) - - return resultLayout - } - - companion object { - const val TAG = "TestConnectionBottomSheet" - } -} \ No newline at end of file From 4dfa56c5aaffc19bede726ba7addf619dc873ed9 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:11:28 +0300 Subject: [PATCH 018/366] Delete app/src/main/java/com/example/sshproxy/ui directory --- .../ui/dialogs/HostKeyChangeDialog.kt | 73 --- .../ui/instructions/InstructionsFragment.kt | 382 ------------- .../ui/instructions/InstructionsViewModel.kt | 66 --- .../InstructionsViewModelFactory.kt | 21 - .../example/sshproxy/ui/keys/AddKeyDialog.kt | 29 - .../example/sshproxy/ui/keys/KeysAdapter.kt | 50 -- .../example/sshproxy/ui/keys/KeysFragment.kt | 118 ---- .../example/sshproxy/ui/log/LogFragment.kt | 54 -- .../sshproxy/ui/servers/AddServerDialog.kt | 260 --------- .../sshproxy/ui/servers/ServerTestResult.kt | 22 - .../sshproxy/ui/servers/ServerTester.kt | 157 ------ .../sshproxy/ui/servers/ServersAdapter.kt | 133 ----- .../sshproxy/ui/servers/ServersFragment.kt | 141 ----- .../sshproxy/ui/servers/ServersViewModel.kt | 44 -- .../sshproxy/ui/settings/SettingsFragment.kt | 517 ------------------ .../sshproxy/ui/setup/KeyChoiceFragment.kt | 37 -- .../ui/setup/KeyGenerationFragment.kt | 95 ---- .../sshproxy/ui/setup/KeySetupFragment.kt | 97 ---- .../ui/setup/ServerInstructionsDialog.kt | 128 ----- .../sshproxy/ui/setup/ServerSetupFragment.kt | 99 ---- .../example/sshproxy/ui/setup/SetupManager.kt | 39 -- .../sshproxy/ui/setup/WelcomeFragment.kt | 32 -- .../sshproxy/ui/splittunneling/AppInfo.kt | 8 - .../ui/splittunneling/AppListAdapter.kt | 66 --- .../splittunneling/SplitTunnelingFragment.kt | 139 ----- .../splittunneling/SplitTunnelingViewModel.kt | 116 ---- 26 files changed, 2923 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/ServerSetupFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt delete mode 100644 app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt diff --git a/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt b/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt deleted file mode 100644 index 9839801..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/dialogs/HostKeyChangeDialog.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.example.sshproxy.ui.dialogs - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.example.sshproxy.R - -/** - * Dialog shown when a server's host key has changed - * This is a security-critical dialog that warns users about potential MITM attacks - */ -class HostKeyChangeDialog : DialogFragment() { - - companion object { - private const val ARG_HOSTNAME = "hostname" - private const val ARG_PORT = "port" - private const val ARG_NEW_FINGERPRINT = "new_fingerprint" - private const val ARG_STORED_FINGERPRINT = "stored_fingerprint" - - fun newInstance( - hostname: String, - port: Int, - newFingerprint: String, - storedFingerprint: String, - onResult: (Boolean) -> Unit - ): HostKeyChangeDialog { - val dialog = HostKeyChangeDialog() - dialog.onResult = onResult - dialog.arguments = Bundle().apply { - putString(ARG_HOSTNAME, hostname) - putInt(ARG_PORT, port) - putString(ARG_NEW_FINGERPRINT, newFingerprint) - putString(ARG_STORED_FINGERPRINT, storedFingerprint) - } - return dialog - } - } - - private var onResult: ((Boolean) -> Unit)? = null - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val hostname = arguments?.getString(ARG_HOSTNAME) ?: "" - val port = arguments?.getInt(ARG_PORT) ?: 22 - val newFingerprint = arguments?.getString(ARG_NEW_FINGERPRINT) ?: "" - val storedFingerprint = arguments?.getString(ARG_STORED_FINGERPRINT) ?: "" - - val hostDisplay = if (port == 22) hostname else "$hostname:$port" - - val message = getString(R.string.host_key_changed_message, hostDisplay) + "\n\n" + - getString(R.string.stored_fingerprint) + "\n$storedFingerprint\n\n" + - getString(R.string.received_fingerprint) + "\n$newFingerprint\n\n" + - getString(R.string.host_key_change_warning) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.security_warning) - .setMessage(message) - .setIcon(android.R.drawable.ic_dialog_alert) - .setPositiveButton(R.string.accept_and_continue) { _, _ -> - onResult?.invoke(true) - } - .setNegativeButton(R.string.reject_connection) { _, _ -> - onResult?.invoke(false) - } - .setCancelable(false) // Force user to make a decision - .create() - } - - override fun onDestroy() { - super.onDestroy() - onResult = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt deleted file mode 100644 index c778ae3..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsFragment.kt +++ /dev/null @@ -1,382 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.FragmentInstructionsBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.InetAddress -import java.net.Socket -import java.net.URL - -class InstructionsFragment : Fragment() { - private var _binding: FragmentInstructionsBinding? = null - private val binding get() = _binding!! - - private val viewModel: InstructionsViewModel by viewModels { - InstructionsViewModelFactory( - KeyRepository(requireContext()), - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentInstructionsBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - observeViewModel() - - binding.btnSelectServer.setOnClickListener { - showServerSelector() - } - - binding.btnSelectKey.setOnClickListener { - showKeySelector() - } - - binding.btnCopyInstructions.setOnClickListener { - copyInstructions() - } - - binding.btnCopyQuick.setOnClickListener { - copyQuickCommand() - } - - binding.btnTestNetwork.setOnClickListener { - runNetworkTest() - } - } - - private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - combine(viewModel.selectedKey, viewModel.selectedServer) { key, server -> - Pair(key, server) - }.collect { (key, server) -> - binding.tvSelectedKey.text = key?.name ?: "No key selected" - binding.tvSelectedServer.text = server?.name ?: "No server selected" - - if (key != null) { - val username = server?.username ?: "sshproxy" - binding.tvInstructions.text = generateInstructions(key.publicKey, username) - binding.tvQuickCommand.text = generateQuickCommand(key.publicKey, username) - } else { - binding.tvInstructions.text = "Please select or generate an SSH key first" - binding.tvQuickCommand.text = "No key selected" - } - } - } - } - - private fun generateInstructions(publicKey: String, username: String): String { - return """ -# SSH Server Setup Instructions - -## 1. Create restricted user for SSH tunneling: -sudo adduser --disabled-password --gecos "" --home /home/$username $username -sudo usermod -s /usr/sbin/nologin $username - -## 2. Setup SSH key authentication: -sudo install -d -m 700 -o $username -g $username /home/$username/.ssh -sudo bash -c 'echo "restrict,port-forwarding $publicKey" > /home/$username/.ssh/authorized_keys' -sudo chown $username:$username /home/$username/.ssh/authorized_keys -sudo chmod 600 /home/$username/.ssh/authorized_keys - -## 3. Configure SSH restrictions for the user: -sudo bash -c 'cat >/etc/ssh/sshd_config.d/$username.conf </dev/null 2>&1; then - sudo apt-get update && sudo apt-get install -y tinyproxy -elif command -v yum >/dev/null 2>&1; then - sudo yum install -y tinyproxy -elif command -v dnf >/dev/null 2>&1; then - sudo dnf install -y tinyproxy -elif command -v pacman >/dev/null 2>&1; then - sudo pacman -S --noconfirm tinyproxy -fi - -# Configure Tinyproxy to listen on port 8118 -sudo sed -i 's/^Port.*/Port 8118/' /etc/tinyproxy/tinyproxy.conf -sudo sed -i 's/^Listen.*/Listen 127.0.0.1/' /etc/tinyproxy/tinyproxy.conf -# Allow localhost connections -sudo sed -i 's/^#Allow 127.0.0.1/Allow 127.0.0.1/' /etc/tinyproxy/tinyproxy.conf -sudo systemctl restart tinyproxy -sudo systemctl enable tinyproxy - -## Notes: -- The user '$username' is restricted to port forwarding only -- No shell access, no TTY, no X11 forwarding -- Tinyproxy will run on port 8118 (localhost only) -- Test connection: ssh -N -L 8080:127.0.0.1:8118 $username@your-server - """.trimIndent() - } - - private fun generateQuickCommand(publicKey: String, username: String): String { - return """#!/bin/bash -# Quick setup script - review before running! -USER="$username" -KEY="restrict,port-forwarding $publicKey" - -# Detect package manager -if command -v apt-get >/dev/null 2>&1; then - PKG_MGR="apt-get" - PKG_UPDATE="apt-get update" - PKG_INSTALL="apt-get install -y" -elif command -v yum >/dev/null 2>&1; then - PKG_MGR="yum" - PKG_UPDATE="yum check-update || true" - PKG_INSTALL="yum install -y" -elif command -v dnf >/dev/null 2>&1; then - PKG_MGR="dnf" - PKG_UPDATE="dnf check-update || true" - PKG_INSTALL="dnf install -y" -elif command -v pacman >/dev/null 2>&1; then - PKG_MGR="pacman" - PKG_UPDATE="pacman -Sy" - PKG_INSTALL="pacman -S --noconfirm" -else - echo "Error: No supported package manager found!" - exit 1 -fi - -echo "Using package manager: ${'$'}PKG_MGR" - -# Auto-detect SSH service name -if systemctl is-active --quiet ssh; then - SSH_SERVICE="ssh" -elif systemctl is-active --quiet sshd; then - SSH_SERVICE="sshd" -else - SSH_SERVICE="ssh" # Default to ssh for Ubuntu/Debian -fi - -echo "SSH service detected: ${'$'}SSH_SERVICE" - -# Create user and setup SSH -sudo adduser --disabled-password --gecos "" --home /home/${'$'}USER ${'$'}USER && \ -sudo usermod -s /usr/sbin/nologin ${'$'}USER && \ -sudo install -d -m 700 -o ${'$'}USER -g ${'$'}USER /home/${'$'}USER/.ssh && \ -sudo bash -c "echo '${'$'}KEY' > /home/${'$'}USER/.ssh/authorized_keys" && \ -sudo chown ${'$'}USER:${'$'}USER /home/${'$'}USER/.ssh/authorized_keys && \ -sudo chmod 600 /home/${'$'}USER/.ssh/authorized_keys && \ -sudo bash -c "cat >/etc/ssh/sshd_config.d/${'$'}USER.conf < "${server.name} (${server.username}@${server.host})" }.toTypedArray() - val currentId = viewModel.selectedServer.value?.id - val currentIndex = servers.indexOfFirst { server -> server.id == currentId }.takeIf { index -> index >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select Server for Instructions") - .setSingleChoiceItems(serverNames, currentIndex) { dialog, which -> - viewModel.selectServer(servers[which]) - dialog.dismiss() - } - .show() - } - - private fun showKeySelector() { - val keys = viewModel.keys.value - if (keys.isEmpty()) { - Toast.makeText(context, "No SSH keys configured", Toast.LENGTH_SHORT).show() - return - } - - val keyNames = keys.map { key -> key.name }.toTypedArray() - val currentId = viewModel.selectedKey.value?.id - val currentIndex = keys.indexOfFirst { key -> key.id == currentId }.takeIf { index -> index >= 0 } ?: 0 - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select SSH Key") - .setSingleChoiceItems(keyNames, currentIndex) { dialog, which -> - viewModel.selectKey(keys[which]) - dialog.dismiss() - } - .show() - } - - private fun copyInstructions() { - val text = binding.tvInstructions.text.toString() - if (text.isNotBlank() && !text.contains("Please select")) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Server Instructions", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Instructions copied", Toast.LENGTH_SHORT).show() - } - } - - private fun copyQuickCommand() { - val text = binding.tvQuickCommand.text.toString() - if (text.isNotBlank() && !text.contains("No key selected")) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Quick Setup Script", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Quick command copied", Toast.LENGTH_SHORT).show() - } - } - - private fun runNetworkTest() { - val server = viewModel.selectedServer.value - if (server == null) { - Toast.makeText(context, "Please select a server first", Toast.LENGTH_SHORT).show() - return - } - - Toast.makeText(context, "Running network test...", Toast.LENGTH_SHORT).show() - - viewLifecycleOwner.lifecycleScope.launch { - val results = withContext(Dispatchers.IO) { - val testResults = mutableListOf() - - // Тест 1: DNS разрешение - try { - val address = InetAddress.getByName(server.host) - testResults.add("✓ DNS Resolution: ${server.host} → ${address.hostAddress}") - } catch (e: Exception) { - testResults.add("✗ DNS Resolution: Failed - ${e.message}") - } - - // Тест 2: HTTP подключение (проверяем общую сетевую доступность) - try { - val url = URL("https://www.google.com") - val connection = url.openConnection() - connection.connectTimeout = 5000 - connection.readTimeout = 5000 - connection.connect() - testResults.add("✓ Internet Access: Working") - } catch (e: Exception) { - testResults.add("✗ Internet Access: Failed - ${e.message}") - } - - // Тест 3: Loopback (базовая функциональность сокетов) - try { - val localSocket = Socket() - localSocket.connect(java.net.InetSocketAddress("127.0.0.1", 1), 1000) - localSocket.close() - testResults.add("✓ Local Sockets: Working") - } catch (e: Exception) { - testResults.add("✗ Local Sockets: ${e.message}") - } - - // Тест 4: SSH подключение к серверу - try { - val sshSocket = Socket() - sshSocket.connect(java.net.InetSocketAddress(server.host, server.port), 10000) - sshSocket.close() - testResults.add("✓ SSH Server (${server.host}:${server.port}): Reachable") - } catch (e: Exception) { - testResults.add("✗ SSH Server (${server.host}:${server.port}): ${e.message}") - } - - // Тест 5: Информация о процессе - testResults.add("") - testResults.add("=== Debug Information ===") - testResults.add("Process UID: ${android.os.Process.myUid()}") - testResults.add("Process PID: ${android.os.Process.myPid()}") - testResults.add("Thread: ${Thread.currentThread().name}") - - testResults - } - - // Показываем результаты в диалоге - val message = results.joinToString("\n") - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Network Test Results") - .setMessage(message) - .setPositiveButton("OK", null) - .setNegativeButton("Copy Results") { _, _ -> - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Network Test Results", message) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Results copied to clipboard", Toast.LENGTH_SHORT).show() - } - .show() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt deleted file mode 100644 index 4cb0f0c..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.data.* -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch - -class InstructionsViewModel( - private val keyRepository: KeyRepository, - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _keys = MutableStateFlow>(emptyList()) - val keys: StateFlow> = _keys.asStateFlow() - - private val _servers = MutableStateFlow>(emptyList()) - val servers: StateFlow> = _servers.asStateFlow() - - private val _selectedKey = MutableStateFlow(null) - val selectedKey: StateFlow = _selectedKey.asStateFlow() - - private val _selectedServer = MutableStateFlow(null) - val selectedServer: StateFlow = _selectedServer.asStateFlow() - - init { - viewModelScope.launch { - keyRepository.getAllKeys().collect { keyList -> - _keys.value = keyList - val activeKeyId = preferencesManager.getActiveKeyId() - // If no key is selected, or the selected one was deleted, select a new one. - if (_selectedKey.value == null || keyList.find { it.id == _selectedKey.value?.id } == null) { - val newKeyToSelect = keyList.find { it.id == activeKeyId } ?: keyList.firstOrNull() - _selectedKey.value = newKeyToSelect - // Also update the preference if we made a new selection - newKeyToSelect?.let { preferencesManager.setActiveKeyId(it.id) } - } - } - } - - viewModelScope.launch { - serverRepository.getAllServers().collect { serverList -> - _servers.value = serverList - val activeServerId = preferencesManager.getActiveServerId() - // If no server is selected, or the selected one was deleted, select a new one. - if (_selectedServer.value == null || serverList.find { it.id == _selectedServer.value?.id } == null) { - val newServerToSelect = serverList.find { it.id == activeServerId } ?: serverList.firstOrNull() - _selectedServer.value = newServerToSelect - // Also update the preference if we made a new selection - newServerToSelect?.let { preferencesManager.setActiveServerId(it.id) } - } - } - } - } - - fun selectKey(key: SshKey) { - _selectedKey.value = key - preferencesManager.setActiveKeyId(key.id) - } - - fun selectServer(server: Server) { - _selectedServer.value = server - preferencesManager.setActiveServerId(server.id) - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt b/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt deleted file mode 100644 index 962eb47..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/instructions/InstructionsViewModelFactory.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.sshproxy.ui.instructions - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository - -class InstructionsViewModelFactory( - private val keyRepository: KeyRepository, - private val serverRepository: ServerRepository, - private val preferencesManager: PreferencesManager -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(InstructionsViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return InstructionsViewModel(keyRepository, serverRepository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt b/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt deleted file mode 100644 index 601292b..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/AddKeyDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.example.sshproxy.R -import com.example.sshproxy.databinding.DialogAddKeyBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class AddKeyDialog( - private val onSave: (String) -> Unit -) : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogAddKeyBinding.inflate(layoutInflater) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.generate_new_key)) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.generate)) { _, _ -> - val name = binding.etKeyName.text.toString().trim() - if (name.isNotEmpty()) { - onSave(name) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .create() - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt deleted file mode 100644 index 0eb6584..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/KeysAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.databinding.ItemKeyBinding - -class KeysAdapter( - private val onKeyClick: (SshKey) -> Unit, - private val onKeyDelete: (SshKey) -> Unit, - private val onKeyCopy: (SshKey) -> Unit -) : ListAdapter(KeyDiffCallback()) { - - private var activeKeyId: String? = null - - fun setActiveKeyId(id: String?) { - activeKeyId = id - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): KeyViewHolder { - val binding = ItemKeyBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return KeyViewHolder(binding) - } - - override fun onBindViewHolder(holder: KeyViewHolder, position: Int) { - val key = getItem(position) - holder.bind(key, key.id == activeKeyId, onKeyClick, onKeyDelete, onKeyCopy) - } - - class KeyViewHolder(private val binding: ItemKeyBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(key: SshKey, isActive: Boolean, onClick: (SshKey) -> Unit, onDelete: (SshKey) -> Unit, onCopy: (SshKey) -> Unit) { - binding.tvKeyName.text = key.name - binding.tvKeyFingerprint.text = key.fingerprint - binding.radioActive.isChecked = isActive - - binding.root.setOnClickListener { onClick(key) } - binding.btnDelete.setOnClickListener { onDelete(key) } - binding.btnCopyKey.setOnClickListener { onCopy(key) } - } - } - - class KeyDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SshKey, newItem: SshKey): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: SshKey, newItem: SshKey): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt b/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt deleted file mode 100644 index 70d5ad0..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/keys/KeysFragment.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.example.sshproxy.ui.keys - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentKeysBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class KeysFragment : Fragment() { - private var _binding: FragmentKeysBinding? = null - private val binding get() = _binding!! - - private lateinit var keyRepository: KeyRepository - private lateinit var preferencesManager: PreferencesManager - private lateinit var adapter: KeysAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeysBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - keyRepository = KeyRepository(requireContext()) - preferencesManager = PreferencesManager(requireContext()) - setupRecyclerView() - - binding.fabAddKey.setOnClickListener { - AddKeyDialog { name -> - lifecycleScope.launch { - keyRepository.generateKeyPair(name) - // After generating, find the new key and set it as active - keyRepository.getAllKeys().collectLatest { keys -> - val newKey = keys.find { it.name == name } - if (newKey != null) { - preferencesManager.setActiveKeyId(newKey.id) - adapter.setActiveKeyId(newKey.id) - } - } - Toast.makeText(context, getString(com.example.sshproxy.R.string.key_generated_and_set_as_active, name), Toast.LENGTH_SHORT).show() - } - }.show(parentFragmentManager, "add_key") - } - - observeKeys() - } - - private fun setupRecyclerView() { - adapter = KeysAdapter( - onKeyClick = { key -> - preferencesManager.setActiveKeyId(key.id) - adapter.setActiveKeyId(key.id) - }, - onKeyDelete = { key -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.delete_key)) - .setMessage(getString(com.example.sshproxy.R.string.delete_key_confirmation_with_name, key.name)) - .setPositiveButton(getString(com.example.sshproxy.R.string.delete)) { _, _ -> - lifecycleScope.launch { - keyRepository.deleteKey(key.id) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .show() - }, - onKeyCopy = { key -> - copyKeyToClipboard(key.publicKey) - } - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.adapter = adapter - } - - private fun observeKeys() { - lifecycleScope.launch { - keyRepository.getAllKeys().collectLatest { keys -> - adapter.submitList(keys) - - var activeKeyId = preferencesManager.getActiveKeyId() - // If no key is active, but keys exist, make the first one active. - if (activeKeyId == null && keys.isNotEmpty()) { - activeKeyId = keys.first().id - preferencesManager.setActiveKeyId(activeKeyId) - } - adapter.setActiveKeyId(activeKeyId) - - binding.emptyView.visibility = if (keys.isEmpty()) View.VISIBLE else View.GONE - binding.recyclerView.visibility = if (keys.isEmpty()) View.GONE else View.VISIBLE - } - } - } - - private fun copyKeyToClipboard(publicKey: String) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(getString(com.example.sshproxy.R.string.public_key), publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, getString(com.example.sshproxy.R.string.public_key_copied), Toast.LENGTH_SHORT).show() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt b/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt deleted file mode 100644 index 2949095..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/log/LogFragment.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.sshproxy.ui.log - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import com.example.sshproxy.AppLog -import com.example.sshproxy.databinding.FragmentLogBinding - -class LogFragment : Fragment() { - private var _binding: FragmentLogBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentLogBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - AppLog.logMessages.observe(viewLifecycleOwner) { logs -> - binding.tvLog.text = logs.joinToString("\n") - // Auto-scroll to bottom - binding.scrollView.post { - binding.scrollView.fullScroll(View.FOCUS_DOWN) - } - } - - binding.fabCopy.setOnClickListener { - copyLogToClipboard() - } - } - - private fun copyLogToClipboard() { - val logText = binding.tvLog.text.toString() - if (logText.isNotBlank()) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("App Log", logText) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Log copied", Toast.LENGTH_SHORT).show() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt b/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt deleted file mode 100644 index c1d4058..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/AddServerDialog.kt +++ /dev/null @@ -1,260 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.app.Dialog -import android.os.Bundle -import android.view.View -import android.widget.Toast -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.databinding.DialogAddServerBinding -import com.example.sshproxy.network.SshAlgorithmManager -import android.widget.ArrayAdapter -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch - -class AddServerDialog( - private val server: Server? = null, - private val onSave: (Server) -> Unit -) : DialogFragment() { - - private var selectedKey: SshKey? = null - private var keys: List = emptyList() - private lateinit var keyRepository: KeyRepository - private lateinit var preferencesManager: PreferencesManager - private lateinit var binding: DialogAddServerBinding - private val algorithmManager = SshAlgorithmManager() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogAddServerBinding.inflate(layoutInflater) - - // Initialize repositories - keyRepository = KeyRepository(requireContext()) - preferencesManager = PreferencesManager(requireContext()) - - // Setup SSH algorithm dropdowns - setupAlgorithmDropdowns() - - // Setup initial values - server?.let { - binding.etHost.setText(it.host) - binding.etServerName.setText(it.name) - binding.etUsername.setText(it.username) - binding.etPort.setText(it.port.toString()) - binding.etHttpProxyPort.setText(it.httpProxyPort.toString()) - - // Set algorithm values - binding.etCipher.setText(it.preferredCipher ?: "", false) - binding.etKex.setText(it.preferredKex ?: "", false) - binding.etMac.setText(it.preferredMac ?: "", false) - } ?: run { - binding.etUsername.setText("user") - binding.etPort.setText("22") - binding.etHttpProxyPort.setText("8080") - } - - // Load SSH keys - loadSshKeys(binding) - - // SSH key selection - binding.btnSelectKey.setOnClickListener { - showSshKeySelectionDialog() - } - - // Auto-fill name from host with URL cleanup - binding.etHost.doAfterTextChanged { text -> - if (!text.isNullOrEmpty()) { - val cleanHost = cleanUrl(text.toString()) - - // Update host field if URL was cleaned - if (cleanHost != text.toString()) { - binding.etHost.setText(cleanHost) - binding.etHost.setSelection(cleanHost.length) // Move cursor to end - } - - // Auto-fill name only if it's empty - if (binding.etServerName.text.isNullOrEmpty()) { - binding.etServerName.setText(cleanHost) - } - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - val title = if (server == null) getString(com.example.sshproxy.R.string.add_server) else getString(com.example.sshproxy.R.string.edit_server) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(title) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.save)) { _, _ -> - val name = binding.etServerName.text.toString().trim() - val host = binding.etHost.text.toString().trim() - val port = binding.etPort.text.toString().toIntOrNull() ?: 22 - val httpProxyPort = binding.etHttpProxyPort.text.toString().toIntOrNull() ?: 8080 - val username = binding.etUsername.text.toString().trim() - - // Get algorithm selections (empty string means auto-detect) - val cipher = binding.etCipher.text.toString().takeIf { it.isNotEmpty() } - val kex = binding.etKex.text.toString().takeIf { it.isNotEmpty() } - val mac = binding.etMac.text.toString().takeIf { it.isNotEmpty() } - - if (name.isNotEmpty() && host.isNotEmpty() && username.isNotEmpty()) { - val newServer = server?.copy( - name = name, - host = host, - port = port, - httpProxyPort = httpProxyPort, - username = username, - sshKeyId = selectedKey?.id, - preferredCipher = cipher, - preferredKex = kex, - preferredMac = mac - ) ?: Server( - name = name, - host = host, - port = port, - httpProxyPort = httpProxyPort, - username = username, - sshKeyId = selectedKey?.id, - preferredCipher = cipher, - preferredKex = kex, - preferredMac = mac - ) - onSave(newServer) - } - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .create() - } - - private fun loadSshKeys(binding: DialogAddServerBinding) { - lifecycleScope.launch { - keyRepository.getAllKeys().collect { keyList -> - keys = keyList - - // Set initial selection based on server or use active key - if (server?.sshKeyId != null) { - selectedKey = keys.find { it.id == server.sshKeyId } - } else { - val activeKeyId = preferencesManager.getActiveKeyId() - selectedKey = keys.find { it.id == activeKeyId } - } - - updateKeyDisplay(binding) - } - } - } - - private fun showSshKeySelectionDialog() { - val keyNames = arrayOf("Use active key") + keys.map { it.name }.toTypedArray() - val currentSelection = if (selectedKey != null) { - keys.indexOfFirst { it.id == selectedKey!!.id } + 1 - } else { - 0 - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle("Select SSH Key") - .setSingleChoiceItems(keyNames, currentSelection) { dialog, which -> - selectedKey = if (which == 0) null else keys[which - 1] - dialog.dismiss() - updateKeyDisplay(binding) - } - .setNegativeButton("Cancel", null) - .show() - } - - private fun updateKeyDisplay(binding: DialogAddServerBinding) { - binding.tvSelectedKey.text = if (selectedKey != null) { - selectedKey!!.name - } else { - "Use active key" - } - } - - private fun cleanUrl(input: String): String { - var cleaned = input.trim() - - // Remove common URL prefixes - val prefixes = listOf("http://", "https://", "ftp://", "ftps://") - for (prefix in prefixes) { - if (cleaned.startsWith(prefix, ignoreCase = true)) { - cleaned = cleaned.substring(prefix.length) - break - } - } - - // Remove trailing slashes and paths - val slashIndex = cleaned.indexOf('/') - if (slashIndex != -1) { - cleaned = cleaned.substring(0, slashIndex) - } - - // Remove port from display (user can set it separately) - val colonIndex = cleaned.lastIndexOf(':') - if (colonIndex != -1) { - // Check if what follows the colon is a number (port) - val afterColon = cleaned.substring(colonIndex + 1) - if (afterColon.toIntOrNull() != null) { - cleaned = cleaned.substring(0, colonIndex) - } - } - - return cleaned - } - - private fun setupAlgorithmDropdowns() { - // Get supported algorithms - val supportedAlgorithms = algorithmManager.getSupportedAlgorithms() - val displayNames = algorithmManager.getAlgorithmDisplayNames() - - // Setup cipher dropdown - val cipherList = supportedAlgorithms.cipher?.split(",") ?: emptyList() - val cipherDisplayList = cipherList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val cipherAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, cipherDisplayList) - binding.etCipher.setAdapter(cipherAdapter) - - // Setup KEX dropdown - val kexList = supportedAlgorithms.kex?.split(",") ?: emptyList() - val kexDisplayList = kexList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val kexAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, kexDisplayList) - binding.etKex.setAdapter(kexAdapter) - - // Setup MAC dropdown - val macList = supportedAlgorithms.mac?.split(",") ?: emptyList() - val macDisplayList = macList.map { algorithm -> - displayNames[algorithm] ?: algorithm - } - val macAdapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, macDisplayList) - binding.etMac.setAdapter(macAdapter) - - // Set up listeners to store actual algorithm names (not display names) - binding.etCipher.setOnItemClickListener { _, _, position, _ -> - binding.etCipher.setText(cipherList.getOrNull(position) ?: "", false) - } - - binding.etKex.setOnItemClickListener { _, _, position, _ -> - binding.etKex.setText(kexList.getOrNull(position) ?: "", false) - } - - binding.etMac.setOnItemClickListener { _, _, position, _ -> - binding.etMac.setText(macList.getOrNull(position) ?: "", false) - } - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt deleted file mode 100644 index d2df2b4..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTestResult.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.sshproxy.ui.servers - -import com.example.sshproxy.network.ConnectionQuality - -data class ServerTestResult( - val serverId: Long, - val latencyMs: Long = 0, - val quality: ConnectionQuality = ConnectionQuality.UNKNOWN, - val isSuccess: Boolean = false, - val errorMessage: String? = null, - val isLoading: Boolean = false -) { - companion object { - fun loading(serverId: Long) = ServerTestResult(serverId = serverId, isLoading = true) - - fun success(serverId: Long, latencyMs: Long, quality: ConnectionQuality) = - ServerTestResult(serverId = serverId, latencyMs = latencyMs, quality = quality, isSuccess = true) - - fun failure(serverId: Long, errorMessage: String) = - ServerTestResult(serverId = serverId, errorMessage = errorMessage, isSuccess = false) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt deleted file mode 100644 index 52d426a..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServerTester.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.content.Context -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.network.HttpLatencyTester -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.security.KnownHostsManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.connection.channel.direct.LocalPortForwarder -import net.schmizz.sshj.connection.channel.direct.Parameters -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import java.io.File -import java.io.IOException -import java.net.InetAddress -import java.net.ServerSocket - -class ServerTester(private val context: Context) { - - private val serverRepository = ServerRepository(context) - private val keyRepository = KeyRepository(context) - private val preferencesManager = PreferencesManager(context) - - suspend fun test(server: Server): ServerTestResult = withContext(Dispatchers.IO) { - var sshClient: SSHClient? = null - var localPortForwarder: LocalPortForwarder? = null - var serverSocket: ServerSocket? = null - - try { - // 1. Establish SSH connection - sshClient = SSHClient() - sshClient.addHostKeyVerifier(ServerTestHostKeyVerifier(context)) - sshClient.connect(server.host, server.port) - - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found. Please generate one.") - - val keyProvider = PKCS8KeyFile() - keyProvider.init(keyFile) - sshClient.authPublickey(server.username, keyProvider) - - // 2. Setup local port forwarder - serverSocket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) - val localPort = serverSocket.localPort - val params = Parameters("127.0.0.1", localPort, "127.0.0.1", 8118) - localPortForwarder = sshClient.newLocalPortForwarder(params, serverSocket) - - val forwarderJob = GlobalScope.launch(Dispatchers.IO) { - try { - localPortForwarder.listen() - } catch (e: Exception) { - // Ignore - } - } - - // 3. Run HttpLatencyTester - val latencyTester = HttpLatencyTester( - proxyHost = "127.0.0.1", - proxyPort = localPort, - timeoutMs = 5000 - ) - val result = latencyTester.performSingleTest() - - forwarderJob.cancel() - - val quality = when { - result.averageLatencyMs > 2000 -> ConnectionQuality.POOR - result.averageLatencyMs > 1000 -> ConnectionQuality.FAIR - result.averageLatencyMs > 500 -> ConnectionQuality.GOOD - else -> ConnectionQuality.EXCELLENT - } - - ServerTestResult.success(server.id, result.averageLatencyMs, quality) - } catch (e: Exception) { - ServerTestResult.failure(server.id, e.message ?: "Unknown error") - } finally { - localPortForwarder?.close() - serverSocket?.close() - sshClient?.disconnect() - } - } - - private suspend fun resolvePrivateKeyFile(serverSshKeyId: String? = null): File? { - val keyId = serverSshKeyId ?: preferencesManager.getActiveKeyId() - if (keyId != null) { - try { - val privateKey = keyRepository.getPrivateKey(keyId) - if (privateKey != null) { - val tempFile = File.createTempFile("ssh_key_$keyId", ".pem", context.cacheDir) - tempFile.deleteOnExit() - val pemContent = convertPrivateKeyToPem(privateKey) - tempFile.writeText(pemContent) - return tempFile - } else { - return null - } - } catch (e: Exception) { - return null - } - } else { - return null - } - } - - private fun convertPrivateKeyToPem(privateKey: java.security.PrivateKey): String { - val stringWriter = java.io.StringWriter() - org.bouncycastle.util.io.pem.PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(org.bouncycastle.util.io.pem.PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } -} - -class ServerTestHostKeyVerifier(private val context: Context) : HostKeyVerifier { - private val knownHostsManager = KnownHostsManager(context) - - override fun verify(hostname: String?, port: Int, key: java.security.PublicKey?): Boolean { - if (hostname == null || key == null) { - return false - } - val keyBytes = key.encoded - val keyType = determineKeyType(key) - when (knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - return true - } - KnownHostsManager.HostKeyValidationResult.VALID -> { - return true - } - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - return true - } - } - } - - private fun determineKeyType(key: java.security.PublicKey): String { - return when (key.algorithm.lowercase()) { - "rsa" -> "ssh-rsa" - "ec" -> "ecdsa-sha2-nistp256" - "eddsa", "ed25519" -> "ssh-ed25519" - else -> "ssh-${key.algorithm.lowercase()}" - } - } - - override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { - return mutableListOf() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt deleted file mode 100644 index f6a1b0f..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersAdapter.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.databinding.ItemServerBinding -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class ServersAdapter( - private val onServerClick: (Server) -> Unit, - private val onServerDelete: (Server) -> Unit, - private val keyRepository: KeyRepository, - private val preferencesManager: PreferencesManager, - private val serverRepository: ServerRepository -) : ListAdapter(ServerDiffCallback()) { - - private val testResults = mutableMapOf() - - fun updateTestResult(result: ServerTestResult) { - testResults[result.serverId] = result - // Find the position and notify only that item - val position = currentList.indexOfFirst { it.id == result.serverId } - if (position >= 0) { - notifyItemChanged(position) - } - } - - fun clearTestResults() { - testResults.clear() - notifyDataSetChanged() - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ServerViewHolder { - val binding = ItemServerBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ServerViewHolder(binding) - } - - override fun onBindViewHolder(holder: ServerViewHolder, position: Int) { - val server = getItem(position) - val testResult = testResults[server.id] - holder.bind(server, testResult, onServerClick, onServerDelete, keyRepository, preferencesManager, serverRepository) - } - - class ServerViewHolder(private val binding: ItemServerBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind( - server: Server, - testResult: ServerTestResult?, - onClick: (Server) -> Unit, - onDelete: (Server) -> Unit, - keyRepository: KeyRepository, - preferencesManager: PreferencesManager, - serverRepository: ServerRepository - ) { - binding.tvServerName.text = server.name - binding.tvServerDetails.text = "${server.username}@${server.host}:${server.port}" - - // Показать отпечаток сервера - val fingerprint = serverRepository.getServerFingerprint(server) - if (fingerprint != null) { - binding.tvServerFingerprint.text = "Host key: ${fingerprint.take(20)}..." - binding.tvServerFingerprint.visibility = android.view.View.VISIBLE - } else { - binding.tvServerFingerprint.text = "Host key: Not connected yet" - binding.tvServerFingerprint.visibility = android.view.View.VISIBLE - } - - // Показать информацию о SSH ключе - CoroutineScope(Dispatchers.Main).launch { - val keyInfo = withContext(Dispatchers.IO) { - if (server.sshKeyId != null) { - val key = keyRepository.getKeyById(server.sshKeyId) - key?.name ?: "Unknown key" - } else { - val activeKeyId = preferencesManager.getActiveKeyId() - if (activeKeyId != null) { - val activeKey = keyRepository.getKeyById(activeKeyId) - "Active: ${activeKey?.name ?: "Unknown"}" - } else { - "No key" - } - } - } - binding.tvSshKey.text = keyInfo - } - - binding.root.setOnClickListener { onClick(server) } - binding.btnDelete.setOnClickListener { onDelete(server) } - - // Show test results - if (testResult != null) { - binding.layoutTestResult.visibility = android.view.View.VISIBLE - - when { - testResult.isLoading -> { - binding.tvTestResult.text = "Testing..." - binding.progressBarTest.visibility = android.view.View.VISIBLE - binding.viewQualityIndicator.setBackgroundColor( - android.graphics.Color.GRAY - ) - } - testResult.isSuccess -> { - binding.tvTestResult.text = "${testResult.quality.getDisplayName(binding.root.context)} (${testResult.latencyMs}ms)" - binding.progressBarTest.visibility = android.view.View.GONE - binding.viewQualityIndicator.setBackgroundColor(testResult.quality.color) - } - else -> { - binding.tvTestResult.text = "Failed: ${testResult.errorMessage ?: "Unknown error"}" - binding.progressBarTest.visibility = android.view.View.GONE - binding.viewQualityIndicator.setBackgroundColor( - android.graphics.Color.RED - ) - } - } - } else { - binding.layoutTestResult.visibility = android.view.View.GONE - } - } - } - - class ServerDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Server, newItem: Server): Boolean = oldItem.id == newItem.id - override fun areContentsTheSame(oldItem: Server, newItem: Server): Boolean = oldItem == newItem - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt deleted file mode 100644 index 81be631..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersFragment.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.sshproxy.ui.servers - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentServersBinding -import com.example.sshproxy.network.ConnectionQuality -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.coroutines.delay - -class ServersFragment : Fragment() { - private var _binding: FragmentServersBinding? = null - private val binding get() = _binding!! - - private val viewModel: ServersViewModel by viewModels { - ServersViewModelFactory( - ServerRepository(requireContext()), - PreferencesManager(requireContext()) - ) - } - private lateinit var adapter: ServersAdapter - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentServersBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupRecyclerView() - - binding.fabAddServer.setOnClickListener { - AddServerDialog { server -> - viewModel.insertServer(server) - }.show(parentFragmentManager, "add_server") - } - - binding.btnTestAllServers.setOnClickListener { - testAllServers() - } - - observeServers() - } - - private fun setupRecyclerView() { - val keyRepository = KeyRepository(requireContext()) - val preferencesManager = PreferencesManager(requireContext()) - val serverRepository = ServerRepository(requireContext()) - - adapter = ServersAdapter( - onServerClick = { server: Server -> - AddServerDialog(server) { updatedServer: Server -> - viewModel.insertServer(updatedServer) - }.show(parentFragmentManager, "edit_server") - }, - onServerDelete = { server: Server -> - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.delete_server)) - .setMessage(getString(com.example.sshproxy.R.string.delete_server_confirmation_with_name, server.name)) - .setPositiveButton(getString(com.example.sshproxy.R.string.delete)) { _, _ -> - viewModel.deleteServer(server) - } - .setNegativeButton(getString(com.example.sshproxy.R.string.cancel), null) - .show() - }, - keyRepository = keyRepository, - preferencesManager = preferencesManager, - serverRepository = serverRepository - ) - - binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.adapter = adapter - } - - private fun observeServers() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.servers.collectLatest { servers -> - adapter.submitList(servers) - binding.emptyView.visibility = if (servers.isEmpty()) View.VISIBLE else View.GONE - binding.recyclerView.visibility = if (servers.isEmpty()) View.GONE else View.VISIBLE - binding.btnTestAllServers.visibility = if (servers.isEmpty()) View.GONE else View.VISIBLE - } - } - } - - private fun testAllServers() { - val servers = adapter.currentList - if (servers.isEmpty()) { - return - } - - // Disable test button during testing - binding.btnTestAllServers.isEnabled = false - binding.btnTestAllServers.text = "Testing..." - - // Clear previous test results - adapter.clearTestResults() - - val serverTester = ServerTester(requireContext()) - - viewLifecycleOwner.lifecycleScope.launch { - try { - // Test each server sequentially - for (server in servers) { - // Show loading state - adapter.updateTestResult(ServerTestResult.loading(server.id)) - - // Perform the test - val result = serverTester.test(server) - adapter.updateTestResult(result) - - // Small delay between tests to avoid overwhelming the network - delay(500) - } - } finally { - // Re-enable test button - binding.btnTestAllServers.isEnabled = true - binding.btnTestAllServers.text = getString(com.example.sshproxy.R.string.test_all_servers) - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt deleted file mode 100644 index 45d8585..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/servers/ServersViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.sshproxy.ui.servers - -import androidx.lifecycle.* -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.PreferencesManager -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -class ServersViewModel(private val repository: ServerRepository, private val preferencesManager: PreferencesManager) : ViewModel() { - val servers: StateFlow> = repository.getAllServers() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - fun insertServer(server: Server) = viewModelScope.launch { - repository.insertServer(server) - val serverList = repository.getAllServers().first() - if (serverList.size == 1) { - preferencesManager.setActiveServerId(serverList.first().id) - } - } - - fun deleteServer(server: Server) = viewModelScope.launch { - repository.deleteServer(server) - val serverList = repository.getAllServers().first() - if (serverList.size == 1) { - preferencesManager.setActiveServerId(serverList.first().id) - } else if (serverList.isEmpty()) { - preferencesManager.setActiveServerId(-1) - } - } -} - -class ServersViewModelFactory(private val repository: ServerRepository, private val preferencesManager: PreferencesManager) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(ServersViewModel::class.java)) { - @Suppress("UNCHECKED_CAST") - return ServersViewModel(repository, preferencesManager) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt b/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt deleted file mode 100644 index 3d19d7c..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/settings/SettingsFragment.kt +++ /dev/null @@ -1,517 +0,0 @@ -package com.example.sshproxy.ui.settings - -import kotlinx.coroutines.flow.first - -import android.content.Intent -import android.app.Activity -import android.widget.EditText -import android.content.Context -import com.example.sshproxy.data.Server -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.R -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import android.util.Base64 -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentSettingsBinding -import com.example.sshproxy.ui.log.LogFragment -import com.example.sshproxy.ui.setup.ServerInstructionsDialog -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import androidx.appcompat.app.AppCompatDelegate -import androidx.core.os.LocaleListCompat -import androidx.activity.result.contract.ActivityResultContracts - -class SettingsFragment : Fragment() { - - private lateinit var serverRepository: com.example.sshproxy.data.ServerRepository - private lateinit var keyRepository: com.example.sshproxy.data.KeyRepository - - private fun doImportBackup(json: String) { - viewLifecycleOwner.lifecycleScope.launch { - try { - val gson = Gson() - val mapType = object : TypeToken>() {}.type - val map = gson.fromJson>(json, mapType) - val serversJson = gson.toJson(map["servers"]) - val keysJson = gson.toJson(map["keys"]) - val serversType = object : TypeToken>() {}.type - val servers: List = gson.fromJson(serversJson, serversType) - - // keysJson is a list of maps with meta/encryptedPem/pemPassword - val keysListType = object : TypeToken>>() {}.type - val keysWithPem: List> = gson.fromJson(keysJson, keysListType) - // val keyManager = SshKeyManager(requireContext(), keyRepository) // Не используется - val existingServers = serverRepository.getAllServers().first() - val existingKeys = keyRepository.getAllKeys().first() - val newServers = servers.filter { s -> existingServers.none { it.id == s.id } } - val newKeys = mutableListOf() - - for (keyMap in keysWithPem) { - val metaObj = keyMap["meta"] - val encryptedPem = keyMap["encryptedPem"] as? String - val pemPassword = keyMap["pemPassword"] as? String - // Deserialize meta - val metaJson = gson.toJson(metaObj) - val keyMeta = gson.fromJson(metaJson, SshKey::class.java) - if (existingKeys.none { it.id == keyMeta.id }) { - newKeys.add(keyMeta) - } - // Restore PEM if present - if (encryptedPem != null && pemPassword != null) { - val pem = com.example.sshproxy.security.PemAesUtil.decryptPem(encryptedPem, pemPassword) - // Генерируем новый пароль, шифруем PEM и сохраняем с паролем в Keystore - val keyManagerLocal = SshKeyManager(requireContext(), keyRepository) - keyManagerLocal.saveEncryptedPem(keyMeta.id, pem) - } - // Восстановить файл публичного ключа, если отсутствует - val publicKeyFile = java.io.File(requireContext().filesDir, "ssh_public_${keyMeta.id}") - if (!publicKeyFile.exists() && keyMeta.publicKey.isNotEmpty()) { - try { - // publicKey в SshKey — это строка вида "ssh-ed25519 AAAAC3..." - val parts = keyMeta.publicKey.split(" ") - if (parts.size >= 2) { - val pubKeyBytes = android.util.Base64.decode(parts[1], android.util.Base64.DEFAULT) - publicKeyFile.writeBytes(pubKeyBytes) - } - } catch (_: Exception) {} - } - } - - newServers.forEach { serverRepository.insertServer(it) } - newKeys.forEach { keyRepository.insertKey(it) } - Toast.makeText(requireContext(), R.string.backup_import_success, Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - - private suspend fun getAllServersAndKeysJson(): String { - val servers = serverRepository.getAllServers().first() - val keys = keyRepository.getAllKeys().first() - val keyManager = SshKeyManager(requireContext(), keyRepository) - val keysWithPem = keys.map { key -> - val keyId = key.id - val privateKey = keyManager.getPrivateKey(keyId) - val pem = if (privateKey != null) keyManager.convertPrivateKeyToPem(privateKey) else null - val password = if (pem != null) com.example.sshproxy.security.PemAesUtil.generatePassword() else null - val encryptedPem = if (pem != null && password != null) com.example.sshproxy.security.PemAesUtil.encryptPem(pem, password) else null - mapOf( - "meta" to key, - "encryptedPem" to encryptedPem, - "pemPassword" to password - ) - } - val backup = mapOf( - "servers" to servers, - "keys" to keysWithPem - ) - return Gson().toJson(backup) - } - - private fun showImportDialog() { - val options = arrayOf( - getString(R.string.backup_import_file), - getString(R.string.backup_import_text) - ) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_import_title) - .setItems(options) { _, which -> - when (which) { - 0 -> importBackupFromFile() - 1 -> importBackupFromText() - } - } - .show() - } - - private fun importBackupFromFile() { - importBackupLauncher.launch(arrayOf("application/json")) - } - - private fun importBackupFromText() { - val input = EditText(requireContext()) - input.hint = getString(R.string.backup_import_text_hint) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_import_text_title) - .setView(input) - .setPositiveButton(android.R.string.ok) { _, _ -> - val base64 = input.text.toString().trim() - if (base64.isNotEmpty()) { - try { - val json = decodeBackupFromBase64(base64) - doImportBackup(json) - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - private val REQUEST_CODE_IMPORT_FILE = 1002 - - private fun showBackupExportDialog() { - val options = arrayOf( - getString(R.string.backup_export_file), - getString(R.string.backup_export_text) - ) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_export_title) - .setItems(options) { _, which -> - when (which) { - 0 -> exportBackupToFile() - 1 -> exportBackupAsText() - } - } - .show() - } - - private val exportBackupLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> - uri?.let { - requireContext().contentResolver.openOutputStream(it)?.use { out -> - pendingExportJson?.let { json -> - out.write(json.toByteArray(Charsets.UTF_8)) - } - } - Toast.makeText(requireContext(), R.string.backup_export_success, Toast.LENGTH_SHORT).show() - } - pendingExportJson = null - } - - private val importBackupLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let { - try { - val json = requireContext().contentResolver.openInputStream(it)?.bufferedReader()?.use { reader -> - reader.readText() - } - json?.let { doImportBackup(it) } - } catch (e: Exception) { - Toast.makeText(requireContext(), R.string.backup_import_error, Toast.LENGTH_LONG).show() - } - } - } - - private fun exportBackupToFile() { - viewLifecycleOwner.lifecycleScope.launch { - val json = getAllServersAndKeysJson() - pendingExportJson = json - val fileName = "sshproxy-backup-${System.currentTimeMillis()}.json" - exportBackupLauncher.launch(fileName) - } - } - - private fun exportBackupAsText() { - viewLifecycleOwner.lifecycleScope.launch { - val json = getAllServersAndKeysJson() - val base64 = encodeBackupToBase64(json) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.backup_export_text_title) - .setMessage(base64) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(R.string.backup_export_copy) { _, _ -> - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - clipboard.setPrimaryClip(android.content.ClipData.newPlainText("Backup", base64)) - Toast.makeText(requireContext(), R.string.backup_export_copied, Toast.LENGTH_SHORT).show() - } - .show() - } - } - - private var pendingExportJson: String? = null - private val REQUEST_CODE_EXPORT_FILE = 1001 - // --- Backup/Import helpers --- - private fun encodeBackupToBase64(json: String): String { - return Base64.encodeToString(json.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) - } - - private fun decodeBackupFromBase64(base64: String): String { - return String(Base64.decode(base64, Base64.DEFAULT), Charsets.UTF_8) - } - - private fun parseBackupJson(json: String): Pair, List> { - val gson = Gson() - val mapType = object : TypeToken>() {}.type - val map = gson.fromJson>(json, mapType) - val serversJson = gson.toJson(map["servers"]) - val keysJson = gson.toJson(map["keys"]) - val serversType = object : TypeToken>() {}.type - val keysType = object : TypeToken>() {}.type - val servers: List = gson.fromJson(serversJson, serversType) - val keys: List = gson.fromJson(keysJson, keysType) - return Pair(servers, keys) - } - private var _binding: FragmentSettingsBinding? = null - private val binding get() = _binding!! - - private lateinit var preferencesManager: PreferencesManager - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsBinding.inflate(inflater, container, false) - serverRepository = com.example.sshproxy.data.ServerRepository(requireContext()) - keyRepository = com.example.sshproxy.data.KeyRepository(requireContext()) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - preferencesManager = PreferencesManager(requireContext()) - - loadSettings() - setupListeners() - setupThemeSpinner() - setupLanguageSpinner() - - // Автоматическая генерация ключа при первом запуске - viewLifecycleOwner.lifecycleScope.launch { - val keyManager = SshKeyManager(requireContext(), keyRepository) - val hasKey = keyRepository.getAllKeys().first().isNotEmpty() - if (!hasKey) { - try { - keyManager.generateKeyPair("default") - android.util.Log.d("SettingsFragment", "SSH key generated automatically on first launch") - } catch (e: Exception) { - android.util.Log.e("SettingsFragment", "Failed to generate SSH key: ${e.message}", e) - } - } - } - } - - private fun loadSettings() { - binding.apply { - switchAutoReconnect.isChecked = preferencesManager.isAutoReconnectEnabled() - editHealthCheckInterval.setText((preferencesManager.getHealthCheckInterval() / 1000).toString()) - editMaxReconnectAttempts.setText(preferencesManager.getMaxReconnectAttempts().toString()) - editInitialBackoff.setText((preferencesManager.getInitialBackoffMs() / 1000).toString()) - editMaxBackoff.setText((preferencesManager.getMaxBackoffMs() / 1000).toString()) - editBackoffMultiplier.setText(preferencesManager.getBackoffMultiplier().toString()) - } - } - - private fun setupListeners() { - binding.btnSaveSettings.setOnClickListener { - saveSettings() - } - - binding.btnViewLog.setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, LogFragment()) - .addToBackStack("settings") - .commit() - } - - binding.btnBackup.setOnClickListener { - android.util.Log.d("SettingsFragment", "Backup button clicked") - showBackupExportDialog() - } - - binding.btnImport.setOnClickListener { - android.util.Log.d("SettingsFragment", "Import button clicked") - showImportDialog() - } - - binding.btnSetup.setOnClickListener { - showServerSetupInstructions() - } - - binding.cardSplitTunneling.setOnClickListener { - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, - com.example.sshproxy.ui.splittunneling.SplitTunnelingFragment()) - .addToBackStack("settings") - .commit() - } - - updateSplitTunnelingSubtitle() - } - - override fun onResume() { - super.onResume() - updateSplitTunnelingSubtitle() - } - - private fun updateSplitTunnelingSubtitle() { - val count = preferencesManager.getSplitTunnelingApps().size - binding.tvSplitTunnelingSubtitle.text = if (count == 0) { - getString(R.string.split_tunneling_subtitle_all) - } else { - resources.getQuantityString(R.plurals.split_tunneling_apps_selected, count, count) - } - } - - private fun showServerSetupInstructions() { - lifecycleScope.launch { - try { - val keyManager = SshKeyManager(requireContext(), KeyRepository(requireContext())) - val publicKey = keyManager.getActivePublicKey() - - if (publicKey.isNotEmpty()) { - ServerInstructionsDialog.newInstance(publicKey) - .show(parentFragmentManager, "server_instructions") - } else { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.no_ssh_key)) - .setMessage(getString(com.example.sshproxy.R.string.please_generate_ssh_key)) - .setPositiveButton(getString(com.example.sshproxy.R.string.ok), null) - .show() - } - } catch (e: Exception) { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.error)) - .setMessage(getString(com.example.sshproxy.R.string.failed_to_get_ssh_key, e.message)) - .setPositiveButton(getString(com.example.sshproxy.R.string.ok), null) - .show() - } - } - } - - private fun saveSettings() { - try { - binding.apply { - preferencesManager.setAutoReconnectEnabled(switchAutoReconnect.isChecked) - - val healthCheckInterval = editHealthCheckInterval.text.toString().toLongOrNull() ?: 30 - preferencesManager.setHealthCheckInterval(healthCheckInterval * 1000) - - val maxAttempts = editMaxReconnectAttempts.text.toString().toIntOrNull() ?: 10 - preferencesManager.setMaxReconnectAttempts(maxAttempts) - - val initialBackoff = editInitialBackoff.text.toString().toLongOrNull() ?: 1 - preferencesManager.setInitialBackoffMs(initialBackoff * 1000) - - val maxBackoff = editMaxBackoff.text.toString().toLongOrNull() ?: 300 - preferencesManager.setMaxBackoffMs(maxBackoff * 1000) - - val backoffMultiplier = editBackoffMultiplier.text.toString().toFloatOrNull() ?: 2.0f - preferencesManager.setBackoffMultiplier(backoffMultiplier) - } - - Toast.makeText(context, getString(com.example.sshproxy.R.string.settings_saved), Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(context, getString(com.example.sshproxy.R.string.error_saving_settings, e.message), Toast.LENGTH_LONG).show() - } - } - - private fun setupLanguageSpinner() { - val languages = resources.getStringArray(com.example.sshproxy.R.array.language_entries) - val languageValues = resources.getStringArray(com.example.sshproxy.R.array.language_values) - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, languages) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.spinnerLanguage.adapter = adapter - - val currentLanguage = preferencesManager.getLanguage() - val currentLanguageIndex = languageValues.indexOf(currentLanguage) - if (currentLanguageIndex != -1) { - binding.spinnerLanguage.setSelection(currentLanguageIndex) - } - - binding.spinnerLanguage.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val selectedLanguage = languageValues[position] - preferencesManager.setLanguage(selectedLanguage) - applyLanguage(selectedLanguage) - } - - override fun onNothingSelected(parent: AdapterView<*>?) {} - } - } - - private fun applyLanguage(language: String) { - val localeList = if (language == "system") { - LocaleListCompat.getEmptyLocaleList() - } else { - LocaleListCompat.forLanguageTags(language) - } - AppCompatDelegate.setApplicationLocales(localeList) - } - - private fun setupThemeSpinner() { - val themes = resources.getStringArray(com.example.sshproxy.R.array.theme_entries) - val themeValues = resources.getStringArray(com.example.sshproxy.R.array.theme_values) - - // Добавим отладочный лог - android.util.Log.d("SettingsFragment", "Available themes: ${themes.joinToString()}") - android.util.Log.d("SettingsFragment", "Available theme values: ${themeValues.joinToString()}") - - val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_item, themes) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.spinnerTheme.adapter = adapter - - val currentTheme = preferencesManager.getTheme() - android.util.Log.d("SettingsFragment", "Current theme from preferences: $currentTheme") - - val currentThemeIndex = themeValues.indexOf(currentTheme) - if (currentThemeIndex != -1) { - binding.spinnerTheme.setSelection(currentThemeIndex) - } - - var isInitialLoad = true - binding.spinnerTheme.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - // Игнорируем первый вызов при загрузке - if (isInitialLoad) { - isInitialLoad = false - return - } - - val selectedTheme = themeValues[position] - android.util.Log.d("SettingsFragment", "Theme selected: $selectedTheme, current: ${preferencesManager.getTheme()}") - - if (selectedTheme != preferencesManager.getTheme()) { - preferencesManager.setTheme(selectedTheme) - android.util.Log.d("SettingsFragment", "Theme saved to preferences: $selectedTheme") - - // Применяем тему немедленно - when (selectedTheme) { - "light" -> { - android.util.Log.d("SettingsFragment", "Applying light theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - } - "dark" -> { - android.util.Log.d("SettingsFragment", "Applying dark theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - } - else -> { - android.util.Log.d("SettingsFragment", "Applying system theme") - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - } - } - - // Показываем сообщение и перезапускаем активити - if (isAdded && !isDetached && activity != null) { - Toast.makeText(context, "Theme changed to $selectedTheme", Toast.LENGTH_SHORT).show() - - // Перезапускаем активити - android.util.Log.d("SettingsFragment", "Recreating activity") - requireActivity().recreate() - } - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) {} - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt deleted file mode 100644 index ebf8e33..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeyChoiceFragment.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.example.sshproxy.MainActivity -import com.example.sshproxy.databinding.FragmentKeyChoiceBinding - -class KeyChoiceFragment : Fragment() { - private var _binding: FragmentKeyChoiceBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeyChoiceBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.btnGenerate.setOnClickListener { - (activity as? MainActivity)?.navigateToKeyGeneration() - } - - binding.btnSkip.setOnClickListener { - // Переход к добавлению сервера без генерации ключа - (activity as? MainActivity)?.navigateToAddServer() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt deleted file mode 100644 index 05f576b..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeyGenerationFragment.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.MainActivity -import com.example.sshproxy.R -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentKeyGenerationBinding -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first - -class KeyGenerationFragment : Fragment() { - private var _binding: FragmentKeyGenerationBinding? = null - private val binding get() = _binding!! - private var publicKey: String = "" - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeyGenerationBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - checkOrGenerateKey() - - binding.btnCopyKey.setOnClickListener { - copyPublicKey() - } - - binding.btnServerSetup.setOnClickListener { - showServerSetupInstructions() - } - - binding.btnNext.setOnClickListener { - (activity as? MainActivity)?.navigateToAddServer() - } - } - - private fun checkOrGenerateKey() { - lifecycleScope.launch { - val keyRepository = KeyRepository(requireContext()) - val keyManager = SshKeyManager(requireContext(), keyRepository) - val preferencesManager = com.example.sshproxy.data.PreferencesManager(requireContext()) - val allKeys = keyRepository.getAllKeys().first() - if (allKeys.isNotEmpty()) { - // Если есть хотя бы один ключ, не генерируем новый, просто используем первый - val existingKey = allKeys.first() - publicKey = existingKey.publicKey - binding.tvPublicKey.text = publicKey - preferencesManager.setActiveKeyId(existingKey.id) - } else { - try { - val generatedKey = keyManager.generateKeyPair("Default Key") - publicKey = generatedKey.publicKey - binding.tvPublicKey.text = publicKey - preferencesManager.setActiveKeyId(generatedKey.id) - } catch (e: Exception) { - binding.tvPublicKey.text = getString(com.example.sshproxy.R.string.error_generating_key_with_message, e.message) - Toast.makeText(context, getString(com.example.sshproxy.R.string.failed_to_generate_ssh_key), Toast.LENGTH_LONG).show() - } - } - } - } - - private fun copyPublicKey() { - if (publicKey.isNotEmpty()) { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(getString(R.string.ssh_public_key), publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, getString(R.string.public_key_copied), Toast.LENGTH_SHORT).show() - } - } - - private fun showServerSetupInstructions() { - if (publicKey.isNotEmpty()) { - ServerInstructionsDialog.newInstance(publicKey) - .show(parentFragmentManager, "server_instructions") - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt deleted file mode 100644 index f2efb45..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/KeySetupFragment.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.SshKey -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.databinding.FragmentKeySetupBinding -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch - -class KeySetupFragment : Fragment() { - private var _binding: FragmentKeySetupBinding? = null - private val binding get() = _binding!! - private lateinit var keyManager: SshKeyManager - private lateinit var preferencesManager: PreferencesManager - private var currentKey: SshKey? = null - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentKeySetupBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val keyRepository = KeyRepository(requireContext()) - keyManager = SshKeyManager(requireContext(), keyRepository) - preferencesManager = PreferencesManager(requireContext()) - - lifecycleScope.launch { - val allKeys = keyRepository.getAllKeys().first() - if (allKeys.isNotEmpty()) { - // Если есть хотя бы один ключ, не генерируем новый, просто используем первый - currentKey = allKeys.firstOrNull() - // Можно выставить активный, если нужно - currentKey?.let { preferencesManager.setActiveKeyId(it.id) } - } else { - // Только если ключей нет вообще, генерируем новый - currentKey = keyManager.generateKeyPair("Default Key") - currentKey?.let { preferencesManager.setActiveKeyId(it.id) } - } - - if (currentKey != null) { - binding.tvPublicKey.text = currentKey!!.publicKey - } else { - binding.tvPublicKey.text = "Error: Could not load or generate a key." - binding.btnNext.isEnabled = false - } - } - - binding.btnCopyKey.setOnClickListener { - copyKeyToClipboard() - } - - binding.btnServerInstructions.setOnClickListener { - showServerInstructions() - } - - binding.btnNext.setOnClickListener { - val fragment = ServerSetupFragment() - parentFragmentManager.beginTransaction() - .replace(com.example.sshproxy.R.id.fragmentContainer, fragment) - .addToBackStack(null) - .commit() - } - } - - private fun copyKeyToClipboard() { - currentKey?.let { - val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("SSH Public Key", it.publicKey) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show() - } - } - - private fun showServerInstructions() { - currentKey?.let { - ServerInstructionsDialog.newInstance(it.publicKey).show(parentFragmentManager, "instructions") - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt b/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt deleted file mode 100644 index c7a47a1..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/ServerInstructionsDialog.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.app.Dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.os.Bundle -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import com.example.sshproxy.databinding.DialogServerInstructionsBinding -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -class ServerInstructionsDialog : DialogFragment() { - - companion object { - private const val ARG_PUBLIC_KEY = "public_key" - fun newInstance(publicKey: String): ServerInstructionsDialog { - val args = Bundle() - args.putString(ARG_PUBLIC_KEY, publicKey) - val fragment = ServerInstructionsDialog() - fragment.arguments = args - return fragment - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogServerInstructionsBinding.inflate(layoutInflater) - val publicKey = arguments?.getString(ARG_PUBLIC_KEY) - - if (publicKey != null) { - val instructions = generateInstructions(publicKey) - binding.tvInstructions.text = instructions - binding.btnCopyInstructions.setOnClickListener { - copyToClipboard(instructions) - } - } else { - binding.tvInstructions.text = getString(com.example.sshproxy.R.string.no_ssh_key_configured) + "\n" + getString(com.example.sshproxy.R.string.please_generate_ssh_key) - binding.btnCopyInstructions.isEnabled = false - } - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(com.example.sshproxy.R.string.server_setup_instructions_title)) - .setView(binding.root) - .setPositiveButton(getString(com.example.sshproxy.R.string.close), null) - .setNegativeButton(getString(com.example.sshproxy.R.string.skip_for_now)) { _, _ -> dismiss() } - .create() - return dialog - } - - private fun generateInstructions(publicKey: String): String { - return """ -# SSH Server Setup Instructions - -## Quick automated setup (copy and run as root): -bash <(cat <<'SCRIPT' -# Detect package manager -if command -v apt-get >/dev/null 2>&1; then - PKG_UPDATE="apt-get update" - PKG_INSTALL="apt-get install -y" -elif command -v yum >/dev/null 2>&1; then - PKG_UPDATE="yum check-update || true" - PKG_INSTALL="yum install -y" -elif command -v dnf >/dev/null 2>&1; then - PKG_UPDATE="dnf check-update || true" - PKG_INSTALL="dnf install -y" -elif command -v pacman >/dev/null 2>&1; then - PKG_UPDATE="pacman -Sy" - PKG_INSTALL="pacman -S --noconfirm" -else - echo "No supported package manager found!"; exit 1 -fi - -# Auto-detect SSH service name -if systemctl is-active --quiet ssh; then - SSH_SERVICE="ssh" -elif systemctl is-active --quiet sshd; then - SSH_SERVICE="sshd" -else - SSH_SERVICE="ssh" # Default to ssh for Ubuntu/Debian -fi - -echo "SSH service detected: ${'$'}SSH_SERVICE" - -# Setup user -adduser --disabled-password --gecos "" --home /home/user user -usermod -s /usr/sbin/nologin user -install -d -m 700 -o user -g user /home/user/.ssh -echo "restrict,port-forwarding $publicKey" > /home/user/.ssh/authorized_keys -chown user:user /home/user/.ssh/authorized_keys -chmod 600 /home/user/.ssh/authorized_keys - -# Configure SSH -cat >/etc/ssh/sshd_config.d/user.conf < - if (binding.etName.text.isNullOrEmpty() && !text.isNullOrEmpty()) { - binding.etName.setText(text.toString()) - } - } - - // Toggle advanced options - binding.btnToggleAdvanced.setOnClickListener { - val isVisible = binding.advancedOptionsLayout.visibility == View.VISIBLE - binding.advancedOptionsLayout.visibility = if (isVisible) View.GONE else View.VISIBLE - binding.btnToggleAdvanced.setIconResource( - if (isVisible) com.example.sshproxy.R.drawable.ic_expand_more - else com.example.sshproxy.R.drawable.ic_expand_less - ) - } - - binding.btnSave.setOnClickListener { - saveServerAndFinish() - } - - binding.btnSkip.setOnClickListener { - finishSetup() - } - } - - private fun saveServerAndFinish() { - val host = binding.etHost.text.toString().trim() - - if (host.isEmpty()) { - Toast.makeText(context, "Please enter a host", Toast.LENGTH_SHORT).show() - return - } - - val nameText = binding.etName.text?.toString()?.trim() - val name = if (!nameText.isNullOrEmpty()) nameText else host - val user = binding.etUser.text?.toString()?.trim() ?: "sshproxy" - val port = binding.etPort.text?.toString()?.toIntOrNull() ?: 22 - - val server = Server( - name = name, - host = host, - port = port, - username = user - ) - - lifecycleScope.launch { - serverRepository.insertServer(server) - Toast.makeText(context, "Server added", Toast.LENGTH_SHORT).show() - finishSetup() - } - } - - private fun finishSetup() { - setupManager.completeSetup() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt b/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt deleted file mode 100644 index b8298f3..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/SetupManager.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.content.Context -import android.content.SharedPreferences -import com.example.sshproxy.MainActivity - -class SetupManager(private val context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences("setup_prefs", Context.MODE_PRIVATE) - - companion object { - private const val KEY_SETUP_COMPLETE = "setup_complete" - private const val KEY_FIRST_LAUNCH = "first_launch" - } - - fun isSetupComplete(): Boolean { - return prefs.getBoolean(KEY_SETUP_COMPLETE, false) - } - - fun isFirstLaunch(): Boolean { - return prefs.getBoolean(KEY_FIRST_LAUNCH, true) - } - - fun showSetupFlow() { - prefs.edit().putBoolean(KEY_FIRST_LAUNCH, false).apply() - (context as? MainActivity)?.showSetupFlow() - } - - fun completeSetup() { - prefs.edit() - .putBoolean(KEY_SETUP_COMPLETE, true) - .putBoolean(KEY_FIRST_LAUNCH, false) - .apply() - (context as? MainActivity)?.navigateToHome() - } - - fun markFirstLaunchComplete() { - prefs.edit().putBoolean(KEY_FIRST_LAUNCH, false).apply() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt b/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt deleted file mode 100644 index 38db5ef..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/setup/WelcomeFragment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.sshproxy.ui.setup - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.example.sshproxy.MainActivity -import com.example.sshproxy.databinding.FragmentWelcomeBinding - -class WelcomeFragment : Fragment() { - private var _binding: FragmentWelcomeBinding? = null - private val binding get() = _binding!! - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentWelcomeBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.btnGetStarted.setOnClickListener { - (activity as? MainActivity)?.navigateToKeySetup() - } - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt deleted file mode 100644 index e6e6cbf..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppInfo.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -data class AppInfo( - val packageName: String, - val appName: String, - val isSystem: Boolean, - val isSelected: Boolean -) diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt deleted file mode 100644 index 6951707..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/AppListAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.graphics.drawable.Drawable -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.example.sshproxy.databinding.ItemAppBinding - -class AppListAdapter( - private val onToggle: (packageName: String, selected: Boolean) -> Unit -) : ListAdapter(AppDiffCallback()) { - - private val iconCache = HashMap() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder { - val binding = ItemAppBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return AppViewHolder(binding) - } - - override fun onBindViewHolder(holder: AppViewHolder, position: Int) { - holder.bind(getItem(position), iconCache, onToggle) - } - - class AppViewHolder(private val binding: ItemAppBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind( - app: AppInfo, - iconCache: HashMap, - onToggle: (String, Boolean) -> Unit - ) { - binding.tvAppName.text = app.appName - binding.tvPackageName.text = app.packageName - - val icon = iconCache.getOrPut(app.packageName) { - try { - binding.root.context.packageManager.getApplicationIcon(app.packageName) - } catch (e: Exception) { - binding.root.context.packageManager.defaultActivityIcon - } - } - binding.imgAppIcon.setImageDrawable(icon) - - // Remove listener before setting checked state to avoid callback loops - binding.checkboxSelected.setOnCheckedChangeListener(null) - binding.checkboxSelected.isChecked = app.isSelected - binding.checkboxSelected.setOnCheckedChangeListener { _, isChecked -> - onToggle(app.packageName, isChecked) - } - - binding.root.setOnClickListener { - binding.checkboxSelected.toggle() - } - } - } - - class AppDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AppInfo, newItem: AppInfo) = - oldItem.packageName == newItem.packageName - - override fun areContentsTheSame(oldItem: AppInfo, newItem: AppInfo) = - oldItem.isSelected == newItem.isSelected && oldItem.appName == newItem.appName - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt deleted file mode 100644 index a9bd2c0..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingFragment.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.example.sshproxy.R -import com.example.sshproxy.SshProxyService -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.databinding.FragmentSplitTunnelingBinding -import com.google.android.material.snackbar.Snackbar -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class SplitTunnelingFragment : Fragment() { - - private var _binding: FragmentSplitTunnelingBinding? = null - private val binding get() = _binding!! - - private val viewModel: SplitTunnelingViewModel by viewModels { - SplitTunnelingViewModel.Factory( - requireContext().packageManager, - PreferencesManager(requireContext()) - ) - } - - private lateinit var adapter: AppListAdapter - private var reconnectSnackbar: Snackbar? = null - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSplitTunnelingBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setupToolbar() - setupRecyclerView() - setupSearch() - setupSystemAppsToggle() - observeViewModel() - observeVpnState() - } - - private fun setupToolbar() { - binding.toolbar.setNavigationOnClickListener { - parentFragmentManager.popBackStack() - } - } - - private fun setupRecyclerView() { - adapter = AppListAdapter { packageName, selected -> - viewModel.toggleApp(packageName, selected) - } - binding.recyclerApps.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerApps.adapter = adapter - } - - private fun setupSearch() { - binding.editSearch.addTextChangedListener { editable -> - viewModel.setSearchQuery(editable?.toString() ?: "") - } - } - - private fun setupSystemAppsToggle() { - binding.switchSystemApps.setOnCheckedChangeListener { _, isChecked -> - viewModel.setShowSystemApps(isChecked) - } - } - - private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - viewModel.isLoading.collectLatest { loading -> - binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE - binding.recyclerApps.visibility = if (loading) View.GONE else View.VISIBLE - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.filteredApps.collectLatest { apps -> - adapter.submitList(apps) - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.selectedCount.collectLatest { count -> - binding.tvSelectedCount.text = if (count == 0) { - getString(R.string.split_tunneling_no_apps_selected) - } else { - resources.getQuantityString( - R.plurals.split_tunneling_apps_selected, count, count - ) - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.showSystemApps.collectLatest { show -> - if (binding.switchSystemApps.isChecked != show) { - binding.switchSystemApps.isChecked = show - } - } - } - } - - private fun observeVpnState() { - viewLifecycleOwner.lifecycleScope.launch { - SshProxyService.isRunning.collectLatest { running -> - if (running) { - if (reconnectSnackbar == null) { - reconnectSnackbar = Snackbar.make( - binding.root, - R.string.split_tunneling_reconnect_notice, - Snackbar.LENGTH_INDEFINITE - ).also { it.show() } - } - } else { - reconnectSnackbar?.dismiss() - reconnectSnackbar = null - } - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - reconnectSnackbar = null - _binding = null - } -} diff --git a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt b/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt deleted file mode 100644 index 04b3935..0000000 --- a/app/src/main/java/com/example/sshproxy/ui/splittunneling/SplitTunnelingViewModel.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.example.sshproxy.ui.splittunneling - -import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.example.sshproxy.data.PreferencesManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class SplitTunnelingViewModel( - private val packageManager: PackageManager, - private val preferencesManager: PreferencesManager -) : ViewModel() { - - private val _allApps = MutableStateFlow>(emptyList()) - - private val _filteredApps = MutableStateFlow>(emptyList()) - val filteredApps: StateFlow> = _filteredApps.asStateFlow() - - private val _selectedCount = MutableStateFlow(0) - val selectedCount: StateFlow = _selectedCount.asStateFlow() - - private val _isLoading = MutableStateFlow(true) - val isLoading: StateFlow = _isLoading.asStateFlow() - - private val _showSystemApps = MutableStateFlow(false) - val showSystemApps: StateFlow = _showSystemApps.asStateFlow() - - private var searchQuery: String = "" - - init { - loadApps() - } - - private fun loadApps() { - viewModelScope.launch { - val apps = withContext(Dispatchers.IO) { - val savedPackages = preferencesManager.getSplitTunnelingApps().toMutableSet() - val installedApps = packageManager.getInstalledApplications(PackageManager.GET_META_DATA) - val installedSet = installedApps.map { it.packageName }.toSet() - - // Clean up stale entries (uninstalled apps) - val stale = savedPackages - installedSet - if (stale.isNotEmpty()) { - savedPackages -= stale - preferencesManager.setSplitTunnelingApps(savedPackages) - } - - installedApps - .map { appInfo -> - val isSystem = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 - AppInfo( - packageName = appInfo.packageName, - appName = packageManager.getApplicationLabel(appInfo).toString(), - isSystem = isSystem, - isSelected = appInfo.packageName in savedPackages - ) - } - .sortedWith(compareBy({ !it.isSelected }, { it.appName.lowercase() })) - } - _allApps.value = apps - _selectedCount.value = apps.count { it.isSelected } - _isLoading.value = false - applyFilter() - } - } - - fun setSearchQuery(query: String) { - searchQuery = query - applyFilter() - } - - fun setShowSystemApps(show: Boolean) { - _showSystemApps.value = show - applyFilter() - } - - private fun applyFilter() { - val base = _allApps.value - val showSystem = _showSystemApps.value - val query = searchQuery.trim().lowercase() - - _filteredApps.value = base - .filter { if (!showSystem) !it.isSystem else true } - .filter { if (query.isNotEmpty()) it.appName.lowercase().contains(query) else true } - } - - fun toggleApp(packageName: String, selected: Boolean) { - _allApps.value = _allApps.value.map { app -> - if (app.packageName == packageName) app.copy(isSelected = selected) else app - } - _selectedCount.value = _allApps.value.count { it.isSelected } - applyFilter() - - val newSet = _allApps.value - .filter { it.isSelected } - .map { it.packageName } - .toSet() - preferencesManager.setSplitTunnelingApps(newSet) - } - - class Factory( - private val packageManager: PackageManager, - private val preferencesManager: PreferencesManager - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - SplitTunnelingViewModel(packageManager, preferencesManager) as T - } -} From ac5566e3fbba6dcf979917734bd7323ae938e333 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:11:55 +0300 Subject: [PATCH 019/366] Delete app/src/main/java/com/example/sshproxy/widget directory --- .../widget/VPNStatusWidgetProvider.kt | 216 ------------------ 1 file changed, 216 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt diff --git a/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt b/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt deleted file mode 100644 index e359a71..0000000 --- a/app/src/main/java/com/example/sshproxy/widget/VPNStatusWidgetProvider.kt +++ /dev/null @@ -1,216 +0,0 @@ -package com.example.sshproxy.widget - -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.appwidget.AppWidgetProvider -import android.content.Context -import android.content.Intent -import android.widget.RemoteViews -import com.example.sshproxy.R - -import android.os.Build -import com.example.sshproxy.SshProxyService -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.delay -import android.os.Handler -import android.os.Looper -import android.view.View -import java.util.Timer -import java.util.TimerTask - -class VPNStatusWidgetProvider : AppWidgetProvider() { - companion object { - const val ACTION_TOGGLE_VPN = "com.example.sshproxy.widget.ACTION_TOGGLE_VPN" - private var lastVpnConnected = false - private var blinkTimer: Timer? = null - private var isBlinkOn = true - - private fun isVpnServiceRunning(context: Context): Boolean { - val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager - for (service in manager.getRunningServices(Integer.MAX_VALUE)) { - if (SshProxyService::class.java.name == service.service.className) { - return true - } - } - return false - } - fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { - val views = RemoteViews(context.packageName, R.layout.widget_vpn_status) - val prefs = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - val serverId = prefs.getLong("active_server_id", -1) - - if (serverId == -1L) { - views.setViewVisibility(R.id.tv_widget_message, View.VISIBLE) - views.setViewVisibility(R.id.iv_status_circle, View.GONE) - views.setViewVisibility(R.id.iv_ssh_icon, View.GONE) - } else { - views.setViewVisibility(R.id.tv_widget_message, View.GONE) - views.setViewVisibility(R.id.iv_status_circle, View.VISIBLE) - views.setViewVisibility(R.id.iv_ssh_icon, View.VISIBLE) - - // Проверяем реальное состояние VPN Service - val isServiceRunning = isVpnServiceRunning(context) - - // Если сервис не запущен, сбрасываем флаги - if (!isServiceRunning) { - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - } - - val isVpnRunning = prefs.getBoolean("vpn_running", false) && isServiceRunning - val isConnecting = prefs.getBoolean("vpn_connecting", false) - lastVpnConnected = isVpnRunning - - android.util.Log.d("VPNStatusWidget", "updateAppWidget: isVpnRunning=$isVpnRunning, isConnecting=$isConnecting, isBlinkOn=$isBlinkOn, serviceRunning=$isServiceRunning") - - val circleColor = when { - isConnecting -> { - // Blinking when connecting - if (isBlinkOn) { - context.getColor(R.color.widget_connecting) - } else { - context.getColor(R.color.widget_disconnected) - } - } - isVpnRunning -> { - context.getColor(R.color.widget_connected) - } - else -> { - context.getColor(R.color.widget_disconnected) - } - } - - views.setInt(R.id.iv_status_circle, "setColorFilter", circleColor) - } - - // The icon and background remain static - views.setInt(R.id.iv_ssh_icon, "setImageResource", R.drawable.ic_ssh) - views.setInt(R.id.widget_root, "setBackgroundResource", R.drawable.widget_background) - - - val intent = Intent(context, VPNStatusWidgetProvider::class.java).apply { - action = ACTION_TOGGLE_VPN - } - val pendingIntent = PendingIntent.getBroadcast(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - views.setOnClickPendingIntent(R.id.btnToggleVpn, pendingIntent) - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - - fun startBlinking(context: Context) { - stopBlinking() - blinkTimer = Timer() - blinkTimer?.scheduleAtFixedRate(object : TimerTask() { - override fun run() { - isBlinkOn = !isBlinkOn - Handler(Looper.getMainLooper()).post { - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } - }, 0, 500) // Моргание каждые 500мс - } - - fun stopBlinking(context: Context? = null) { - android.util.Log.d("VPNStatusWidget", "stopBlinking called") - blinkTimer?.cancel() - blinkTimer = null - isBlinkOn = true - - // Если передан контекст, обновляем виджет немедленно - if (context != null) { - android.os.Handler(android.os.Looper.getMainLooper()).post { - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } - } - } - - override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - - override fun onReceive(context: Context, intent: Intent) { - super.onReceive(context, intent) - android.util.Log.d("VPNStatusWidget", "onReceive: action=${intent.action}") - if (intent.action == ACTION_TOGGLE_VPN) { - android.util.Log.d("VPNStatusWidget", "ACTION_TOGGLE_VPN received") - val prefs = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - val isVpnRunning = prefs.getBoolean("vpn_running", false) - val serverId = prefs.getLong("active_server_id", -1) - if (serverId == -1L && !isVpnRunning) { - // Do nothing if no server is selected - return - } - android.util.Log.d("VPNStatusWidget", "isVpnRunning=$isVpnRunning, serverId=$serverId") - if (isVpnRunning) { - android.util.Log.d("VPNStatusWidget", "Stopping VPN") - // Проверяем что сервис действительно запущен перед остановкой - if (isVpnServiceRunning(context)) { - // Отключение VPN можно делать напрямую - val serviceIntent = Intent(context, SshProxyService::class.java) - serviceIntent.action = SshProxyService.ACTION_STOP - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(serviceIntent) - } else { - context.startService(serviceIntent) - } - } catch (e: Exception) { - android.util.Log.e("VPNStatusWidget", "Error stopping VPN service", e) - // Сбрасываем флаги если сервис не запущен - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - // Обновляем виджет - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } else { - android.util.Log.d("VPNStatusWidget", "VPN service not running, resetting flags") - // Сбрасываем флаги если сервис не запущен - prefs.edit() - .putBoolean("vpn_running", false) - .putBoolean("vpn_connecting", false) - .apply() - // Обновляем виджет - val appWidgetManager = AppWidgetManager.getInstance(context) - val thisWidget = android.content.ComponentName(context, VPNStatusWidgetProvider::class.java) - val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) - for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) - } - } - } else { - android.util.Log.d("VPNStatusWidget", "Starting VPN via VpnPermissionActivity") - // Для подключения VPN используем прозрачную activity - val activityIntent = Intent(context, com.example.sshproxy.VpnPermissionActivity::class.java).apply { - putExtra("server_id", serverId) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - } - context.startActivity(activityIntent) - } - } - } -} \ No newline at end of file From 87767bef7cb2e8b5be2a44485309bdc928073101 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:13:23 +0300 Subject: [PATCH 020/366] Delete app/src/main/java/com/example/sshproxy/security directory --- .../sshproxy/security/KeyPasswordKeystore.kt | 80 ------- .../sshproxy/security/KeyPasswordStorage.kt | 44 ---- .../sshproxy/security/KeystoreManager.kt | 163 -------------- .../sshproxy/security/KnownHostsManager.kt | 208 ------------------ .../example/sshproxy/security/PemAesUtil.kt | 61 ----- .../security/SecureHostKeyVerifier.kt | 155 ------------- 6 files changed, 711 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt delete mode 100644 app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt delete mode 100644 app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt delete mode 100644 app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt delete mode 100644 app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt delete mode 100644 app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt diff --git a/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt b/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt deleted file mode 100644 index 11ced4e..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeyPasswordKeystore.kt +++ /dev/null @@ -1,80 +0,0 @@ - -package com.example.sshproxy.security - -import android.content.Context - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 -import android.util.Log -import java.security.KeyStore -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.IvParameterSpec - -object KeyPasswordKeystore { - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS_PREFIX = "pem_key_password_" - private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" - private const val PASSWORD_LENGTH = 100 - private const val CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>/?|" - private const val IV_LENGTH = 16 - - fun getOrCreatePassword(context: Context, keyId: String): String { - val keyAlias = KEY_ALIAS_PREFIX + keyId - val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } - if (keyStore.containsAlias(keyAlias)) { - val secretKey = keyStore.getKey(keyAlias, null) as SecretKey - val (iv, encrypted) = KeyPasswordStorage.getEncryptedPassword(context, keyId) - ?: return "" - return decryptPassword(secretKey, iv, encrypted) - } else { - val password = generatePassword() - val (secretKey, iv, encrypted) = generateSecretKeyAndEncryptPassword(keyAlias, password) - KeyPasswordStorage.storeEncryptedPassword(context, keyId, iv, encrypted) - return password - } - } - - private fun generateSecretKeyAndEncryptPassword(keyAlias: String, password: String): Triple { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - .build() - keyGenerator.init(keyGenParameterSpec) - val secretKey = keyGenerator.generateKey() - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - val iv = cipher.iv - val encrypted = cipher.doFinal(password.toByteArray(Charsets.UTF_8)) - return Triple(secretKey, iv, encrypted) - } - - private fun decryptPassword(secretKey: SecretKey, iv: ByteArray, encrypted: ByteArray): String { - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - val spec = javax.crypto.spec.GCMParameterSpec(128, iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - val plain = cipher.doFinal(encrypted) - return String(plain, Charsets.UTF_8) - } - - private fun generatePassword(): String { - val random = SecureRandom() - return (1..PASSWORD_LENGTH) - .map { CHARSET[random.nextInt(CHARSET.length)] } - .joinToString("") - } - - // generateSecretKey больше не используется - - // encryptAndStorePassword больше не используется - - // decryptPassword(secretKey) больше не используется -} diff --git a/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt b/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt deleted file mode 100644 index f227293..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeyPasswordStorage.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.content.SharedPreferences -import android.util.Base64 - -object KeyPasswordStorage { - // Для хранения зашифрованного PEM-файла (salt:iv:ciphertext в одной строке) - private const val PEM_PREFIX = "pem_" - fun storeEncryptedPem(context: Context, keyId: String, encryptedPem: String) { - getPrefs(context).edit().putString(PEM_PREFIX + keyId, encryptedPem).apply() - } - fun getEncryptedPem(context: Context, keyId: String): String? { - return getPrefs(context).getString(PEM_PREFIX + keyId, null) - } - private const val PREFS_NAME = "pem_key_password_storage" - private const val KEY_IV_PREFIX = "iv_" - private const val KEY_ENC_PREFIX = "enc_" - - private fun getPrefs(context: Context): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - fun storeEncryptedPassword(context: Context, keyId: String, iv: ByteArray, encrypted: ByteArray) { - val prefs = getPrefs(context) - prefs.edit() - .putString(KEY_IV_PREFIX + keyId, Base64.encodeToString(iv, Base64.NO_WRAP)) - .putString(KEY_ENC_PREFIX + keyId, Base64.encodeToString(encrypted, Base64.NO_WRAP)) - .apply() - } - - fun getEncryptedPassword(context: Context, keyId: String): Pair? { - val prefs = getPrefs(context) - val ivB64 = prefs.getString(KEY_IV_PREFIX + keyId, null) - val encB64 = prefs.getString(KEY_ENC_PREFIX + keyId, null) - if (ivB64 != null && encB64 != null) { - return Pair( - Base64.decode(ivB64, Base64.NO_WRAP), - Base64.decode(encB64, Base64.NO_WRAP) - ) - } - return null - } -} diff --git a/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt b/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt deleted file mode 100644 index ac3441f..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KeystoreManager.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.example.sshproxy.security - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Log -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec - -/** - * Manages encryption/decryption using Android Keystore for secure SSH private key storage - */ -class KeystoreManager { - - companion object { - private const val TAG = "KeystoreManager" - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val KEY_ALIAS_PREFIX = "ssh_key_" - private const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" - private const val GCM_IV_LENGTH = 12 - private const val GCM_TAG_LENGTH = 128 - } - - private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { - load(null) - } - - /** - * Generate or get existing encryption key for SSH private key - */ - private fun getOrCreateSecretKey(keyId: String): SecretKey { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - - return if (keyStore.containsAlias(keyAlias)) { - keyStore.getKey(keyAlias, null) as SecretKey - } else { - generateSecretKey(keyAlias) - } - } - - /** - * Generate new AES key in Android Keystore - */ - private fun generateSecretKey(keyAlias: String): SecretKey { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - val keyGenParameterSpec = KeyGenParameterSpec.Builder( - keyAlias, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - // Require user authentication for key access (optional - can be enabled for extra security) - // .setUserAuthenticationRequired(true) - // .setUserAuthenticationValidityDurationSeconds(300) // 5 minutes - .build() - - keyGenerator.init(keyGenParameterSpec) - return keyGenerator.generateKey() - } - - /** - * Encrypt private key data - * @param keyId SSH key identifier - * @param plaintext Private key content as byte array - * @return EncryptedData containing ciphertext and IV - */ - fun encryptPrivateKey(keyId: String, plaintext: ByteArray): EncryptedData { - try { - val secretKey = getOrCreateSecretKey(keyId) - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext) - - Log.d(TAG, "Successfully encrypted private key for keyId: $keyId") - return EncryptedData(ciphertext, iv) - } catch (e: Exception) { - Log.e(TAG, "Failed to encrypt private key for keyId: $keyId", e) - throw SecurityException("Failed to encrypt private key", e) - } - } - - /** - * Decrypt private key data - * @param keyId SSH key identifier - * @param encryptedData EncryptedData containing ciphertext and IV - * @return Decrypted private key content as byte array - */ - fun decryptPrivateKey(keyId: String, encryptedData: EncryptedData): ByteArray { - try { - val secretKey = getOrCreateSecretKey(keyId) - val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION) - val spec = GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.iv) - cipher.init(Cipher.DECRYPT_MODE, secretKey, spec) - - val plaintext = cipher.doFinal(encryptedData.ciphertext) - Log.d(TAG, "Successfully decrypted private key for keyId: $keyId") - return plaintext - } catch (e: Exception) { - Log.e(TAG, "Failed to decrypt private key for keyId: $keyId", e) - throw SecurityException("Failed to decrypt private key", e) - } - } - - /** - * Delete encryption key from Android Keystore - * @param keyId SSH key identifier - */ - fun deleteEncryptionKey(keyId: String) { - try { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - if (keyStore.containsAlias(keyAlias)) { - keyStore.deleteEntry(keyAlias) - Log.d(TAG, "Deleted encryption key for keyId: $keyId") - } - } catch (e: Exception) { - Log.e(TAG, "Failed to delete encryption key for keyId: $keyId", e) - } - } - - /** - * Check if encryption key exists for given SSH key ID - */ - fun hasEncryptionKey(keyId: String): Boolean { - return try { - val keyAlias = "$KEY_ALIAS_PREFIX$keyId" - keyStore.containsAlias(keyAlias) - } catch (e: Exception) { - Log.e(TAG, "Failed to check encryption key existence for keyId: $keyId", e) - false - } - } - - /** - * Data class to hold encrypted content and initialization vector - */ - data class EncryptedData( - val ciphertext: ByteArray, - val iv: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as EncryptedData - - if (!ciphertext.contentEquals(other.ciphertext)) return false - if (!iv.contentEquals(other.iv)) return false - - return true - } - - override fun hashCode(): Int { - var result = ciphertext.contentHashCode() - result = 31 * result + iv.contentHashCode() - return result - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt b/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt deleted file mode 100644 index a12a87f..0000000 --- a/app/src/main/java/com/example/sshproxy/security/KnownHostsManager.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.util.Log -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap - -/** - * Manages known SSH host keys for fingerprint validation - * Similar to ~/.ssh/known_hosts but with Android-specific enhancements - */ -class KnownHostsManager(private val context: Context) { - - companion object { - private const val TAG = "KnownHostsManager" - private const val KNOWN_HOSTS_FILE = "known_hosts" - } - - private val knownHosts = ConcurrentHashMap() - private val knownHostsFile = File(context.filesDir, KNOWN_HOSTS_FILE) - - init { - loadKnownHosts() - } - - /** - * Data class representing a host key entry - */ - data class HostKeyInfo( - val hostname: String, - val port: Int, - val keyType: String, - val fingerprint: String, - val publicKey: ByteArray - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as HostKeyInfo - - if (hostname != other.hostname) return false - if (port != other.port) return false - if (keyType != other.keyType) return false - if (fingerprint != other.fingerprint) return false - if (!publicKey.contentEquals(other.publicKey)) return false - - return true - } - - override fun hashCode(): Int { - var result = hostname.hashCode() - result = 31 * result + port - result = 31 * result + keyType.hashCode() - result = 31 * result + fingerprint.hashCode() - result = 31 * result + publicKey.contentHashCode() - return result - } - } - - /** - * Check if we know this host and validate its key - * @param hostname Server hostname/IP - * @param port Server port - * @param publicKey Server's public key - * @param keyType Key type (ssh-rsa, ssh-ed25519, etc.) - * @return HostKeyValidationResult - */ - fun validateHostKey(hostname: String, port: Int, publicKey: ByteArray, keyType: String): HostKeyValidationResult { - val hostKey = generateHostKey(hostname, port) - val fingerprint = generateFingerprint(publicKey) - - Log.d(TAG, "Validating host key for $hostname:$port") - Log.d(TAG, "Key type: $keyType, Fingerprint: $fingerprint") - - val storedFingerprint = knownHosts[hostKey] - - return when { - storedFingerprint == null -> { - Log.i(TAG, "New host $hostname:$port - storing fingerprint") - HostKeyValidationResult.NEW_HOST - } - storedFingerprint == fingerprint -> { - Log.d(TAG, "Host key matches for $hostname:$port") - HostKeyValidationResult.VALID - } - else -> { - Log.w(TAG, "Host key mismatch for $hostname:$port!") - Log.w(TAG, "Stored: $storedFingerprint") - Log.w(TAG, "Received: $fingerprint") - HostKeyValidationResult.KEY_CHANGED - } - } - } - - /** - * Store a host key after validation - * @param hostname Server hostname/IP - * @param port Server port - * @param publicKey Server's public key - * @param keyType Key type - */ - fun storeHostKey(hostname: String, port: Int, publicKey: ByteArray, keyType: String) { - val hostKey = generateHostKey(hostname, port) - val fingerprint = generateFingerprint(publicKey) - - knownHosts[hostKey] = fingerprint - saveKnownHosts() - - Log.i(TAG, "Stored host key for $hostname:$port with fingerprint $fingerprint") - } - - /** - * Get stored fingerprint for a host - * @param hostname Server hostname/IP - * @param port Server port - * @return Stored fingerprint or null if not known - */ - fun getStoredFingerprint(hostname: String, port: Int): String? { - val hostKey = generateHostKey(hostname, port) - return knownHosts[hostKey] - } - - /** - * Remove a host key (for when user wants to reset/remove) - * @param hostname Server hostname/IP - * @param port Server port - */ - fun removeHostKey(hostname: String, port: Int) { - val hostKey = generateHostKey(hostname, port) - knownHosts.remove(hostKey) - saveKnownHosts() - Log.i(TAG, "Removed host key for $hostname:$port") - } - - /** - * Generate SHA-256 fingerprint from public key - */ - private fun generateFingerprint(publicKey: ByteArray): String { - return try { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - "SHA256:" + android.util.Base64.encodeToString(hash, android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) - } catch (e: Exception) { - Log.e(TAG, "Failed to generate fingerprint", e) - "ERROR:${e.message}" - } - } - - /** - * Generate host key identifier - */ - private fun generateHostKey(hostname: String, port: Int): String { - return if (port == 22) hostname else "[$hostname]:$port" - } - - /** - * Load known hosts from file - */ - private fun loadKnownHosts() { - try { - if (!knownHostsFile.exists()) { - Log.d(TAG, "Known hosts file does not exist, starting fresh") - return - } - - knownHostsFile.readLines().forEach { line -> - val trimmed = line.trim() - if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { - val parts = trimmed.split(" ", limit = 2) - if (parts.size == 2) { - knownHosts[parts[0]] = parts[1] - } - } - } - - Log.d(TAG, "Loaded ${knownHosts.size} known hosts") - } catch (e: Exception) { - Log.e(TAG, "Failed to load known hosts", e) - } - } - - /** - * Save known hosts to file - */ - private fun saveKnownHosts() { - try { - val content = knownHosts.entries.joinToString("\n") { "${it.key} ${it.value}" } - knownHostsFile.writeText(content) - Log.d(TAG, "Saved ${knownHosts.size} known hosts to file") - } catch (e: Exception) { - Log.e(TAG, "Failed to save known hosts", e) - } - } - - /** - * Result of host key validation - */ - enum class HostKeyValidationResult { - /** Host is new, key should be stored */ - NEW_HOST, - /** Host key matches stored fingerprint */ - VALID, - /** Host key has changed - potential security issue */ - KEY_CHANGED - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt b/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt deleted file mode 100644 index caf58d2..0000000 --- a/app/src/main/java/com/example/sshproxy/security/PemAesUtil.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.sshproxy.security - -import android.util.Base64 -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.SecretKeyFactory -import javax.crypto.spec.IvParameterSpec -import javax.crypto.spec.PBEKeySpec -import javax.crypto.spec.SecretKeySpec - -object PemAesUtil { - private const val AES_MODE = "AES/CBC/PKCS5Padding" - private const val KEY_LENGTH = 256 - private const val ITERATION_COUNT = 100_000 - private const val SALT_LENGTH = 16 - private const val IV_LENGTH = 16 - private const val PASSWORD_LENGTH = 100 - private const val CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+[]{};:,.<>/?|" - - fun generatePassword(): String { - val random = SecureRandom() - return (1..PASSWORD_LENGTH) - .map { CHARSET[random.nextInt(CHARSET.length)] } - .joinToString("") - } - - fun encryptPem(pem: String, password: String): String { - val salt = ByteArray(SALT_LENGTH) - val iv = ByteArray(IV_LENGTH) - SecureRandom().nextBytes(salt) - SecureRandom().nextBytes(iv) - val key = deriveKey(password, salt) - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) - val ciphertext = cipher.doFinal(pem.toByteArray(Charsets.UTF_8)) - // Формат: salt:iv:ciphertext (все base64) - return Base64.encodeToString(salt, Base64.NO_WRAP) + ":" + - Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + - Base64.encodeToString(ciphertext, Base64.NO_WRAP) - } - - fun decryptPem(encrypted: String, password: String): String { - val parts = encrypted.split(":") - require(parts.size == 3) { "Invalid encrypted PEM format" } - val salt = Base64.decode(parts[0], Base64.NO_WRAP) - val iv = Base64.decode(parts[1], Base64.NO_WRAP) - val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP) - val key = deriveKey(password, salt) - val cipher = Cipher.getInstance(AES_MODE) - cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) - val plain = cipher.doFinal(ciphertext) - return String(plain, Charsets.UTF_8) - } - - private fun deriveKey(password: String, salt: ByteArray): SecretKeySpec { - val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") - val spec = PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH) - val tmp = factory.generateSecret(spec) - return SecretKeySpec(tmp.encoded, "AES") - } -} diff --git a/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt b/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt deleted file mode 100644 index 7529520..0000000 --- a/app/src/main/java/com/example/sshproxy/security/SecureHostKeyVerifier.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.example.sshproxy.security - -import android.content.Context -import android.util.Log -import com.example.sshproxy.AppLog -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume - -/** - * Custom host key verifier that implements known_hosts validation - * Replaces the promiscuous verifier with proper security checks - */ -class SecureHostKeyVerifier(private val context: Context) : HostKeyVerifier { - - companion object { - private const val TAG = "SecureHostKeyVerifier" - } - - private val knownHostsManager = KnownHostsManager(context) - - // Callback for when host key validation fails - var onHostKeyMismatch: (suspend (hostname: String, port: Int, fingerprint: String, storedFingerprint: String) -> Boolean)? = null - - override fun findExistingAlgorithms(hostname: String?, port: Int): MutableList { - // Return empty list - we don't restrict algorithms, just validate keys - return mutableListOf() - } - - override fun verify(hostname: String?, port: Int, key: java.security.PublicKey?): Boolean { - if (hostname == null || key == null) { - Log.e(TAG, "Invalid hostname or key provided") - return false - } - - return try { - val keyBytes = key.encoded - val keyType = determineKeyType(key) - - Log.d(TAG, "Verifying host key for $hostname:$port (type: $keyType)") - - when (val result = knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - // First connection - store the key - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("New SSH server $hostname:$port - fingerprint stored") - true - } - - KnownHostsManager.HostKeyValidationResult.VALID -> { - // Key matches - connection is safe - Log.d(TAG, "Host key validation successful for $hostname:$port") - true - } - - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - // Key changed - potential security issue - val fingerprint = generateFingerprint(keyBytes) - val storedFingerprint = knownHostsManager.getStoredFingerprint(hostname, port) ?: "unknown" - - Log.w(TAG, "Host key changed for $hostname:$port!") - AppLog.log("WARNING: Host key changed for $hostname:$port") - AppLog.log("Stored fingerprint: $storedFingerprint") - AppLog.log("Received fingerprint: $fingerprint") - - // For now, return false - we need async user confirmation - // The service will handle this differently using verifyWithUserConfirmation - false - } - } - } catch (e: Exception) { - Log.e(TAG, "Host key verification failed", e) - AppLog.log("Host key verification error: ${e.message}") - false - } - } - - /** - * Verify host key with user confirmation for changed keys - * This is used when we need to ask the user about key changes - */ - suspend fun verifyWithUserConfirmation(hostname: String, port: Int, key: java.security.PublicKey): Boolean { - val keyBytes = key.encoded - val keyType = determineKeyType(key) - - when (val result = knownHostsManager.validateHostKey(hostname, port, keyBytes, keyType)) { - KnownHostsManager.HostKeyValidationResult.NEW_HOST -> { - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("New SSH server $hostname:$port - fingerprint stored") - return true - } - - KnownHostsManager.HostKeyValidationResult.VALID -> { - return true - } - - KnownHostsManager.HostKeyValidationResult.KEY_CHANGED -> { - val fingerprint = generateFingerprint(keyBytes) - val storedFingerprint = knownHostsManager.getStoredFingerprint(hostname, port) ?: "unknown" - - // Ask user for confirmation - val userAccepted = onHostKeyMismatch?.invoke(hostname, port, fingerprint, storedFingerprint) ?: false - - if (userAccepted) { - // User accepted - update the stored key - knownHostsManager.storeHostKey(hostname, port, keyBytes, keyType) - AppLog.log("Host key updated for $hostname:$port with user confirmation") - return true - } else { - AppLog.log("Connection rejected - host key change not accepted") - return false - } - } - } - } - - /** - * Get stored fingerprint for a server (for display purposes) - */ - fun getServerFingerprint(hostname: String, port: Int): String? { - return knownHostsManager.getStoredFingerprint(hostname, port) - } - - /** - * Remove stored host key (when user wants to reset) - */ - fun removeHostKey(hostname: String, port: Int) { - knownHostsManager.removeHostKey(hostname, port) - } - - /** - * Determine SSH key type from PublicKey - */ - private fun determineKeyType(key: java.security.PublicKey): String { - return when (key.algorithm.lowercase()) { - "rsa" -> "ssh-rsa" - "ec" -> "ecdsa-sha2-nistp256" // Assuming P-256 curve - "eddsa", "ed25519" -> "ssh-ed25519" - else -> "ssh-${key.algorithm.lowercase()}" - } - } - - /** - * Generate SHA-256 fingerprint from public key bytes - */ - private fun generateFingerprint(publicKey: ByteArray): String { - return try { - val digest = java.security.MessageDigest.getInstance("SHA-256") - val hash = digest.digest(publicKey) - "SHA256:" + android.util.Base64.encodeToString(hash, android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING) - } catch (e: Exception) { - "ERROR:${e.message}" - } - } -} \ No newline at end of file From 9e56878411ea8963fda755deedb5579abb42e462 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:15:01 +0300 Subject: [PATCH 021/366] Delete app/src/main/java/com/example/sshproxy/data directory --- .../example/sshproxy/data/ConnectionStatus.kt | 72 ---- .../sshproxy/data/IpLocationService.kt | 219 ---------- .../java/com/example/sshproxy/data/KeyDao.kt | 22 - .../example/sshproxy/data/KeyRepository.kt | 50 --- .../sshproxy/data/PreferencesManager.kt | 115 ------ .../java/com/example/sshproxy/data/Server.kt | 47 --- .../com/example/sshproxy/data/ServerDao.kt | 25 -- .../example/sshproxy/data/ServerDatabase.kt | 118 ------ .../example/sshproxy/data/ServerRepository.kt | 50 --- .../java/com/example/sshproxy/data/SshKey.kt | 17 - .../example/sshproxy/data/SshKeyManager.kt | 390 ------------------ 11 files changed, 1125 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/IpLocationService.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/KeyDao.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/KeyRepository.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/Server.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/ServerDao.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/ServerRepository.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/SshKey.kt delete mode 100644 app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt diff --git a/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt b/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt deleted file mode 100644 index 9939f81..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ConnectionStatus.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import com.example.sshproxy.R -import com.example.sshproxy.network.ConnectionQuality -import com.example.sshproxy.network.PingResult -import com.example.sshproxy.network.ServerStats - -data class ConnectionStatus( - val state: ConnectionState = ConnectionState.DISCONNECTED, - val server: Server? = null, - val connectedSince: Long? = null, - val latestPing: PingResult? = null, - val serverStats: ServerStats? = null, - val connectionQuality: ConnectionQuality = ConnectionQuality.UNKNOWN, - val errorMessage: String? = null, - val reconnectionAttempt: Int = 0, - val maxReconnectionAttempts: Int = 0, - val isReconnecting: Boolean = false -) - -enum class ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED, - DISCONNECTING, - RECONNECTING, - ERROR -} - -// Extension functions for display -fun ConnectionStatus.getDisplayStatus(context: Context): String { - return when (state) { - ConnectionState.DISCONNECTED -> context.getString(R.string.connection_status_disconnected) - ConnectionState.CONNECTING -> context.getString(R.string.connection_status_connecting) - ConnectionState.CONNECTED -> context.getString(R.string.connection_status_connected) - ConnectionState.DISCONNECTING -> context.getString(R.string.connection_status_disconnecting) - ConnectionState.RECONNECTING -> context.getString(R.string.connection_status_reconnecting, reconnectionAttempt, maxReconnectionAttempts) - ConnectionState.ERROR -> context.getString(R.string.connection_status_error) - } -} - -fun ConnectionStatus.getConnectionDuration(): String? { - if (state != ConnectionState.CONNECTED || connectedSince == null) { - return null - } - - val durationMs = System.currentTimeMillis() - connectedSince - val seconds = durationMs / 1000 - val minutes = seconds / 60 - val hours = minutes / 60 - val days = hours / 24 - - return when { - days > 0 -> "${days}д ${hours % 24}ч" - hours > 0 -> "${hours}ч ${minutes % 60}м" - minutes > 0 -> "${minutes}м ${seconds % 60}с" - else -> "${seconds}с" - } -} - -fun ConnectionStatus.getPingDisplay(context: Context): String? { - return when { - latestPing == null -> null - !latestPing.isSuccessful -> context.getString(R.string.connection_ping_no_response) - else -> "${latestPing.latencyMs}мс" - } -} - -fun ConnectionStatus.getQualityColor(): Int { - return connectionQuality.color -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt b/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt deleted file mode 100644 index fbe6b88..0000000 --- a/app/src/main/java/com/example/sshproxy/data/IpLocationService.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.example.sshproxy.data - -import android.util.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL - -data class IpLocation( - val ip: String, - val country: String, - val countryCode: String, - val flag: String -) - -object IpLocationService { - private const val TAG = "IpLocationService" - private const val CACHE_DURATION_NO_VPN_MS = 10 * 60 * 1000L // 10 minutes cache when no VPN - private const val CACHE_DURATION_VPN_MS = Long.MAX_VALUE // Cache VPN IP for entire session - - private var cachedIpLocation: IpLocation? = null - private var cachedSimpleIp: String? = null - private var lastFetchTime = 0L - private var lastKnownVpnState = false - private var isVpnSession = false - - // Карта кодов стран на флаги эмодзи - private val countryToFlag = mapOf( - "US" to "🇺🇸", "GB" to "🇬🇧", "DE" to "🇩🇪", "FR" to "🇫🇷", "RU" to "🇷🇺", - "CN" to "🇨🇳", "JP" to "🇯🇵", "IN" to "🇮🇳", "BR" to "🇧🇷", "CA" to "🇨🇦", - "AU" to "🇦🇺", "IT" to "🇮🇹", "ES" to "🇪🇸", "MX" to "🇲🇽", "KR" to "🇰🇷", - "NL" to "🇳🇱", "SE" to "🇸🇪", "NO" to "🇳🇴", "CH" to "🇨🇭", "FI" to "🇫🇮", - "DK" to "🇩🇰", "BE" to "🇧🇪", "AT" to "🇦🇹", "IE" to "🇮🇪", "PL" to "🇵🇱", - "CZ" to "🇨🇿", "HU" to "🇭🇺", "GR" to "🇬🇷", "PT" to "🇵🇹", "TR" to "🇹🇷", - "IL" to "🇮🇱", "SA" to "🇸🇦", "AE" to "🇦🇪", "EG" to "🇪🇬", "ZA" to "🇿🇦", - "NG" to "🇳🇬", "KE" to "🇰🇪", "GH" to "🇬🇭", "AR" to "🇦🇷", "CL" to "🇨🇱", - "PE" to "🇵🇪", "CO" to "🇨🇴", "VE" to "🇻🇪", "TH" to "🇹🇭", "VN" to "🇻🇳", - "MY" to "🇲🇾", "SG" to "🇸🇬", "ID" to "🇮🇩", "PH" to "🇵🇭", "BD" to "🇧🇩", - "PK" to "🇵🇰", "LK" to "🇱🇰", "NZ" to "🇳🇿", "UA" to "🇺🇦", "BY" to "🇧🇾", - "RO" to "🇷🇴", "BG" to "🇧🇬", "RS" to "🇷🇸", "HR" to "🇭🇷", "SI" to "🇸🇮", - "SK" to "🇸🇰", "LT" to "🇱🇹", "LV" to "🇱🇻", "EE" to "🇪🇪", "IS" to "🇮🇸" - ) - - suspend fun getIpLocation(): IpLocation? = withContext(Dispatchers.IO) { - // Check cache first with dynamic duration based on VPN state - val now = System.currentTimeMillis() - val cacheDuration = if (isVpnSession) CACHE_DURATION_VPN_MS else CACHE_DURATION_NO_VPN_MS - - if (cachedIpLocation != null && (now - lastFetchTime) < cacheDuration) { - val cacheAge = (now - lastFetchTime) / 1000 // seconds - val sessionType = if (isVpnSession) "VPN session" else "no VPN" - Log.d(TAG, "Returning cached IP location (${cachedIpLocation?.ip}) - $sessionType, age: ${cacheAge}s") - return@withContext cachedIpLocation - } - - // Always get a reliable IPv4 address first - val ipAddress = getReliableIp() ?: return@withContext null - - // Try to get country information - var country = "Unknown location" - var countryCode = "" - var flag = "🌍" - - try { - // Пытаемся получить информацию о стране через ipapi.co - val url = URL("https://ipapi.co/json/") - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 3000 - connection.readTimeout = 5000 - connection.setRequestProperty("User-Agent", "SSH-Proxy-Android/1.0") - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val response = reader.readText() - reader.close() - - val json = JSONObject(response) - val countryName = json.optString("country_name", "") - val code = json.optString("country_code", "") - - if (countryName.isNotEmpty() && code.isNotEmpty()) { - country = countryName - countryCode = code - flag = countryToFlag[countryCode] ?: "🌍" - Log.d(TAG, "Country info: $country ($countryCode) $flag") - } else { - Log.w(TAG, "No country data from ipapi.co") - } - } else { - Log.w(TAG, "HTTP error from ipapi.co: $responseCode (using fallback country)") - } - } catch (e: Exception) { - Log.w(TAG, "Error fetching country info, using fallback: ${e.message}") - } - - // Create result with reliable IP and best available country info - val ipLocation = IpLocation(ipAddress, country, countryCode, flag) - - // Cache the result - cachedIpLocation = ipLocation - lastFetchTime = now - - Log.d(TAG, "Final IP location: $ipAddress, $country ($countryCode) $flag") - return@withContext ipLocation - } - - private suspend fun getReliableIp(): String? = withContext(Dispatchers.IO) { - // Try multiple services to get a clean IPv4 address - val ipServices = listOf( - "https://httpbin.org/ip" to "origin", - "https://api.ipify.org?format=json" to "ip", - "https://ipinfo.io/ip" to null // returns plain text - ) - - for ((serviceUrl, jsonKey) in ipServices) { - try { - val url = URL(serviceUrl) - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "GET" - connection.connectTimeout = 3000 - connection.readTimeout = 5000 - connection.setRequestProperty("User-Agent", "SSH-Proxy-Android/1.0") - - val responseCode = connection.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - val reader = BufferedReader(InputStreamReader(connection.inputStream)) - val response = reader.readText().trim() - reader.close() - - val ip = if (jsonKey != null) { - try { - JSONObject(response).optString(jsonKey, "") - } catch (e: Exception) { - "" - } - } else { - response // plain text response - } - - if (ip.isNotEmpty() && isValidIPv4(ip)) { - Log.d(TAG, "Got reliable IPv4 from $serviceUrl: $ip") - return@withContext ip - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to get IP from $serviceUrl: ${e.message}") - } - } - - Log.e(TAG, "Failed to get reliable IP from all services") - return@withContext null - } - - private fun isValidIPv4(ip: String): Boolean { - return try { - val parts = ip.split(".") - parts.size == 4 && parts.all { part -> - val num = part.toIntOrNull() - num != null && num in 0..255 - } - } catch (e: Exception) { - false - } - } - - suspend fun getSimpleIp(): String? = withContext(Dispatchers.IO) { - // This now delegates to the reliable IP method - return@withContext getReliableIp() - } - - /** - * Invalidate cache when VPN state changes (connect/disconnect/reconnect) - * This forces fresh IP detection on the next request - */ - fun invalidateCacheOnVpnChange(isVpnConnected: Boolean) { - android.util.Log.d(TAG, "invalidateCacheOnVpnChange called, isVpnConnected: $isVpnConnected") - if (lastKnownVpnState != isVpnConnected) { - Log.d(TAG, "VPN state changed: $lastKnownVpnState -> $isVpnConnected, invalidating IP cache") - cachedIpLocation = null - cachedSimpleIp = null - lastFetchTime = 0L - lastKnownVpnState = isVpnConnected - isVpnSession = isVpnConnected - - val cacheStrategy = if (isVpnConnected) "session-long caching" else "10-minute caching" - Log.d(TAG, "Switching to $cacheStrategy") - } - } - - /** - * Force refresh IP info regardless of cache (for manual refresh button) - */ - fun forceRefresh() { - Log.d(TAG, "Force refresh requested, invalidating cache") - cachedIpLocation = null - cachedSimpleIp = null - lastFetchTime = 0L - } - - /** - * Get cached IP info without triggering network requests - * Returns null if no valid cache exists - */ - fun getCachedIpLocation(): IpLocation? { - val now = System.currentTimeMillis() - val cacheDuration = if (isVpnSession) CACHE_DURATION_VPN_MS else CACHE_DURATION_NO_VPN_MS - - return if (cachedIpLocation != null && (now - lastFetchTime) < cacheDuration) { - cachedIpLocation - } else { - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/KeyDao.kt b/app/src/main/java/com/example/sshproxy/data/KeyDao.kt deleted file mode 100644 index 6522807..0000000 --- a/app/src/main/java/com/example/sshproxy/data/KeyDao.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import kotlinx.coroutines.flow.Flow - -@Dao -interface KeyDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertKey(key: SshKey) - - @Query("SELECT * FROM ssh_keys") - fun getAllKeys(): Flow> - - @Query("SELECT * FROM ssh_keys WHERE id = :id") - suspend fun getKeyById(id: String): SshKey? - - @Query("DELETE FROM ssh_keys WHERE id = :id") - suspend fun deleteKey(id: String) -} diff --git a/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt b/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt deleted file mode 100644 index e08c48f..0000000 --- a/app/src/main/java/com/example/sshproxy/data/KeyRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext -import java.security.PrivateKey - -class KeyRepository(private val context: Context) { - private val database = ServerDatabase.getDatabase(context) - private val keyDao = database.keyDao() - - fun getAllKeys(): Flow> = keyDao.getAllKeys() - - suspend fun insertKey(key: SshKey) { - withContext(Dispatchers.IO) { - keyDao.insertKey(key) - } - } - - suspend fun getKeyById(id: String): SshKey? { - return withContext(Dispatchers.IO) { - keyDao.getKeyById(id) - } - } - - suspend fun generateKeyPair(name: String) { - withContext(Dispatchers.IO) { - SshKeyManager(context, this@KeyRepository).generateKeyPair(name) - } - } - - suspend fun deleteKey(id: String) { - withContext(Dispatchers.IO) { - keyDao.deleteKey(id) - SshKeyManager(context, this@KeyRepository).deleteKeyFiles(id) - } - } - - /** - * Get decrypted private key for SSH operations - * @param id SSH key identifier - * @return PrivateKey object for SSH connections, null if not found or decryption failed - */ - suspend fun getPrivateKey(id: String): PrivateKey? { - return withContext(Dispatchers.IO) { - SshKeyManager(context, this@KeyRepository).getPrivateKey(id) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt b/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt deleted file mode 100644 index 2663dba..0000000 --- a/app/src/main/java/com/example/sshproxy/data/PreferencesManager.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import android.content.SharedPreferences - -class PreferencesManager(context: Context) { - private val prefs: SharedPreferences = context.getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - - companion object { - private const val KEY_ACTIVE_SERVER_ID = "active_server_id" - private const val KEY_ACTIVE_KEY_ID = "active_key_id" - private const val KEY_AUTO_RECONNECT = "auto_reconnect" - private const val KEY_HEALTH_CHECK_INTERVAL = "health_check_interval" - private const val KEY_MAX_RECONNECT_ATTEMPTS = "max_reconnect_attempts" - private const val KEY_INITIAL_BACKOFF_MS = "initial_backoff_ms" - private const val KEY_MAX_BACKOFF_MS = "max_backoff_ms" - private const val KEY_BACKOFF_MULTIPLIER = "backoff_multiplier" - private const val KEY_THEME = "theme" - private const val KEY_LANGUAGE = "language" - private const val KEY_SPLIT_TUNNELING_APPS = "split_tunneling_apps" - } - - // Active Server ID - fun getActiveServerId(): Long? { - val id = prefs.getLong(KEY_ACTIVE_SERVER_ID, -1) - return if (id == -1L) null else id - } - - fun setActiveServerId(serverId: Long) { - prefs.edit().putLong(KEY_ACTIVE_SERVER_ID, serverId).apply() - } - - // Active Key ID - fun setActiveKeyId(id: String) { - prefs.edit().putString(KEY_ACTIVE_KEY_ID, id).apply() - } - - fun getActiveKeyId(): String? { - return prefs.getString(KEY_ACTIVE_KEY_ID, null) - } - - // Auto-reconnection settings - fun isAutoReconnectEnabled(): Boolean { - return prefs.getBoolean(KEY_AUTO_RECONNECT, true) - } - - fun setAutoReconnectEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply() - } - - fun getHealthCheckInterval(): Long { - return prefs.getLong(KEY_HEALTH_CHECK_INTERVAL, 10_000) - } - - fun setHealthCheckInterval(intervalMs: Long) { - prefs.edit().putLong(KEY_HEALTH_CHECK_INTERVAL, intervalMs).apply() - } - - fun getMaxReconnectAttempts(): Int { - return prefs.getInt(KEY_MAX_RECONNECT_ATTEMPTS, 10) - } - - fun setMaxReconnectAttempts(attempts: Int) { - prefs.edit().putInt(KEY_MAX_RECONNECT_ATTEMPTS, attempts).apply() - } - - fun getInitialBackoffMs(): Long { - return prefs.getLong(KEY_INITIAL_BACKOFF_MS, 1_000) - } - - fun setInitialBackoffMs(backoffMs: Long) { - prefs.edit().putLong(KEY_INITIAL_BACKOFF_MS, backoffMs).apply() - } - - fun getMaxBackoffMs(): Long { - return prefs.getLong(KEY_MAX_BACKOFF_MS, 300_000) - } - - fun setMaxBackoffMs(backoffMs: Long) { - prefs.edit().putLong(KEY_MAX_BACKOFF_MS, backoffMs).apply() - } - - fun getBackoffMultiplier(): Float { - return prefs.getFloat(KEY_BACKOFF_MULTIPLIER, 2.0f) - } - - fun setBackoffMultiplier(multiplier: Float) { - prefs.edit().putFloat(KEY_BACKOFF_MULTIPLIER, multiplier).apply() - } - - // Theme - fun getTheme(): String { - return prefs.getString(KEY_THEME, "system") ?: "system" - } - - fun setTheme(theme: String) { - prefs.edit().putString(KEY_THEME, theme).apply() - } - - // Language - fun getLanguage(): String { - return prefs.getString(KEY_LANGUAGE, "system") ?: "system" - } - - fun setLanguage(language: String) { - prefs.edit().putString(KEY_LANGUAGE, language).apply() - } - - // Split Tunneling — global allowlist of app package names - fun getSplitTunnelingApps(): Set = - prefs.getStringSet(KEY_SPLIT_TUNNELING_APPS, emptySet()) ?: emptySet() - - fun setSplitTunnelingApps(packages: Set) = - prefs.edit().putStringSet(KEY_SPLIT_TUNNELING_APPS, packages).apply() -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/Server.kt b/app/src/main/java/com/example/sshproxy/data/Server.kt deleted file mode 100644 index 6a8af1a..0000000 --- a/app/src/main/java/com/example/sshproxy/data/Server.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "servers") -data class Server( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, - - @ColumnInfo(name = "name") - val name: String, - - @ColumnInfo(name = "host") - val host: String, - - @ColumnInfo(name = "port") - val port: Int = 22, - - @ColumnInfo(name = "username") - val username: String = "user", - - @ColumnInfo(name = "http_proxy_port") - val httpProxyPort: Int = 8080, // HTTP proxy port - - @ColumnInfo(name = "ssh_key_id") - val sshKeyId: String? = null, // ID SSH ключа для этого сервера - - @ColumnInfo(name = "is_favorite") - val isFavorite: Boolean = false, - - @ColumnInfo(name = "created_at") - val createdAt: Long = System.currentTimeMillis(), - - @ColumnInfo(name = "last_used") - val lastUsed: Long? = null, - - @ColumnInfo(name = "preferred_cipher") - val preferredCipher: String? = null, - - @ColumnInfo(name = "preferred_kex") - val preferredKex: String? = null, - - @ColumnInfo(name = "preferred_mac") - val preferredMac: String? = null -) diff --git a/app/src/main/java/com/example/sshproxy/data/ServerDao.kt b/app/src/main/java/com/example/sshproxy/data/ServerDao.kt deleted file mode 100644 index ff3e992..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerDao.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.* -import kotlinx.coroutines.flow.Flow - -@Dao -interface ServerDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertServer(server: Server) - - @Delete - suspend fun deleteServer(server: Server) - - @Update - suspend fun updateServer(server: Server) - - @Query("SELECT * FROM servers") - fun getAllServers(): Flow> - - @Query("SELECT * FROM servers WHERE id = :id") - suspend fun getServerById(id: Long): Server? - - @Query("SELECT * FROM servers WHERE is_favorite = 1") - fun getFavoriteServers(): Flow> -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt b/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt deleted file mode 100644 index fc0ca4a..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerDatabase.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -class Converters { - @TypeConverter - fun fromKeyType(value: SshKeyManager.KeyType): String { - return value.name - } - - @TypeConverter - fun toKeyType(value: String): SshKeyManager.KeyType { - return SshKeyManager.KeyType.valueOf(value) - } -} - -@Database( - entities = [Server::class, SshKey::class], - version = 9, // Incremented version for preferred algorithms - exportSchema = false -) -@TypeConverters(Converters::class) -abstract class ServerDatabase : RoomDatabase() { - abstract fun serverDao(): ServerDao - abstract fun keyDao(): KeyDao - - companion object { - @Volatile - private var INSTANCE: ServerDatabase? = null - - private val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN ssh_key_id TEXT DEFAULT NULL") - } - } - - private val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE ssh_keys ADD COLUMN keyType TEXT NOT NULL DEFAULT 'ED25519'") - } - } - - private val MIGRATION_5_6 = object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_port INTEGER NOT NULL DEFAULT 8080") - } - } - - private val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_auth_enabled INTEGER NOT NULL DEFAULT 0") - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_username TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN http_proxy_password TEXT DEFAULT NULL") - } - } - - private val MIGRATION_7_8 = object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - // Create a new table with the desired schema - db.execSQL(""" - CREATE TABLE servers_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - name TEXT NOT NULL, - host TEXT NOT NULL, - port INTEGER NOT NULL, - username TEXT NOT NULL, - http_proxy_port INTEGER NOT NULL, - ssh_key_id TEXT, - is_favorite INTEGER NOT NULL, - created_at INTEGER NOT NULL, - last_used INTEGER - ) - """) - - // Copy the data from the old table to the new table - db.execSQL(""" - INSERT INTO servers_new (id, name, host, port, username, http_proxy_port, ssh_key_id, is_favorite, created_at, last_used) - SELECT id, name, host, port, username, http_proxy_port, ssh_key_id, is_favorite, created_at, last_used FROM servers - """) - - // Remove the old table - db.execSQL("DROP TABLE servers") - - // Rename the new table to the original table name - db.execSQL("ALTER TABLE servers_new RENAME TO servers") - } - } - - private val MIGRATION_8_9 = object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_cipher TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_kex TEXT DEFAULT NULL") - db.execSQL("ALTER TABLE servers ADD COLUMN preferred_mac TEXT DEFAULT NULL") - } - } - - fun getDatabase(context: Context): ServerDatabase { - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - ServerDatabase::class.java, - "server_database" - ) - .addMigrations(MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) - .build() - INSTANCE = instance - instance - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt b/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt deleted file mode 100644 index e48b937..0000000 --- a/app/src/main/java/com/example/sshproxy/data/ServerRepository.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import com.example.sshproxy.security.SecureHostKeyVerifier -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext - -class ServerRepository(private val context: Context) { - private val database = ServerDatabase.getDatabase(context) - private val serverDao = database.serverDao() - private val hostKeyVerifier by lazy { SecureHostKeyVerifier(context) } - - fun getAllServers(): Flow> = serverDao.getAllServers() - - fun getFavoriteServers(): Flow> = serverDao.getFavoriteServers() - - suspend fun insertServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.insertServer(server) - } - } - - suspend fun deleteServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.deleteServer(server) - } - } - - suspend fun updateServer(server: Server) { - withContext(Dispatchers.IO) { - serverDao.updateServer(server) - } - } - - suspend fun getServerById(id: Long): Server? { - return withContext(Dispatchers.IO) { - serverDao.getServerById(id) - } - } - - /** - * Get stored fingerprint for a server - * @param server The server to get fingerprint for - * @return Server fingerprint or null if not stored - */ - fun getServerFingerprint(server: Server): String? { - return hostKeyVerifier.getServerFingerprint(server.host, server.port) - } -} diff --git a/app/src/main/java/com/example/sshproxy/data/SshKey.kt b/app/src/main/java/com/example/sshproxy/data/SshKey.kt deleted file mode 100644 index b1538c8..0000000 --- a/app/src/main/java/com/example/sshproxy/data/SshKey.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.sshproxy.data - -import androidx.room.Entity -import androidx.room.PrimaryKey - -import com.example.sshproxy.data.SshKeyManager - -@Entity(tableName = "ssh_keys") -data class SshKey( - @PrimaryKey - val id: String, - val name: String, - val publicKey: String, - val fingerprint: String, - val keyType: SshKeyManager.KeyType, // Added keyType - val createdAt: Long = System.currentTimeMillis() -) diff --git a/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt b/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt deleted file mode 100644 index 81f014f..0000000 --- a/app/src/main/java/com/example/sshproxy/data/SshKeyManager.kt +++ /dev/null @@ -1,390 +0,0 @@ -package com.example.sshproxy.data - -import android.content.Context -import android.util.Base64 -import android.util.Log -import com.example.sshproxy.security.KeystoreManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withContext -import org.bouncycastle.jcajce.provider.asymmetric.edec.BCEdDSAPublicKey -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemWriter -import java.io.ByteArrayOutputStream -import java.io.DataOutputStream -import java.io.File -import java.io.StringWriter -import java.math.BigInteger -import java.security.* -import java.security.interfaces.ECPublicKey -import java.security.interfaces.RSAPublicKey -import java.security.spec.AlgorithmParameterSpec -import java.security.spec.ECGenParameterSpec -import java.security.spec.PKCS8EncodedKeySpec -import java.security.spec.X509EncodedKeySpec -import java.util.* - - -class SshKeyManager(private val context: Context, private val keyRepository: KeyRepository) { - // Сохраняет зашифрованный PEM-файл приватного ключа - fun saveEncryptedPem(keyId: String, pem: String) { - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - val encryptedPem = com.example.sshproxy.security.PemAesUtil.encryptPem(pem, password) - com.example.sshproxy.security.KeyPasswordStorage.storeEncryptedPem(context, keyId, encryptedPem) - } - - // Загружает и расшифровывает PEM-файл приватного ключа - fun loadDecryptedPem(keyId: String): String? { - Log.d(TAG, "loadDecryptedPem called for keyId: $keyId") - val encryptedPem = com.example.sshproxy.security.KeyPasswordStorage.getEncryptedPem(context, keyId) - Log.d(TAG, "Encrypted PEM from storage: ${if (encryptedPem != null) "Found (${encryptedPem.length} chars)" else "NULL"}") - - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - Log.d(TAG, "Password from Keystore: ${if (password != null) "Found" else "NULL"}") - - return if (encryptedPem != null && password != null) { - try { - val decrypted = com.example.sshproxy.security.PemAesUtil.decryptPem(encryptedPem, password) - Log.d(TAG, "PEM decryption successful: ${if (decrypted != null) decrypted.length else 0} chars") - decrypted - } catch (e: Exception) { - Log.e(TAG, "PEM decryption failed", e) - null - } - } else { - Log.e(TAG, "Cannot decrypt: encryptedPem=${encryptedPem != null}, password=${password != null}") - null - } - } - - enum class KeyType(val algorithm: String, val spec: Any?, val sshName: String) { - RSA("RSA", 4096, "ssh-rsa"), - ED25519("Ed25519", null, "ssh-ed25519"), - ECDSA_256("EC", ECGenParameterSpec("secp256r1"), "ecdsa-sha2-nistp256") - } - - companion object { - private const val TAG = "SshKeyManager" - private const val PRIVATE_KEY_PREFIX = "ssh_private_encrypted_" - private const val IV_PREFIX = "ssh_iv_" - private const val PUBLIC_KEY_PREFIX = "ssh_public_" - val DEFAULT_KEY_TYPE = KeyType.ECDSA_256 - - init { - Security.removeProvider("BC") - Security.insertProviderAt(BouncyCastleProvider(), 1) - } - } - - private val keystoreManager = KeystoreManager() - - suspend fun hasKeyPair(): Boolean = withContext(Dispatchers.IO) { - keyRepository.getAllKeys().first().isNotEmpty() - } - - - suspend fun generateKeyPair(name: String, keyType: KeyType = DEFAULT_KEY_TYPE): SshKey = withContext(Dispatchers.IO) { - try { - val keyId = UUID.randomUUID().toString() - val keyGen = KeyPairGenerator.getInstance(keyType.algorithm) - when (val spec = keyType.spec) { - is Int -> keyGen.initialize(spec) - is AlgorithmParameterSpec -> keyGen.initialize(spec) - } - val keyPair = keyGen.generateKeyPair() - - // --- Новый способ: шифруем PEM паролем из Keystore, сохраняем IV+encrypted в SharedPreferences --- - val privateKeyPem = convertPrivateKeyToPem(keyPair.private) - val password = com.example.sshproxy.security.KeyPasswordKeystore.getOrCreatePassword(context, keyId) - val encryptedPem = com.example.sshproxy.security.PemAesUtil.encryptPem(privateKeyPem, password) - com.example.sshproxy.security.KeyPasswordStorage.storeEncryptedPem(context, keyId, encryptedPem) - - // Сохраняем публичный ключ (старый способ) - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - publicKeyFile.writeBytes(keyPair.public.encoded) - - val publicKeyString = formatSshPublicKey(keyPair.public, keyType) - val fingerprint = generateFingerprint(keyPair.public) - - val newKey = SshKey( - id = keyId, - name = name, - publicKey = publicKeyString, - fingerprint = fingerprint, - keyType = keyType - ) - keyRepository.insertKey(newKey) - newKey - } catch (e: Exception) { - Log.e(TAG, "Failed to generate key pair", e) - throw e - } - } - - private fun saveKeyPair(keyPair: KeyPair, keyId: String) { - // Save private key encrypted using Android Keystore - val privateKeyPem = convertPrivateKeyToPem(keyPair.private) - val encryptedData = keystoreManager.encryptPrivateKey(keyId, privateKeyPem.toByteArray()) - - val privateKeyFile = File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId") - privateKeyFile.writeBytes(encryptedData.ciphertext) - - val ivFile = File(context.filesDir, "$IV_PREFIX$keyId") - ivFile.writeBytes(encryptedData.iv) - - // Save public key unencrypted (it's public anyway) - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - publicKeyFile.writeBytes(keyPair.public.encoded) - } - - fun convertPrivateKeyToPem(privateKey: PrivateKey): String { - val stringWriter = StringWriter() - PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } - - suspend fun getPublicKey(keyId: String): String? = withContext(Dispatchers.IO) { - try { - val key = keyRepository.getKeyById(keyId) - if (key == null) { - Log.e(TAG, "Key with id $keyId not found in database") - return@withContext null - } - - val publicKeyFile = File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId") - if (!publicKeyFile.exists()) { - Log.e(TAG, "Public key file for keyId $keyId not found") - return@withContext null - } - - val keyBytes = publicKeyFile.readBytes() - if (keyBytes.isEmpty()) { - Log.e(TAG, "Public key file for keyId $keyId is empty") - return@withContext null - } - val keySpec = X509EncodedKeySpec(keyBytes) - val keyFactory = KeyFactory.getInstance(key.keyType.algorithm) - val publicKey = try { keyFactory.generatePublic(keySpec) } catch (e: Exception) { - Log.e(TAG, "Failed to generate public key from spec", e) - return@withContext null - } - formatSshPublicKey(publicKey, key.keyType) - } catch (e: Exception) { - Log.e(TAG, "Failed to load public key", e) - null - } - } - - suspend fun getActivePublicKey(): String = withContext(Dispatchers.IO) { - try { - val preferencesManager = PreferencesManager(context) - val activeKeyId = preferencesManager.getActiveKeyId() - - if (activeKeyId != null) { - getPublicKey(activeKeyId) ?: "" - } else { - // If no active key set, try to get the first available key - val keys = keyRepository.getAllKeys().first() - if (keys.isNotEmpty()) { - val firstKey = keys.first() - preferencesManager.setActiveKeyId(firstKey.id) - firstKey.publicKey - } else { - "" - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to get active public key", e) - "" - } - } - - private fun formatSshPublicKey(publicKey: PublicKey, keyType: KeyType): String { - if (publicKey == null) { - Log.e(TAG, "formatSshPublicKey: publicKey is null") - return "Error: publicKey is null" - } - val out = ByteArrayOutputStream() - val dos = DataOutputStream(out) - try { - val sshNameBytes = keyType.sshName.toByteArray() - dos.writeInt(sshNameBytes.size) - dos.write(sshNameBytes) - - when (keyType) { - KeyType.RSA -> { - val rsaPublicKey = publicKey as? RSAPublicKey - if (rsaPublicKey == null) return "Error: Not an RSA public key" - val e = rsaPublicKey.publicExponent?.toByteArray() ?: return "Error: RSA exponent is null" - val m = rsaPublicKey.modulus?.toByteArray() ?: return "Error: RSA modulus is null" - dos.writeInt(e.size) - dos.write(e) - dos.writeInt(m.size) - dos.write(m) - } - KeyType.ED25519 -> { - val edPublicKey = publicKey as? BCEdDSAPublicKey - if (edPublicKey == null) return "Error: Not an ED25519 public key" - val p = edPublicKey.pointEncoding?.reversedArray() ?: return "Error: ED25519 pointEncoding is null" - dos.writeInt(p.size) - dos.write(p) - } - KeyType.ECDSA_256 -> { - val ecPublicKey = publicKey as? ECPublicKey - if (ecPublicKey == null) return "Error: Not an ECDSA public key" - val curveName = "nistp256" - val curveNameBytes = curveName.toByteArray() - dos.writeInt(curveNameBytes.size) - dos.write(curveNameBytes) - val x = ecPublicKey.w.affineX?.toByteArray(32) ?: return "Error: ECDSA X is null" - val y = ecPublicKey.w.affineY?.toByteArray(32) ?: return "Error: ECDSA Y is null" - val p = x + y - val uncompressed = ByteArray(1) { 4 } + p - dos.writeInt(uncompressed.size) - dos.write(uncompressed) - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to format public key for keyType: $keyType", e) - return "Error formatting key: ${e.message}" - } - return "${keyType.sshName} " + Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP) - } - - private fun generateFingerprint(publicKey: PublicKey): String { - return try { - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(buildSshPublicKey(publicKey)) - "SHA256:" + Base64.encodeToString(digest, Base64.NO_WRAP or Base64.NO_PADDING).removeSuffix("=") - } catch (e: Exception) { - "Error generating fingerprint" - } - } - - fun getPrivateKeyFile(keyId: String): File { - return File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId") - } - - /** - * Get decrypted private key for SSH operations - * @param keyId SSH key identifier - * @return PrivateKey object for SSH connections - */ - suspend fun getPrivateKey(keyId: String): PrivateKey? = withContext(Dispatchers.IO) { - try { - Log.d(TAG, "getPrivateKey called for keyId: $keyId") - val key = keyRepository.getKeyById(keyId) - if (key == null) { - Log.e(TAG, "Key not found in database for keyId: $keyId") - return@withContext null - } - Log.d(TAG, "Key found in database: ${key.name}, type: ${key.keyType}") - - // 1. Пробуем зашифрованный PEM-файл с паролем из Keystore - val pemString = loadDecryptedPem(keyId) - Log.d(TAG, "loadDecryptedPem returned: ${if (pemString != null) "PEM content (${pemString.length} chars)" else "null"}") - - if (pemString != null) { - val privateKey = parsePemToPrivateKey(pemString, key.keyType) - Log.d(TAG, "parsePemToPrivateKey returned: ${if (privateKey != null) "PrivateKey object" else "null"}") - return@withContext privateKey - } - Log.e(TAG, "No private key found for keyId: $keyId") - return@withContext null - } catch (e: Exception) { - Log.e(TAG, "Failed to get private key for keyId: $keyId", e) - null - } - } - - private fun parsePemToPrivateKey(pemString: String, keyType: KeyType): PrivateKey? { - try { - Log.d(TAG, "parsePemToPrivateKey: keyType=${keyType.algorithm}, pemLength=${pemString.length}") - - // Extract base64 content from PEM - val base64Content = pemString - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replace("\n", "") - .replace("\r", "") - - Log.d(TAG, "Base64 content length: ${base64Content.length}") - - val keyBytes = Base64.decode(base64Content, Base64.DEFAULT) - Log.d(TAG, "Decoded key bytes: ${keyBytes.size} bytes") - - val keySpec = PKCS8EncodedKeySpec(keyBytes) - val keyFactory = KeyFactory.getInstance(keyType.algorithm) - val privateKey = keyFactory.generatePrivate(keySpec) - - Log.d(TAG, "Successfully created PrivateKey: ${privateKey.algorithm}") - return privateKey - } catch (e: Exception) { - Log.e(TAG, "Failed to parse PEM to private key for keyType: ${keyType.algorithm}", e) - return null - } - } - - fun deleteKeyFiles(keyId: String) { - File(context.filesDir, "$PRIVATE_KEY_PREFIX$keyId").delete() - File(context.filesDir, "$IV_PREFIX$keyId").delete() - File(context.filesDir, "$PUBLIC_KEY_PREFIX$keyId").delete() - // Also delete the encryption key from Android Keystore - keystoreManager.deleteEncryptionKey(keyId) - } - - private fun buildSshPublicKey(key: PublicKey): ByteArray { - val byteos = ByteArrayOutputStream() - val dos = DataOutputStream(byteos) - - when (key) { - is RSAPublicKey -> { - val sshName = "ssh-rsa".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - dos.writeInt(key.publicExponent.toByteArray().size) - dos.write(key.publicExponent.toByteArray()) - dos.writeInt(key.modulus.toByteArray().size) - dos.write(key.modulus.toByteArray()) - } - is ECPublicKey -> { - val sshName = "ecdsa-sha2-nistp256".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - val curveName = "nistp256".toByteArray() - dos.writeInt(curveName.size) - dos.write(curveName) - val p = key.w.affineX.toByteArray(32) + key.w.affineY.toByteArray(32) - val uncompressed = ByteArray(1) { 4 } + p - dos.writeInt(uncompressed.size) - dos.write(uncompressed) - } - is BCEdDSAPublicKey -> { - val sshName = "ssh-ed25519".toByteArray() - dos.writeInt(sshName.size) - dos.write(sshName) - val p = key.pointEncoding.reversedArray() - dos.writeInt(p.size) - dos.write(p) - } - } - return byteos.toByteArray() - } - - // Helper to pad byte array to a specific size - private fun BigInteger.toByteArray(size: Int): ByteArray { - val bytes = toByteArray() - if (bytes.size == size) return bytes - val padded = ByteArray(size) - val offset = if (bytes.size == size + 1 && bytes[0].toInt() == 0) 1 else 0 - val length = bytes.size - offset - if (length > size) { - throw IllegalStateException("BigInteger is too large to fit in $size bytes") - } - System.arraycopy(bytes, offset, padded, size - length, length) - return padded - } -} \ No newline at end of file From ae54f7c50680f61bd51ced5bc64f4f5369f8ef0d Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:15:39 +0300 Subject: [PATCH 022/366] Delete app/src/main/java/com/example/sshproxy/network directory --- .../sshproxy/network/HttpLatencyTester.kt | 256 ----------------- .../example/sshproxy/network/PingMonitor.kt | 241 ---------------- .../sshproxy/network/SshAlgorithmManager.kt | 258 ------------------ 3 files changed, 755 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt delete mode 100644 app/src/main/java/com/example/sshproxy/network/PingMonitor.kt delete mode 100644 app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt diff --git a/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt b/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt deleted file mode 100644 index f1ad546..0000000 --- a/app/src/main/java/com/example/sshproxy/network/HttpLatencyTester.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.example.sshproxy.network - -import android.util.Log -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.net.HttpURLConnection -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.URL -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSession -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager -import java.security.cert.X509Certificate -import java.util.Base64 -import kotlin.system.measureTimeMillis - -data class HttpLatencyResult( - val url: String, - val latencyMs: Long, - val isSuccessful: Boolean, - val httpStatusCode: Int = 0, - val errorMessage: String? = null, - val timestamp: Long = System.currentTimeMillis() -) - -data class AggregatedLatencyResult( - val averageLatencyMs: Long, - val medianLatencyMs: Long, - val minLatencyMs: Long, - val maxLatencyMs: Long, - val successfulTests: Int, - val totalTests: Int, - val successRate: Float, - val individualResults: List, - val timestamp: Long = System.currentTimeMillis() -) - -class HttpLatencyTester( - private val proxyHost: String? = "127.0.0.1", - private val proxyPort: Int? = 8080, - private val timeoutMs: Int = 10000 // 10 seconds -) { - companion object { - private const val TAG = "HttpLatencyTester" - - // Test endpoints for latency measurement - private val TEST_ENDPOINTS = listOf( - "https://tazhate.com", - "https://httpbin.org/status/200", - "https://www.google.com/generate_204", // Google connectivity check - "https://detectportal.firefox.com/success.txt", // Firefox connectivity check - "https://connectivitycheck.gstatic.com/generate_204", // Google static connectivity check - "https://clients3.google.com/generate_204", // Alternative Google check - "https://www.msftconnecttest.com/connecttest.txt" // Microsoft connectivity check - ) - } - - private var testingJob: Job? = null - private var isRunning = false - - private val _latestResult = MutableStateFlow(null) - val latestResult: StateFlow = _latestResult.asStateFlow() - - // SSL endpoints that might have certificate issues - private val problematicSslEndpoints = setOf( - "https://www.msftconnecttest.com/connecttest.txt" - ) - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - fun startContinuousTesting(intervalMs: Long = 10000) { - if (isRunning) { - Log.d(TAG, "HTTP latency testing already running") - return - } - - Log.d(TAG, "Starting continuous HTTP latency testing") - isRunning = true - _isActive.value = true - - testingJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { - while (isRunning && currentCoroutineContext().isActive) { - try { - val result = performLatencyTest() - _latestResult.value = result - - delay(intervalMs) - } catch (e: Exception) { - Log.e(TAG, "Error in continuous latency testing", e) - delay(intervalMs) // Still delay to prevent tight loop - } - } - } - } - - fun stopTesting() { - Log.d(TAG, "Stopping HTTP latency testing") - isRunning = false - _isActive.value = false - testingJob?.cancel() - testingJob = null - } - - suspend fun performSingleTest(): AggregatedLatencyResult { - return performLatencyTest() - } - - private suspend fun performLatencyTest(): AggregatedLatencyResult = withContext(Dispatchers.IO) { - val results = mutableListOf() - - // Test all endpoints in parallel for faster results - val jobs = TEST_ENDPOINTS.map { endpoint -> - async { - testSingleEndpoint(endpoint) - } - } - - // Wait for all tests to complete - jobs.awaitAll().forEach { result -> - results.add(result) - } - - // Calculate aggregated statistics - val successfulResults = results.filter { it.isSuccessful } - val latencies = successfulResults.map { it.latencyMs } - - val aggregatedResult = if (latencies.isNotEmpty()) { - val sortedLatencies = latencies.sorted() - AggregatedLatencyResult( - averageLatencyMs = latencies.average().toLong(), - medianLatencyMs = if (sortedLatencies.size % 2 == 0) { - (sortedLatencies[sortedLatencies.size / 2 - 1] + sortedLatencies[sortedLatencies.size / 2]) / 2 - } else { - sortedLatencies[sortedLatencies.size / 2] - }, - minLatencyMs = latencies.minOrNull() ?: 0, - maxLatencyMs = latencies.maxOrNull() ?: 0, - successfulTests = successfulResults.size, - totalTests = results.size, - successRate = (successfulResults.size.toFloat() / results.size) * 100f, - individualResults = results - ) - } else { - // All tests failed - AggregatedLatencyResult( - averageLatencyMs = 0, - medianLatencyMs = 0, - minLatencyMs = 0, - maxLatencyMs = 0, - successfulTests = 0, - totalTests = results.size, - successRate = 0f, - individualResults = results - ) - } - - Log.d(TAG, "HTTP latency test completed: ${aggregatedResult.successfulTests}/${aggregatedResult.totalTests} successful, avg: ${aggregatedResult.averageLatencyMs}ms") - - aggregatedResult - } - - private suspend fun testSingleEndpoint(endpoint: String): HttpLatencyResult = withContext(Dispatchers.IO) { - try { - val url = URL(endpoint) - val connection: HttpURLConnection - if (proxyHost != null && proxyPort != null) { - val proxy = Proxy(Proxy.Type.HTTP, InetSocketAddress(proxyHost, proxyPort)) - connection = url.openConnection(proxy) as HttpURLConnection - } else { - connection = url.openConnection() as HttpURLConnection - } - - val latency = measureTimeMillis { - - // Handle SSL bypass for problematic endpoints - if (connection is HttpsURLConnection && endpoint in problematicSslEndpoints) { - val httpsConnection = connection as HttpsURLConnection - httpsConnection.sslSocketFactory = createTrustAllSSLContext().socketFactory - httpsConnection.hostnameVerifier = HostnameVerifier { _: String, _: SSLSession -> true } - } - - connection!!.apply { - requestMethod = "GET" - connectTimeout = timeoutMs - readTimeout = timeoutMs - setRequestProperty("User-Agent", "SSH-Proxy-Latency-Test/1.0") - setRequestProperty("Accept", "*/*") - setRequestProperty("Connection", "close") - } - - val responseCode = connection!!.responseCode - - // For some endpoints, we need to read the response to complete the request - if (responseCode == 200) { - connection!!.inputStream.use { it.read() } - } - } - - val responseCode = connection!!.responseCode - - Log.d(TAG, "HTTP test to $endpoint: ${latency}ms, status: $responseCode") - - HttpLatencyResult( - url = endpoint, - latencyMs = latency, - isSuccessful = responseCode in 200..299, - httpStatusCode = responseCode - ) - - } catch (e: Exception) { - val errorMsg = when { - e.message?.contains("timeout") == true -> "Connection timeout" - e.message?.contains("refused") == true -> "Connection refused" - e.message?.contains("unreachable") == true -> "Host unreachable" - e.message?.contains("proxy") == true -> "Proxy error" - else -> e.message ?: "HTTP request failed" - } - - Log.d(TAG, "HTTP test failed for $endpoint: $errorMsg") - - HttpLatencyResult( - url = endpoint, - latencyMs = 0, - isSuccessful = false, - errorMessage = errorMsg - ) - - } finally { - // connection?.disconnect() - } - } - - private fun createTrustAllSSLContext(): SSLContext { - val trustAllCerts = arrayOf( - object : X509TrustManager { - override fun checkClientTrusted(chain: Array, authType: String) {} - override fun checkServerTrusted(chain: Array, authType: String) {} - override fun getAcceptedIssuers(): Array = arrayOf() - } - ) - - val sslContext = SSLContext.getInstance("SSL") - sslContext.init(null, trustAllCerts, java.security.SecureRandom()) - return sslContext - } - - fun reset() { - _latestResult.value = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt b/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt deleted file mode 100644 index c9ce2dc..0000000 --- a/app/src/main/java/com/example/sshproxy/network/PingMonitor.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.example.sshproxy.network - -import android.content.Context -import android.util.Log -import com.example.sshproxy.R -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.io.IOException -import java.net.InetSocketAddress -import java.net.Socket -import kotlin.system.measureTimeMillis - -data class PingResult( - val latencyMs: Long, - val isSuccessful: Boolean, - val errorMessage: String? = null, - val timestamp: Long = System.currentTimeMillis() -) - -data class ServerStats( - val averageLatencyMs: Long = 0, - val minLatencyMs: Long = Long.MAX_VALUE, - val maxLatencyMs: Long = 0, - val packetLossPercentage: Float = 0f, - val lastSuccessfulPing: Long = 0, - val consecutiveFailures: Int = 0 -) - -class PingMonitor( - private val hostname: String, - private val port: Int, - private val intervalMs: Long = 5000, // 5 seconds - more frequent pings - private val timeoutMs: Int = 3000, // 3 seconds - faster timeout - private val onConnectionIssue: ((consecutiveFailures: Int) -> Unit)? = null -) { - companion object { - private const val TAG = "PingMonitor" - private const val PING_HISTORY_SIZE = 10 - } - - private var monitoringJob: Job? = null - private var isMonitoring = false - private var httpLatencyTester: HttpLatencyTester? = null - - private val _latestPing = MutableStateFlow(null) - val latestPing: StateFlow = _latestPing.asStateFlow() - - private val _serverStats = MutableStateFlow(ServerStats()) - val serverStats: StateFlow = _serverStats.asStateFlow() - - private val _isActive = MutableStateFlow(false) - val isActive: StateFlow = _isActive.asStateFlow() - - private val pingHistory = mutableListOf() - - fun startMonitoring() { - if (isMonitoring) { - Log.d(TAG, "Already monitoring $hostname:$port") - return - } - - Log.d(TAG, "Starting HTTP latency monitoring via proxy") - isMonitoring = true - _isActive.value = true - - // Initialize HTTP latency tester - httpLatencyTester = HttpLatencyTester( - proxyHost = "127.0.0.1", - timeoutMs = timeoutMs - ) - - monitoringJob = CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { - while (isMonitoring && currentCoroutineContext().isActive) { - try { - // Perform both TCP ping (for quick connection check) and HTTP latency test - val tcpPingResult = performTcpPing() - - // If TCP ping fails, don't bother with HTTP test - val finalResult = if (!tcpPingResult.isSuccessful) { - tcpPingResult - } else { - // Perform HTTP latency test for real-world performance - val httpResult = httpLatencyTester?.performSingleTest() - if (httpResult != null && httpResult.successfulTests > 0) { - // Use HTTP latency as the primary metric - PingResult( - latencyMs = httpResult.averageLatencyMs, - isSuccessful = httpResult.successRate >= 50f, // At least 50% success rate - errorMessage = if (httpResult.successRate < 50f) "Poor HTTP connectivity: ${httpResult.successfulTests}/${httpResult.totalTests} successful" else null - ) - } else { - // Fallback to TCP ping if HTTP test fails completely - tcpPingResult - } - } - - _latestPing.value = finalResult - updateStats(finalResult) - - delay(intervalMs) - } catch (e: Exception) { - Log.e(TAG, "Error in latency monitoring", e) - delay(intervalMs) // Still delay to prevent tight loop - } - } - } - } - - fun stopMonitoring() { - Log.d(TAG, "Stopping HTTP latency monitoring") - isMonitoring = false - _isActive.value = false - monitoringJob?.cancel() - monitoringJob = null - httpLatencyTester?.stopTesting() - httpLatencyTester = null - } - - suspend fun performSinglePing(): PingResult { - return performTcpPing() - } - - private suspend fun performTcpPing(): PingResult = withContext(Dispatchers.IO) { - var socket: Socket? = null - - try { - socket = Socket() - - val latency = measureTimeMillis { - socket.connect(InetSocketAddress(hostname, port), timeoutMs) - } - - Log.d(TAG, "Ping to $hostname:$port: ${latency}ms") - PingResult(latency, true) - - } catch (e: IOException) { - val errorMsg = when { - e.message?.contains("timeout") == true -> "Connection timeout" - e.message?.contains("refused") == true -> "Connection refused" - e.message?.contains("unreachable") == true -> "Host unreachable" - else -> e.message ?: "Connection failed" - } - - Log.d(TAG, "Ping failed to $hostname:$port: $errorMsg") - PingResult(0, false, errorMsg) - - } catch (e: Exception) { - Log.e(TAG, "Unexpected error during ping", e) - PingResult(0, false, e.message ?: "Unknown error") - - } finally { - socket?.close() - } - } - - private fun updateStats(pingResult: PingResult) { - synchronized(pingHistory) { - // Add to history - pingHistory.add(pingResult) - if (pingHistory.size > PING_HISTORY_SIZE) { - pingHistory.removeAt(0) - } - - // Calculate stats - val successfulPings = pingHistory.filter { it.isSuccessful } - val latencies = successfulPings.map { it.latencyMs } - - val currentStats = _serverStats.value - val newStats = if (latencies.isNotEmpty()) { - val avgLatency = latencies.average().toLong() - val minLatency = latencies.minOrNull() ?: 0 - val maxLatency = latencies.maxOrNull() ?: 0 - val packetLoss = ((pingHistory.size - successfulPings.size).toFloat() / pingHistory.size) * 100f - val lastSuccess = if (pingResult.isSuccessful) pingResult.timestamp else currentStats.lastSuccessfulPing - val consecutiveFailures = if (pingResult.isSuccessful) 0 else currentStats.consecutiveFailures + 1 - - ServerStats( - averageLatencyMs = avgLatency, - minLatencyMs = minLatency, - maxLatencyMs = maxLatency, - packetLossPercentage = packetLoss, - lastSuccessfulPing = lastSuccess, - consecutiveFailures = consecutiveFailures - ) - } else { - currentStats.copy( - consecutiveFailures = currentStats.consecutiveFailures + 1 - ) - } - - _serverStats.value = newStats - - // Quick detection of connection issues - notify after 2 consecutive failures - if (newStats.consecutiveFailures >= 2) { - onConnectionIssue?.invoke(newStats.consecutiveFailures) - } - } - } - - fun getConnectionQuality(): ConnectionQuality { - val stats = _serverStats.value - val latestPing = _latestPing.value - - return when { - latestPing == null -> ConnectionQuality.UNKNOWN - !latestPing.isSuccessful || stats.consecutiveFailures > 2 -> ConnectionQuality.POOR - stats.averageLatencyMs > 1500 -> ConnectionQuality.POOR // >1500ms = Poor - stats.averageLatencyMs > 1000 -> ConnectionQuality.FAIR // 1000-1500ms = Fair - stats.averageLatencyMs > 600 -> ConnectionQuality.GOOD // 600-1000ms = Good - else -> ConnectionQuality.EXCELLENT // <600ms = Excellent - } - } - - fun reset() { - synchronized(pingHistory) { - pingHistory.clear() - _serverStats.value = ServerStats() - _latestPing.value = null - } - } -} - -enum class ConnectionQuality(val color: Int) { - EXCELLENT(0xFF4CAF50.toInt()), // Green - GOOD(0xFF8BC34A.toInt()), // Light Green - FAIR(0xFFFF9800.toInt()), // Orange - POOR(0xFFF44336.toInt()), // Red - UNKNOWN(0xFF9E9E9E.toInt()); // Gray - - fun getDisplayName(context: Context): String { - return when (this) { - EXCELLENT -> context.getString(R.string.quality_excellent) - GOOD -> context.getString(R.string.quality_good) - FAIR -> context.getString(R.string.quality_fair) - POOR -> context.getString(R.string.quality_poor) - UNKNOWN -> context.getString(R.string.quality_unknown) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt b/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt deleted file mode 100644 index 896ed94..0000000 --- a/app/src/main/java/com/example/sshproxy/network/SshAlgorithmManager.kt +++ /dev/null @@ -1,258 +0,0 @@ -package com.example.sshproxy.network - -import android.util.Log -import net.schmizz.sshj.Config -import net.schmizz.sshj.DefaultConfig -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Factory -import net.schmizz.sshj.transport.cipher.Cipher -import net.schmizz.sshj.transport.kex.KeyExchange -import net.schmizz.sshj.transport.mac.MAC -import kotlin.system.measureTimeMillis - -data class SshAlgorithms( - val cipher: String? = null, - val kex: String? = null, - val mac: String? = null -) - -data class AlgorithmPerformance( - val algorithm: String, - val averageTimeMs: Long, - val isSupported: Boolean -) - -class SshAlgorithmManager { - companion object { - private const val TAG = "SshAlgorithmManager" - - // Быстрые алгоритмы в порядке предпочтения (скорость) - private val FAST_CIPHERS = listOf( - "aes128-ctr", - "aes192-ctr", - "aes256-ctr", - "aes128-cbc", - "aes192-cbc", - "aes256-cbc" - ) - - private val FAST_KEX = listOf( - "diffie-hellman-group14-sha256", - "diffie-hellman-group16-sha512", - "ecdh-sha2-nistp256", - "ecdh-sha2-nistp384", - "ecdh-sha2-nistp521" - ) - - private val FAST_MAC = listOf( - "hmac-sha2-256", - "hmac-sha2-512", - "hmac-sha1" - ) - } - - /** - * Получить список всех поддерживаемых алгоритмов из DefaultConfig - */ - fun getSupportedAlgorithms(): SshAlgorithms { - val defaultConfig = DefaultConfig() - - val ciphers = defaultConfig.cipherFactories.map { it.name } - val kexAlgorithms = defaultConfig.keyExchangeFactories.map { it.name } - val macAlgorithms = defaultConfig.macFactories.map { it.name } - - Log.d(TAG, "Supported ciphers: $ciphers") - Log.d(TAG, "Supported KEX: $kexAlgorithms") - Log.d(TAG, "Supported MAC: $macAlgorithms") - - return SshAlgorithms( - cipher = ciphers.joinToString(","), - kex = kexAlgorithms.joinToString(","), - mac = macAlgorithms.joinToString(",") - ) - } - - /** - * Получить оптимальные быстрые алгоритмы из поддерживаемых - */ - fun getFastAlgorithms(): SshAlgorithms { - val defaultConfig = DefaultConfig() - - val supportedCiphers = defaultConfig.cipherFactories.map { it.name } - val supportedKex = defaultConfig.keyExchangeFactories.map { it.name } - val supportedMac = defaultConfig.macFactories.map { it.name } - - Log.d(TAG, "All supported ciphers: ${supportedCiphers.joinToString(", ")}") - Log.d(TAG, "All supported KEX: ${supportedKex.joinToString(", ")}") - Log.d(TAG, "All supported MAC: ${supportedMac.joinToString(", ")}") - - // Выбираем первый доступный быстрый алгоритм из каждой категории - val fastCipher = FAST_CIPHERS.firstOrNull { it in supportedCiphers } - val selectedCipher = fastCipher ?: supportedCiphers.firstOrNull() - - val fastKex = FAST_KEX.firstOrNull { it in supportedKex } - val selectedKex = fastKex ?: supportedKex.firstOrNull() - - val fastMac = FAST_MAC.firstOrNull { it in supportedMac } - val selectedMac = fastMac ?: supportedMac.firstOrNull() - - // Логируем что выбрали и почему - Log.d(TAG, "Cipher selection: preferred=${FAST_CIPHERS.joinToString(", ")}") - Log.d(TAG, "Cipher selected: $selectedCipher ${if (fastCipher != null) "(fast)" else "(fallback)"}") - - Log.d(TAG, "KEX selection: preferred=${FAST_KEX.joinToString(", ")}") - Log.d(TAG, "KEX selected: $selectedKex ${if (fastKex != null) "(fast)" else "(fallback)"}") - - Log.d(TAG, "MAC selection: preferred=${FAST_MAC.joinToString(", ")}") - Log.d(TAG, "MAC selected: $selectedMac ${if (fastMac != null) "(fast)" else "(fallback)"}") - - return SshAlgorithms( - cipher = selectedCipher, - kex = selectedKex, - mac = selectedMac - ) - } - - /** - * Создать кастомный Config с указанными алгоритмами - */ - fun createCustomConfig(algorithms: SshAlgorithms): Config { - val defaultConfig = DefaultConfig() - - Log.d(TAG, "Creating custom SSH config with algorithms:") - Log.d(TAG, " - Preferred cipher: ${algorithms.cipher}") - Log.d(TAG, " - Preferred KEX: ${algorithms.kex}") - Log.d(TAG, " - Preferred MAC: ${algorithms.mac}") - - return object : Config by defaultConfig { - override fun getCipherFactories(): List> { - val allFactories = defaultConfig.cipherFactories - return if (algorithms.cipher != null) { - // Ставим предпочтительный алгоритм первым - val preferredFactory = allFactories.find { it.name == algorithms.cipher } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.cipher } - } else { - allFactories - } - } else { - allFactories - } - } - - override fun getKeyExchangeFactories(): List> { - val allFactories = defaultConfig.keyExchangeFactories - return if (algorithms.kex != null) { - val preferredFactory = allFactories.find { it.name == algorithms.kex } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.kex } - } else { - allFactories - } - } else { - allFactories - } - } - - override fun getMACFactories(): List> { - val allFactories = defaultConfig.macFactories - return if (algorithms.mac != null) { - val preferredFactory = allFactories.find { it.name == algorithms.mac } - if (preferredFactory != null) { - listOf(preferredFactory) + allFactories.filter { it.name != algorithms.mac } - } else { - allFactories - } - } else { - allFactories - } - } - } - } - - /** - * Проверить совместимость алгоритмов с сервером - * Возвращает фактически согласованные алгоритмы после подключения - */ - suspend fun testAlgorithmCompatibility( - host: String, - port: Int, - username: String, - keyProvider: net.schmizz.sshj.userauth.keyprovider.KeyProvider, - algorithms: SshAlgorithms, - timeoutMs: Int = 15000 - ): Result { - return try { - val config = createCustomConfig(algorithms) - val sshClient = SSHClient(config) - - val connectionTime = measureTimeMillis { - sshClient.use { client -> - client.connectTimeout = timeoutMs - client.connect(host, port) - client.authPublickey(username, keyProvider) - } - } - - Log.d(TAG, "Algorithm test successful in ${connectionTime}ms") - - // В реальности нужно было бы получить согласованные алгоритмы из транспорта SSH - // Но для упрощения возвращаем исходные алгоритмы как успешные - Result.success(algorithms) - - } catch (e: Exception) { - Log.e(TAG, "Algorithm compatibility test failed", e) - Result.failure(e) - } - } - - /** - * Автоматически выбрать и протестировать лучшие алгоритмы для сервера - */ - suspend fun autoSelectOptimalAlgorithms( - host: String, - port: Int, - username: String, - keyProvider: net.schmizz.sshj.userauth.keyprovider.KeyProvider - ): SshAlgorithms? { - val fastAlgorithms = getFastAlgorithms() - - // Пробуем быстрые алгоритмы - val result = testAlgorithmCompatibility(host, port, username, keyProvider, fastAlgorithms) - - return if (result.isSuccess) { - Log.d(TAG, "Fast algorithms work for $host:$port") - fastAlgorithms - } else { - Log.w(TAG, "Fast algorithms failed for $host:$port, using defaults") - null // Возвращаем null чтобы использовать default config - } - } - - /** - * Получить человекочитаемые названия алгоритмов для UI - */ - fun getAlgorithmDisplayNames(): Map { - return mapOf( - // Ciphers - "aes128-ctr" to "AES-128 CTR (Fast)", - "aes192-ctr" to "AES-192 CTR (Fast)", - "aes256-ctr" to "AES-256 CTR (Fast)", - "aes128-cbc" to "AES-128 CBC", - "aes192-cbc" to "AES-192 CBC", - "aes256-cbc" to "AES-256 CBC", - - // KEX - "diffie-hellman-group14-sha256" to "DH Group14 SHA256", - "diffie-hellman-group16-sha512" to "DH Group16 SHA512", - "ecdh-sha2-nistp256" to "ECDH P-256 (Fast)", - "ecdh-sha2-nistp384" to "ECDH P-384", - "ecdh-sha2-nistp521" to "ECDH P-521", - - // MAC - "hmac-sha2-256" to "HMAC-SHA2-256 (Fast)", - "hmac-sha2-512" to "HMAC-SHA2-512", - "hmac-sha1" to "HMAC-SHA1" - ) - } -} \ No newline at end of file From 816456db89db0e510406acdeb8a3be63447c8a5e Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:16:31 +0300 Subject: [PATCH 023/366] Delete app/src/main/java/com/example/sshproxy/service directory --- .../service/ConnectionHealthMonitor.kt | 174 ------------------ 1 file changed, 174 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt diff --git a/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt b/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt deleted file mode 100644 index c78d5db..0000000 --- a/app/src/main/java/com/example/sshproxy/service/ConnectionHealthMonitor.kt +++ /dev/null @@ -1,174 +0,0 @@ -package com.example.sshproxy.service - -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.util.concurrent.atomic.AtomicInteger -import kotlin.math.min -import kotlin.math.pow - -/** - * Monitors SSH connection health and manages automatic reconnection with exponential backoff - */ -class ConnectionHealthMonitor( - private val healthCheckIntervalMs: Long = 10_000L, // 10 seconds - more aggressive monitoring - private val maxReconnectAttempts: Int = 10, - private val initialBackoffMs: Long = 1_000L, // 1 second - private val maxBackoffMs: Long = 300_000L, // 5 minutes - private val backoffMultiplier: Double = 2.0 -) { - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val reconnectAttempts = AtomicInteger(0) - private var healthCheckJob: Job? = null - private var reconnectJob: Job? = null - - enum class ConnectionState { - CONNECTED, - DISCONNECTED, - RECONNECTING, - FAILED - } - - data class ReconnectStrategy( - val attempt: Int, - val delayMs: Long, - val shouldRetry: Boolean - ) - - /** - * Start monitoring connection health - */ - fun startMonitoring( - scope: CoroutineScope, - isConnectionAlive: suspend () -> Boolean, - onReconnect: suspend () -> Boolean - ) { - stopMonitoring() - - healthCheckJob = scope.launch { - while (isActive) { - delay(healthCheckIntervalMs) - - if (_connectionState.value == ConnectionState.CONNECTED) { - try { - val isAlive = withTimeout(3000L) { isConnectionAlive() } - - if (!isAlive) { - handleConnectionLost(scope, onReconnect) - } else { - // Connection is healthy, reset attempts counter - reconnectAttempts.set(0) - } - } catch (e: Exception) { - handleConnectionLost(scope, onReconnect) - } - } - } - } - } - - /** - * Handle connection loss and initiate reconnection - */ - private fun handleConnectionLost( - scope: CoroutineScope, - onReconnect: suspend () -> Boolean - ) { - if (_connectionState.value == ConnectionState.RECONNECTING) { - return // Already reconnecting - } - - _connectionState.value = ConnectionState.RECONNECTING - - reconnectJob?.cancel() - reconnectJob = scope.launch { - while (isActive) { - val strategy = getReconnectStrategy() - - if (!strategy.shouldRetry) { - _connectionState.value = ConnectionState.FAILED - break - } - - delay(strategy.delayMs) - - try { - val success = withTimeout(10_000L) { onReconnect() } - - if (success) { - _connectionState.value = ConnectionState.CONNECTED - reconnectAttempts.set(0) - break - } - } catch (e: Exception) { - // Reconnection failed, will retry - } - - reconnectAttempts.incrementAndGet() - } - } - } - - /** - * Calculate reconnection strategy with exponential backoff - */ - private fun getReconnectStrategy(): ReconnectStrategy { - val attempt = reconnectAttempts.get() - val shouldRetry = attempt < maxReconnectAttempts - - val delayMs = if (shouldRetry) { - val exponentialDelay = initialBackoffMs * backoffMultiplier.pow(attempt.toDouble()) - min(exponentialDelay.toLong(), maxBackoffMs) - } else { - 0L - } - - return ReconnectStrategy( - attempt = attempt, - delayMs = delayMs, - shouldRetry = shouldRetry - ) - } - - /** - * Notify that connection has been established - */ - fun onConnectionEstablished() { - _connectionState.value = ConnectionState.CONNECTED - reconnectAttempts.set(0) - } - - /** - * Notify that connection has been lost - */ - fun onConnectionLost() { - _connectionState.value = ConnectionState.DISCONNECTED - } - - /** - * Stop monitoring and cancel all jobs - */ - fun stopMonitoring() { - healthCheckJob?.cancel() - healthCheckJob = null - reconnectJob?.cancel() - reconnectJob = null - _connectionState.value = ConnectionState.DISCONNECTED - reconnectAttempts.set(0) - } - - /** - * Reset reconnection attempts counter - */ - fun resetReconnectAttempts() { - reconnectAttempts.set(0) - } - - /** - * Get current number of reconnection attempts - */ - fun getReconnectAttempts(): Int = reconnectAttempts.get() -} From ad3d4cf8a6d51d75a56548592822c90dd670af8d Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:17:05 +0300 Subject: [PATCH 024/366] Delete app/src/main/java/com/example/sshproxy/AppLog.kt --- .../main/java/com/example/sshproxy/AppLog.kt | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/AppLog.kt diff --git a/app/src/main/java/com/example/sshproxy/AppLog.kt b/app/src/main/java/com/example/sshproxy/AppLog.kt deleted file mode 100644 index 15a989d..0000000 --- a/app/src/main/java/com/example/sshproxy/AppLog.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.sshproxy - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import java.text.SimpleDateFormat -import java.util.* - -object AppLog { - private val _logMessages = MutableLiveData>(emptyList()) - val logMessages: LiveData> = _logMessages - - private const val MAX_LOG_LINES = 100 - - fun log(message: String) { - val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) - val logEntry = "$timestamp: $message" - - val currentLogs = _logMessages.value?.toMutableList() ?: mutableListOf() - currentLogs.add(0, logEntry) // Add new message to the top - - if (currentLogs.size > MAX_LOG_LINES) { - currentLogs.removeAt(currentLogs.size - 1) // Remove the oldest - } - - _logMessages.postValue(currentLogs) - } - - fun clear() { - _logMessages.postValue(emptyList()) - } -} From 7965d8b3195d64ada6bb77b108428a075dbb5089 Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:17:48 +0300 Subject: [PATCH 025/366] Delete app/src/main/java/com/example/sshproxy/SshProxyService.kt --- .../com/example/sshproxy/SshProxyService.kt | 1184 ----------------- 1 file changed, 1184 deletions(-) delete mode 100644 app/src/main/java/com/example/sshproxy/SshProxyService.kt diff --git a/app/src/main/java/com/example/sshproxy/SshProxyService.kt b/app/src/main/java/com/example/sshproxy/SshProxyService.kt deleted file mode 100644 index 57b0f31..0000000 --- a/app/src/main/java/com/example/sshproxy/SshProxyService.kt +++ /dev/null @@ -1,1184 +0,0 @@ - -package com.example.sshproxy - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.content.pm.PackageManager -import android.net.VpnService -import android.os.IBinder -import android.os.Build -import android.os.ParcelFileDescriptor -import android.util.Log -import androidx.core.app.NotificationCompat -import com.example.sshproxy.data.KeyRepository -import com.example.sshproxy.data.PreferencesManager -import com.example.sshproxy.data.ServerRepository -import com.example.sshproxy.data.SshKeyManager -import com.example.sshproxy.security.SecureHostKeyVerifier -import com.example.sshproxy.security.SecurityNotificationManager -import com.example.sshproxy.service.ConnectionHealthMonitor -import com.example.sshproxy.network.PingMonitor -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import net.schmizz.sshj.DefaultConfig -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Factory -import com.example.sshproxy.network.SshAlgorithmManager -import com.example.sshproxy.network.SshAlgorithms -import com.example.sshproxy.data.Server -import net.schmizz.sshj.connection.channel.direct.LocalPortForwarder -import net.schmizz.sshj.connection.channel.direct.Parameters -import net.schmizz.sshj.transport.verification.PromiscuousVerifier -import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile -import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile -import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.io.File -import java.io.IOException -import java.security.Security -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.net.Socket -import java.net.URL - -class SshProxyService : VpnService() { - private fun updateVpnWidget() { - val intent = Intent("android.appwidget.action.APPWIDGET_UPDATE") - intent.setPackage(packageName) - sendBroadcast(intent) - } - - private fun setVpnRunningPref(isRunning: Boolean) { - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putBoolean("vpn_running", isRunning).apply() - updateVpnWidget() - } - - private fun setVpnConnectingPref(isConnecting: Boolean) { - AppLog.log("setVpnConnectingPref: isConnecting=$isConnecting") - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putBoolean("vpn_connecting", isConnecting).apply() - - // Управляем морганием виджета - if (isConnecting) { - com.example.sshproxy.widget.VPNStatusWidgetProvider.startBlinking(this) - } else { - com.example.sshproxy.widget.VPNStatusWidgetProvider.stopBlinking(this) - // Дополнительно обновляем виджет после остановки моргания - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - updateVpnWidget() - }, 100) - } - - updateVpnWidget() - } - enum class ConnectionState { - DISCONNECTED, - CONNECTING, - CONNECTED, - DISCONNECTING - } - - companion object { - const val ACTION_START = "com.example.sshproxy.START_VPN" - const val ACTION_STOP = "com.example.sshproxy.STOP_VPN" - const val EXTRA_SERVER_ID = "server_id" - private const val NOTIFICATION_ID = 1 - private const val CHANNEL_ID = "ssh_proxy_channel" - private const val TAG = "SshProxyService" - - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) - val connectionState: StateFlow = _connectionState.asStateFlow() - - // Для обратной совместимости - создаем isRunning из connectionState - private val _isRunning = MutableStateFlow(false) - val isRunning: StateFlow = _isRunning.asStateFlow() - - // Connection start time tracking - private val _connectionStartTime = MutableStateFlow(null) - val connectionStartTime: StateFlow = _connectionStartTime.asStateFlow() - - // Expose ping data - private val _currentPingMonitor = MutableStateFlow(null) - val currentPingMonitor: StateFlow = _currentPingMonitor.asStateFlow() - - init { - android.util.Log.d("SshProxyService", "Companion object init") - // Регистрируем BouncyCastle провайдер для поддержки современной криптографии - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.addProvider(BouncyCastleProvider()) - } - } - - private var sshClient: SSHClient? = null - private var localPortForwarder: LocalPortForwarder? = null - private var vpnInterface: ParcelFileDescriptor? = null - private var connectionJob: Job? = null - private var serverSocket: ServerSocket? = null - private var hostKeyVerifier: SecureHostKeyVerifier? = null - private var securityNotificationManager: SecurityNotificationManager? = null - private var connectionMonitor: ConnectionHealthMonitor? = null - private var pingMonitor: PingMonitor? = null - private lateinit var serviceScope: CoroutineScope - private val algorithmManager = SshAlgorithmManager() - - private lateinit var preferencesManager: PreferencesManager - private lateinit var serverRepository: ServerRepository - private var currentServerId: Long = -1L - private var activeNetworks: Array? = null - private var networkCallback: ConnectivityManager.NetworkCallback? = null - private var lastActiveNetwork: Network? = null - private var isVpnTemporarilyDisabled = false - private var isVpnRecreating = false - private var lastNetworkType: String? = null // "wifi" или "cellular" - - - override fun onCreate() { - super.onCreate() // Corrected: Added missing semicolon - AppLog.log("SshProxyService.onCreate") - serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - createNotificationChannel() - - preferencesManager = PreferencesManager(this) - serverRepository = ServerRepository(this) - - // Initialize security notification manager - securityNotificationManager = SecurityNotificationManager(this) - - // Initialize host key verifier - hostKeyVerifier = SecureHostKeyVerifier(this) - - // Initialize connection monitor - connectionMonitor = ConnectionHealthMonitor( - healthCheckIntervalMs = preferencesManager.getHealthCheckInterval(), - maxReconnectAttempts = preferencesManager.getMaxReconnectAttempts(), - initialBackoffMs = preferencesManager.getInitialBackoffMs(), - maxBackoffMs = preferencesManager.getMaxBackoffMs(), - backoffMultiplier = preferencesManager.getBackoffMultiplier().toDouble() - ) - - // Observe connection state changes - serviceScope.launch { - connectionMonitor?.connectionState?.collectLatest { - when (it) { - ConnectionHealthMonitor.ConnectionState.RECONNECTING -> { - Log.d(TAG, "Attempting to reconnect...") - updateNotification("Reconnecting...") - } - ConnectionHealthMonitor.ConnectionState.CONNECTED -> { - Log.i(TAG, "Connection restored") - updateNotification("Connected") - } - ConnectionHealthMonitor.ConnectionState.FAILED -> { - Log.e(TAG, "Failed to reconnect after maximum attempts") - updateNotification("Connection failed") - stopSelf() - } - else -> {} - } - } - } - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - AppLog.log("SshProxyService.onStartCommand: ${intent?.action}") - when (intent?.action) { - ACTION_START -> { - _connectionState.value = ConnectionState.CONNECTING - _isRunning.value = false // Connecting != Running - setVpnConnectingPref(true) - val serverId = intent.getLongExtra(EXTRA_SERVER_ID, -1) - if (serverId != -1L) { - currentServerId = serverId - startForeground(NOTIFICATION_ID, createNotification("Connecting...")) - connectionJob = serviceScope.launch { - startConnection(serverId) - } - } - } - ACTION_STOP -> { - stopVpn() - } - } - return START_NOT_STICKY - } - - private suspend fun startConnection(serverId: Long) { - withContext(Dispatchers.IO) { - try { - AppLog.log("Starting connection...") - val server = serverRepository.getServerById(serverId) - if (server == null) { - Log.e(TAG, "Server not found") - AppLog.log("Error: Server not found for ID: $serverId") - stopSelf() - return@withContext - } - val prefs = getSharedPreferences("ssh_proxy_prefs", Context.MODE_PRIVATE) - prefs.edit().putString("active_server_host", server.host).apply() - // Проверяем, что VPN еще НЕ активен - val currentVpnStatus = if (vpnInterface != null) "ACTIVE" else "INACTIVE" - AppLog.log("Current VPN status: $currentVpnStatus") - // Получаем активные сети для обхода VPN - val connectivityManager = getSystemService(ConnectivityManager::class.java) - activeNetworks = connectivityManager?.let { cm -> - try { - val activeNetwork = cm.activeNetwork - if (activeNetwork != null) { - val capabilities = cm.getNetworkCapabilities(activeNetwork) - val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - val notVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true - val validated = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true - AppLog.log("Active network: $activeNetwork, hasInternet: $hasInternet, notVPN: $notVpn, validated: $validated") - if (hasInternet && notVpn && validated) { - arrayOf(activeNetwork) - } else { - AppLog.log("Active network is not suitable for SSH bypass") - arrayOf() - } - } else { - AppLog.log("No active network found") - arrayOf() - } - } catch (e: Exception) { - AppLog.log("Error getting networks: ${e.message}") - arrayOf() - } - } ?: arrayOf() - AppLog.log("Found ${activeNetworks?.size ?: 0} active non-VPN networks") - - AppLog.log("Connecting to ${server.name} (${server.host}:${server.port}) as ${server.username}") - AppLog.log("Server SSH key ID: ${server.sshKeyId}") - AppLog.log("SSH connect timeout: 30000ms") - - // SSH подключение с проверкой host key - AppLog.log("Initializing SSH client...") - sshClient = createOptimizedSshClient(server).apply { - // Use our secure host key verifier instead of promiscuous - addHostKeyVerifier(hostKeyVerifier!!) - AppLog.log("Connecting to ${server.host}:${server.port}...") - - try { - connect(server.host, server.port) - AppLog.log("Connected. Authenticating as '${server.username}'...") - - // SSH подключение установлено - AppLog.log("SSH transport connection established successfully") - - } catch (e: Exception) { - // Check if this is a host key verification failure - if (e.message?.contains("host key", ignoreCase = true) == true || - e.message?.contains("verification", ignoreCase = true) == true) { - AppLog.log("Host key verification failed - showing security notification") - securityNotificationManager?.showHostKeyChangeNotification(server.host, server.port) - } - throw e - } - - // Используем ключ сервера, если есть, иначе активный ключ - // Если у сервера нет привязанного ключа (null), функция автоматически использует активный - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found. Please generate one.") - - // Try PKCS8KeyFile first (RSA/ECDSA), иначе показать ошибку для Ed25519 - val keyProvider = try { - val pkcs8KeyFile = PKCS8KeyFile() - pkcs8KeyFile.init(keyFile) - pkcs8KeyFile - } catch (e: Exception) { - AppLog.log("PKCS8KeyFile failed: ${e.message}") - throw IOException("Unable to load SSH key: ${e.message}. Ed25519 keys are not supported, use ECDSA or RSA.") - } - - authPublickey(server.username, keyProvider) - AppLog.log("SSH authentication successful.") - } - - // Автодетекция и сохранение оптимальных алгоритмов для первого подключения - if (server.preferredCipher == null && server.preferredKex == null && server.preferredMac == null) { - serviceScope.launch { - saveOptimalAlgorithmsForServer(server) - } - } - - // 2. Port forwarding - AppLog.log("Setting up port forwarding...") - setupPortForwarding(server) - - // 3. Запускаем мониторинг сети - AppLog.log("Starting network monitoring...") - // Инициализируем текущую активную сеть - val connManager = getSystemService(ConnectivityManager::class.java) - lastActiveNetwork = connManager?.activeNetwork - lastNetworkType = getNetworkType(connManager, lastActiveNetwork) - AppLog.log("Initial active network: $lastActiveNetwork, type: $lastNetworkType") - startNetworkMonitoring() - - // 4. VPN после стабильного SSH - AppLog.log("Setting up VPN...") - setupVpn() - - withContext(Dispatchers.Main) { - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - _connectionStartTime.value = System.currentTimeMillis() - updateNotification("Connected to ${server.name}") - setVpnConnectingPref(false) - setVpnRunningPref(true) - AppLog.log("Connection fully established at ${_connectionStartTime.value}") - - // Start ping monitoring with aggressive connection issue detection - pingMonitor?.stopMonitoring() - pingMonitor = PingMonitor( - server.host, - server.port, - onConnectionIssue = { - AppLog.log("Quick connection issue detected: $it consecutive ping failures") - // Trigger immediate connection check - serviceScope.launch { - if (!checkSshConnection()) { - AppLog.log("SSH connection confirmed dead, triggering reconnection") - connectionMonitor?.onConnectionLost() - } - } - } - ) - pingMonitor?.startMonitoring() - _currentPingMonitor.value = pingMonitor - AppLog.log("Started aggressive ping monitoring for ${server.host}:${server.port}") - } - - connectionMonitor?.onConnectionEstablished() - if (preferencesManager.isAutoReconnectEnabled()) { - connectionMonitor?.startMonitoring( - scope = serviceScope, - isConnectionAlive = { checkSshConnection() }, - onReconnect = { reconnectSsh(serverId) } - ) - } - } catch (e: Exception) { - AppLog.log("SshProxyService.startConnection catch: ${e.message}") - Log.e(TAG, "Connection failed: ${e.message}", e) - connectionMonitor?.onConnectionLost() - withContext(Dispatchers.Main) { - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - setVpnConnectingPref(false) - updateNotification("Connection failed") - stopSelf() - } - } - } - } - - private fun setupPortForwarding(server: com.example.sshproxy.data.Server) { - AppLog.log("Binding to local port ${server.httpProxyPort}...") - serverSocket = ServerSocket(server.httpProxyPort, 50, InetAddress.getByName("127.0.0.1")) - val params = Parameters("127.0.0.1", server.httpProxyPort, "127.0.0.1", 8118) - localPortForwarder = sshClient?.newLocalPortForwarder(params, serverSocket) - - // Start forwarding in background - serviceScope.launch(Dispatchers.IO) { - try { - AppLog.log("Port forwarder listening...") - localPortForwarder?.listen() - AppLog.log("Port forwarder stopped.") - } catch (e: Exception) { - if (e is InterruptedException || e.message?.contains("Socket closed") == true) { - Log.i(TAG, "Port forwarding stopped intentionally.") - AppLog.log("Port forwarding stopped.") - } else { - Log.e(TAG, "Port forwarding error: ${e.message}", e) - AppLog.log("Error: Port forwarding failed - ${e.message}") - } - } - } - } - - private suspend fun setupVpn() { - val server = serverRepository.getServerById(currentServerId) ?: throw IOException("Server not found") - - val builder = Builder() - .setSession("SSH Proxy") - .addAddress("10.0.0.2", 32) // IPv4 - .addAddress("fd00::1", 128) // IPv6 local address - .addDnsServer("8.8.8.8") // IPv4 DNS - .addDnsServer("2001:4860:4860::8888") // IPv6 Google DNS - .setMtu(1500) - - // setHttpProxy доступен только с API 29 - if (Build.VERSION.SDK_INT >= 29) { - builder.setHttpProxy(android.net.ProxyInfo.buildDirectProxy("127.0.0.1", server.httpProxyPort)) - } - - // Настраиваем обход VPN для SSH соединения - if (activeNetworks != null && activeNetworks!!.isNotEmpty()) { - AppLog.log("Setting underlying networks to bypass VPN") - builder.setUnderlyingNetworks(activeNetworks) - } - - // Защита SSH соединения от VPN маршрутизации - try { - // Используем уже подключенный SSH клиент для получения IP - val sshServerIp = if (server.host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) { - // Уже IP адрес - server.host - } else { - // Разрешаем имя хоста - withContext(Dispatchers.IO) { - InetAddress.getByName(server.host).hostAddress - } - } - - AppLog.log("Excluding SSH server $sshServerIp from VPN routes") - - // ПРОСТОЕ РЕШЕНИЕ: используем setUnderlyingNetworks вместо сложной маршрутизации - if (activeNetworks != null && activeNetworks!!.isNotEmpty()) { - AppLog.log("Using setUnderlyingNetworks for SSH bypass") - builder.setUnderlyingNetworks(activeNetworks) - } else { - // Фоллбек - добавляем простые маршруты для IPv4 и IPv6 - AppLog.log("Using split routing fallback") - // IPv4 routes - builder.addRoute("1.0.0.0", 8) // 1.0.0.0/8 - builder.addRoute("2.0.0.0", 7) // 2.0.0.0/7 - builder.addRoute("4.0.0.0", 6) // 4.0.0.0/6 - builder.addRoute("8.0.0.0", 5) // 8.0.0.0/5 - builder.addRoute("16.0.0.0", 4) // 16.0.0.0/4 - builder.addRoute("32.0.0.0", 3) // 32.0.0.0/3 - builder.addRoute("64.0.0.0", 2) // 64.0.0.0/2 - builder.addRoute("128.0.0.0", 1) // 128.0.0.0/1 - // IPv6 routes - route all IPv6 traffic through VPN - builder.addRoute("2000::", 3) // Global IPv6 unicast (2000::/3) - builder.addRoute("fd00::", 8) // Unique local addresses (fd00::/8) - builder.addRoute("fe80::", 10) // Link-local addresses (fe80::/10) - // Исключаем блок с SSH сервером - } - - } catch (e: Exception) { - AppLog.log("Error setting up VPN routes: ${e.message}") - // Минимальная маршрутизация для тестирования - builder.addRoute("8.8.8.8", 32) // IPv4 Google DNS для теста - builder.addRoute("2001:4860:4860::8888", 128) // IPv6 Google DNS для теста - } - - // Per-app split tunneling (allowlist mode) - val allowedApps = preferencesManager.getSplitTunnelingApps() - if (allowedApps.isNotEmpty()) { - // Always include own package so IP checker and connection health check - // work through the VPN tunnel, not bypassing it - try { - builder.addAllowedApplication(packageName) - } catch (e: PackageManager.NameNotFoundException) { - AppLog.log("Split tunneling: could not add own package $packageName") - } - for (pkg in allowedApps) { - try { - builder.addAllowedApplication(pkg) - } catch (e: PackageManager.NameNotFoundException) { - AppLog.log("Split tunneling: skipping unknown package $pkg") - } - } - AppLog.log("Split tunneling: ${allowedApps.size} user apps + own package in allowlist") - } - - vpnInterface = builder.establish() - if (vpnInterface == null) { - throw IOException("Failed to establish VPN") - } - AppLog.log("VPN established with SSH server protection") - } - - private fun addAllRoutesExcept(builder: Builder, excludeIp: String) { - // Разбиваем 0.0.0.0/0 на более мелкие блоки, исключая SSH сервер - val excludeAddr = InetAddress.getByName(excludeIp) - val excludeBytes = excludeAddr.address - - if (excludeBytes.size == 4) { // IPv4 - // Добавляем маршруты, исключая /32 блок SSH сервера - for (i in 0..255) { - if (i.toByte() != excludeBytes[0]) { - builder.addRoute("$i.0.0.0", 8) - } else { - // Разбиваем этот /8 блок дальше - for (j in 0..255) { - if (j.toByte() != excludeBytes[1]) { - builder.addRoute("$i.$j.0.0", 16) - } else { - // Разбиваем этот /16 блок дальше - for (k in 0..255) { - if (k.toByte() != excludeBytes[2]) { - builder.addRoute("$i.$j.$k.0", 24) - } else { - // Добавляем все адреса в /24 блоке кроме SSH сервера - for (l in 0..255) { - if (l.toByte() != excludeBytes[3]) { - builder.addRoute("$i.$j.$k.$l", 32) - } - } - } - } - } - } - } - } - } - } - - private suspend fun checkSshConnection(): Boolean { - return withContext(Dispatchers.IO) { - try { - val client = sshClient - if (client == null || !client.isConnected || !client.isAuthenticated) { - return@withContext false - } - // sshj's isConnected may return true even when the underlying TCP - // connection has silently dropped (server-side timeout, NAT expiry, etc). - // Test the actual proxy port to catch those silent failures. - val server = serverRepository.getServerById(currentServerId) - if (server != null) { - try { - Socket().use { socket -> - socket.soTimeout = 2000 - socket.connect(InetSocketAddress("127.0.0.1", server.httpProxyPort), 2000) - } - true - } catch (e: Exception) { - AppLog.log("Health check: proxy port ${server.httpProxyPort} not responding — tunnel broken") - false - } - } else { - client.isConnected && client.isAuthenticated - } - } catch (e: Exception) { - Log.w(TAG, "Health check failed: ${e.message}") - false - } - } - } - - private suspend fun reconnectSsh(serverId: Long): Boolean { - return withContext(Dispatchers.IO) { - try { - cleanupSshConnection() - delay(500) - - val server = serverRepository.getServerById(serverId) ?: return@withContext false - Log.d(TAG, "Reconnecting to ${server.host}...") - - sshClient = createOptimizedSshClient(server).apply { - addHostKeyVerifier(hostKeyVerifier!!) - connectTimeout = 30000 - connect(server.host, server.port) - - val keyFile = resolvePrivateKeyFile(server.sshKeyId) - ?: throw IOException("SSH key not found for reconnect.") - val pk = PKCS8KeyFile().apply { init(keyFile) } - authPublickey(server.username, pk) - } - - setupPortForwarding(server) - Log.i(TAG, "Reconnection successful") - true - } catch (e: Exception) { - Log.e(TAG, "Reconnection failed: ${e.message}") - false - } - } - } - - private fun cleanupSshConnection() { - try { - localPortForwarder?.close() - serverSocket?.close() - sshClient?.disconnect() - } catch (e: Exception) { - Log.w(TAG, "Error during cleanup: ${e.message}") - } finally { - localPortForwarder = null - serverSocket = null - sshClient = null - } - } - - private fun stopVpn() { - AppLog.log("SshProxyService.stopVpn") - _connectionState.value = ConnectionState.DISCONNECTING - _isRunning.value = false - setVpnConnectingPref(false) - AppLog.log("Disconnecting...") - connectionJob?.cancel() - connectionMonitor?.stopMonitoring() - cleanupSshConnection() - - vpnInterface?.close() - vpnInterface = null - stopNetworkMonitoring() - - _connectionState.value = ConnectionState.DISCONNECTED - _connectionStartTime.value = null - setVpnConnectingPref(false) - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - private suspend fun resolvePrivateKeyFile(serverSshKeyId: String? = null): File? { - return withContext(Dispatchers.IO) { - // Добавим подробные логи для отладки - AppLog.log("resolvePrivateKeyFile called, serverSshKeyId=$serverSshKeyId") - val activeKeyId = preferencesManager.getActiveKeyId() - AppLog.log("Active key from preferences: $activeKeyId") - Log.d(TAG, "resolvePrivateKeyFile: serverSshKeyId=$serverSshKeyId, activeKeyId=$activeKeyId") - - // Используем ключ сервера, если указан, иначе активный ключ - val keyId = serverSshKeyId ?: activeKeyId - AppLog.log("Using SSH key ID: $keyId ${if (serverSshKeyId != null) "(server-specific)" else "(active)"}") - - if (keyId != null) { - try { - val keyRepository = KeyRepository(this@SshProxyService) - - // Get decrypted private key content - val privateKey = keyRepository.getPrivateKey(keyId) - if (privateKey != null) { - // Create temporary PEM file with decrypted content - val tempFile = File.createTempFile("ssh_key_$keyId", ".pem", cacheDir) - tempFile.deleteOnExit() - - // Convert PrivateKey to PEM format and write to temp file - val pemContent = convertPrivateKeyToPem(privateKey) - tempFile.writeText(pemContent) - - AppLog.log("Created temporary decrypted key file: ${tempFile.absolutePath}") - AppLog.log("Temp key file size: ${tempFile.length()} bytes") - AppLog.log("Found SSH key: $keyId") - - return@withContext tempFile - } else { - Log.e(TAG, "Failed to decrypt private key for ID $keyId") - AppLog.log("Error: Failed to decrypt private key for ID $keyId") - } - } catch (e: Exception) { - Log.e(TAG, "Error resolving private key: ${e.message}", e) - AppLog.log("Error resolving private key: ${e.message}") - } - } else { - Log.w(TAG, "No SSH key ID specified.") - AppLog.log("Warning: No SSH key specified.") - } - - Log.e(TAG, "No private key file found.") - AppLog.log("Error: No private key file found.") - return@withContext null - } - } - - private fun convertPrivateKeyToPem(privateKey: java.security.PrivateKey): String { - val stringWriter = java.io.StringWriter() - org.bouncycastle.util.io.pem.PemWriter(stringWriter).use { pemWriter -> - pemWriter.writeObject(org.bouncycastle.util.io.pem.PemObject("PRIVATE KEY", privateKey.encoded)) - } - return stringWriter.toString() - } - - private fun startNetworkMonitoring() { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - if (connectivityManager != null) { - val networkRequest = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - .build() - - networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - AppLog.log("Network available: $network") - handleNetworkChange(network, isAvailable = true) - } - - override fun onLost(network: Network) { - AppLog.log("Network lost: $network") - handleNetworkChange(network, isAvailable = false) - } - - override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { - // Логируем, но не реагируем на мелкие изменения - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - AppLog.log("Network capabilities changed: $network (internet=$hasInternet, wifi=$isWifi, cellular=$isCellular)") - } - } - - connectivityManager.registerNetworkCallback(networkRequest, networkCallback!!) - AppLog.log("Network monitoring started") - } - } - - private fun stopNetworkMonitoring() { - networkCallback?.let { callback -> - val connectivityManager = getSystemService(ConnectivityManager::class.java) - connectivityManager?.unregisterNetworkCallback(callback) - networkCallback = null - AppLog.log("Network monitoring stopped") - } - } - - private fun updateActiveNetworks() { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - val newActiveNetworks = connectivityManager?.let { cm -> - try { - // Получаем активную сеть и её alternatives - val validNetworks = mutableListOf() - - // Ищем базовые сети (не VPN) среди всех доступных - @Suppress("DEPRECATION") - val allNetworks = cm.allNetworks - for (network in allNetworks) { - val capabilities = cm.getNetworkCapabilities(network) - if (capabilities != null) { - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val notVpn = !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - - AppLog.log("Checking network $network: internet=$hasInternet, notVPN=$notVpn, wifi=$isWifi, cellular=$isCellular") - - if (hasInternet && notVpn && (isWifi || isCellular)) { - validNetworks.add(network) - AppLog.log("Added valid network: $network") - } - } - } - - // Всегда используем только активную сеть, если она валидна - val activeNetwork = cm.activeNetwork - if (activeNetwork != null) { - val capabilities = cm.getNetworkCapabilities(activeNetwork) - if (capabilities != null) { - val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - val notVpn = !capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) - val isWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - val isCellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) - - if (hasInternet && notVpn && (isWifi || isCellular)) { - AppLog.log("Using only active network: $activeNetwork") - validNetworks.clear() - validNetworks.add(activeNetwork) - } else { - AppLog.log("Active network $activeNetwork is not suitable (internet=$hasInternet, notVPN=$notVpn, wifi=$isWifi, cellular=$isCellular)") - // Если активная сеть не подходит, используем первую подходящую - if (validNetworks.isNotEmpty()) { - val firstValid = validNetworks.first() - AppLog.log("Using first valid network: $firstValid") - validNetworks.clear() - validNetworks.add(firstValid) - } - } - } - } else if (validNetworks.isNotEmpty()) { - // Если нет активной сети, используем первую подходящую - val firstValid = validNetworks.first() - AppLog.log("No active network, using first valid: $firstValid") - validNetworks.clear() - validNetworks.add(firstValid) - } - - AppLog.log("Found ${validNetworks.size} valid networks") - - validNetworks.toTypedArray() - } catch (e: Exception) { - AppLog.log("Error updating networks: ${e.message}") - arrayOf() - } - } ?: arrayOf() - - if (!newActiveNetworks.contentEquals(activeNetworks)) { - activeNetworks = newActiveNetworks - AppLog.log("Active networks updated: ${activeNetworks?.size ?: 0} networks") - - // Обновляем underlying networks для VPN, если он активен - if (vpnInterface != null) { - updateVpnNetworks() - } - } - } - - private fun handleNetworkChange(network: Network, isAvailable: Boolean) { - serviceScope.launch(Dispatchers.IO) { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - val capabilities = connectivityManager.getNetworkCapabilities(network) - val isVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true - if (isVpn) { - AppLog.log("Ignoring VPN network change") - return@launch - } - - try { - val connectivityManager = getSystemService(ConnectivityManager::class.java) - - // Ждем немного чтобы система определилась с активной сетью - delay(1000) - - val currentActiveNetwork = connectivityManager.activeNetwork - val currentNetworkType = getNetworkType(connectivityManager, currentActiveNetwork) - - // Также проверяем тип появившейся/потерянной сети - val changedNetworkType = if (isAvailable) { - getNetworkType(connectivityManager, network) - } else { - null - } - - AppLog.log("Network change: available=$isAvailable, network=$network, networkType=$changedNetworkType") - AppLog.log("Current active: $currentActiveNetwork, type: $currentNetworkType") - AppLog.log("Last active: $lastActiveNetwork, last type: $lastNetworkType") - - // Реагируем на смену ТИПА сети, потерю активной сети, или появление нового типа сети - val shouldReact = (currentNetworkType != lastNetworkType && currentNetworkType != null) || - (currentNetworkType == null && lastNetworkType != null) || - (isAvailable && changedNetworkType != null && changedNetworkType != lastNetworkType) - - if (shouldReact) { - AppLog.log("Should react to network change: currentType=$currentNetworkType, changedType=$changedNetworkType, lastType=$lastNetworkType") - - if (isVpnRecreating) { - AppLog.log("VPN recreation already in progress, ignoring") - return@launch - } - - isVpnRecreating = true - - try { - // Уведомляем UI о начале переподключения - _connectionState.value = ConnectionState.CONNECTING - _isRunning.value = false - updateNotification("Reconnecting...") - - // Отключаем VPN - if (vpnInterface != null) { - AppLog.log("Disabling VPN for network type change") - vpnInterface?.close() - vpnInterface = null - } - - // Ждем стабилизации сети и ищем лучшую доступную сеть - var attempts = 0 - var finalActiveNetwork: Network? = null - var finalNetworkType: String? = null - - while (attempts < 5 && (finalActiveNetwork == null || finalNetworkType == null)) { - delay(1000) - attempts++ - - finalActiveNetwork = connectivityManager.activeNetwork - finalNetworkType = getNetworkType(connectivityManager, finalActiveNetwork) - - AppLog.log("Attempt $attempts: network=$finalActiveNetwork, type=$finalNetworkType") - - // Если активная сеть не подходит, ищем среди всех доступных - if (finalNetworkType == null) { - @Suppress("DEPRECATION") - val allNetworks = connectivityManager.allNetworks - for (availableNetwork in allNetworks) { - val networkType = getNetworkType(connectivityManager, availableNetwork) - val capabilities = connectivityManager.getNetworkCapabilities(availableNetwork) - val hasInternet = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true - val notVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) != true - - AppLog.log("Checking alternative network $availableNetwork: type=$networkType, internet=$hasInternet, notVPN=$notVpn") - - if (networkType != null && hasInternet && notVpn) { - AppLog.log("Found suitable alternative network: $availableNetwork ($networkType)") - finalActiveNetwork = availableNetwork - finalNetworkType = networkType - break - } - } - } - } - - AppLog.log("Final result: network=$finalActiveNetwork, type=$finalNetworkType") - - if (finalActiveNetwork != null && finalNetworkType != null) { - lastActiveNetwork = finalActiveNetwork - lastNetworkType = finalNetworkType - isVpnTemporarilyDisabled = false - - // Проверяем SSH - val isSSHAlive = try { - sshClient?.isConnected == true && sshClient?.isAuthenticated == true - } catch (e: Exception) { - false - } - - AppLog.log("SSH alive after network change: $isSSHAlive") - - if (isSSHAlive) { - updateActiveNetworks() - setupVpn() - - // SSH живой, просто переустановили VPN - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - updateNotification("Connected") - } else { - AppLog.log("Reconnecting SSH after network change") - val reconnected = reconnectSsh(currentServerId) - if (reconnected) { - AppLog.log("SSH reconnected successfully, setting up VPN") - updateActiveNetworks() - setupVpn() - - // Обновляем состояние UI - время подключения сохраняется при переподключении - _connectionState.value = ConnectionState.CONNECTED - _isRunning.value = true - updateNotification("Connected") - } else { - AppLog.log("SSH reconnection failed") - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - updateNotification("Connection failed") - } - } - } else { - AppLog.log("No suitable network found after 5 attempts") - isVpnTemporarilyDisabled = true - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - updateNotification("No network available") - } - } finally { - isVpnRecreating = false - } - } - } catch (e: Exception) { - AppLog.log("Error handling network change: ${e.message}") - isVpnRecreating = false - } - } - } - - private fun getNetworkType(connectivityManager: ConnectivityManager?, network: Network?): String? { - if (connectivityManager == null || network == null) return null - - return try { - val capabilities = connectivityManager.getNetworkCapabilities(network) - when { - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi" - capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular" - else -> null - } - } catch (e: Exception) { - null - } - } - - private fun updateVpnNetworks() { - try { - if (activeNetworks?.isNotEmpty() == true) { - val currentInterface = vpnInterface - if (currentInterface != null) { - // Пересоздаем VPN с новыми underlying networks - serviceScope.launch(Dispatchers.IO) { - AppLog.log("Updating VPN networks due to network change") - setupVpn() - } - } - } - } catch (e: Exception) { - AppLog.log("Error updating VPN networks: ${e.message}") - } - } - - private fun createNotification(status: String): android.app.Notification { - val intent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = if (intent != null) { - PendingIntent.getActivity( - this, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - } else null - - val stopIntent = Intent(this, SshProxyService::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("SSH Proxy") - .setContentText(status) - .setSmallIcon(android.R.drawable.ic_lock_lock) - .apply { - if (pendingIntent != null) { - setContentIntent(pendingIntent) - } - } - .addAction(android.R.drawable.ic_delete, "Stop", stopPendingIntent) - .build() - } - - private fun updateNotification(status: String) { - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager?.notify(NOTIFICATION_ID, createNotification(status)) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - "SSH Proxy Service", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "SSH Proxy VPN connection status" - } - - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager?.createNotificationChannel(channel) - } - } - - override fun onDestroy() { - AppLog.log("SshProxyService.onDestroy") - _connectionState.value = ConnectionState.DISCONNECTED - _isRunning.value = false - _connectionStartTime.value = null - setVpnConnectingPref(false) - setVpnRunningPref(false) - connectionMonitor?.stopMonitoring() - pingMonitor?.stopMonitoring() - _currentPingMonitor.value = null - serviceScope.cancel() - cleanupSshConnection() - super.onDestroy() - } - - /** - * Создать SSH клиент с оптимальными алгоритмами - */ - private suspend fun createOptimizedSshClient(server: Server): SSHClient { - return withContext(Dispatchers.IO) { - // Проверяем есть ли сохраненные алгоритмы для этого сервера - val savedAlgorithms = SshAlgorithms( - cipher = server.preferredCipher, - kex = server.preferredKex, - mac = server.preferredMac - ) - - val hasCustomAlgorithms = savedAlgorithms.cipher != null || - savedAlgorithms.kex != null || - savedAlgorithms.mac != null - - val client = if (hasCustomAlgorithms) { - AppLog.log("Using saved algorithms for ${server.name}:") - AppLog.log(" - Cipher: ${savedAlgorithms.cipher}") - AppLog.log(" - KEX: ${savedAlgorithms.kex}") - AppLog.log(" - MAC: ${savedAlgorithms.mac}") - val customConfig = algorithmManager.createCustomConfig(savedAlgorithms) - SSHClient(customConfig) - } else { - AppLog.log("No saved algorithms for ${server.name}, using default SSH config") - AppLog.log("Fast algorithms will be auto-selected and saved after successful connection") - SSHClient() - } - // Protect the SSH socket so it bypasses the VPN tunnel. - // Without this, when the app package is in the split-tunneling allowlist, - // the SSH socket would route through the VPN → proxy → SSH → loop. - applyProtectedSocketFactory(client) - client - } - } - - /** - * Inject a VPN-protected SocketFactory into an SSHClient via reflection. - * Sockets created by this factory call VpnService.protect() before connecting, - * ensuring SSH traffic goes directly to the server without routing through the - * VPN tunnel (which would cause a routing loop when the app is in the allowlist). - */ - private fun applyProtectedSocketFactory(client: SSHClient) { - val factory = object : javax.net.SocketFactory() { - override fun createSocket(): Socket = Socket().also { protect(it) } - override fun createSocket(host: String, port: Int): Socket { - val s = createSocket() - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { - val s = createSocket() - s.bind(InetSocketAddress(localHost, localPort)) - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(host: InetAddress, port: Int): Socket { - val s = createSocket() - s.connect(InetSocketAddress(host, port)) - return s - } - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { - val s = createSocket() - s.bind(InetSocketAddress(localAddress, localPort)) - s.connect(InetSocketAddress(address, port)) - return s - } - } - var clazz: Class<*>? = client.javaClass - while (clazz != null) { - try { - val field = clazz.getDeclaredField("socketFactory") - field.isAccessible = true - field.set(client, factory) - AppLog.log("SSH socket: VPN protection applied") - return - } catch (e: NoSuchFieldException) { - clazz = clazz.superclass - } catch (e: Exception) { - AppLog.log("SSH socket: could not apply VPN protection — ${e.message}") - return - } - } - AppLog.log("SSH socket: socketFactory field not found, SSH may loop through VPN") - } - - /** - * Сохранить быстрые алгоритмы для сервера (после успешного подключения) - */ - private suspend fun saveOptimalAlgorithmsForServer(server: Server) { - try { - AppLog.log("Saving optimal algorithms for ${server.name}...") - - // Получаем быстрые алгоритмы из менеджера - val fastAlgorithms = algorithmManager.getFastAlgorithms() - - AppLog.log("Selected fast algorithms for ${server.name}: cipher=${fastAlgorithms.cipher}, kex=${fastAlgorithms.kex}, mac=${fastAlgorithms.mac}") - - // Сохраняем в базу данных - val updatedServer = server.copy( - preferredCipher = fastAlgorithms.cipher, - preferredKex = fastAlgorithms.kex, - preferredMac = fastAlgorithms.mac - ) - - serverRepository.updateServer(updatedServer) - AppLog.log("Saved fast algorithms for ${server.name}") - - } catch (e: Exception) { - AppLog.log("Failed to save algorithms for ${server.name}: ${e.message}") - Log.w(TAG, "Algorithm saving failed", e) - } - } -} From b34bb13e5098c544e8d9c1af62861d22ad5591ea Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:23:49 +0300 Subject: [PATCH 026/366] Create HttpCustomActivity.kt --- .../example/sshproxy/HttpCustomActivity.kt | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt diff --git a/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt b/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt new file mode 100644 index 0000000..2a76a23 --- /dev/null +++ b/app/src/main/java/com/example/sshproxy/HttpCustomActivity.kt @@ -0,0 +1,102 @@ +package com.example.sshproxy + +import android.os.Bundle +import android.widget.Button +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity + +class HttpCustomActivity : AppCompatActivity() { + + private lateinit var sshHostInput: EditText + private lateinit var sshPortInput: EditText + private lateinit var sshUsernameInput: EditText + private lateinit var sshPasswordInput: EditText + private lateinit var proxyHostInput: EditText + private lateinit var proxyPortInput: EditText + private lateinit var payloadInput: EditText + private lateinit var connectButton: Button + private lateinit var disconnectButton: Button + private lateinit var statusText: TextView + private lateinit var logText: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_http_custom) + + sshHostInput = findViewById(R.id.sshHostInput) + sshPortInput = findViewById(R.id.sshPortInput) + sshUsernameInput = findViewById(R.id.sshUsernameInput) + sshPasswordInput = findViewById(R.id.sshPasswordInput) + proxyHostInput = findViewById(R.id.proxyHostInput) + proxyPortInput = findViewById(R.id.proxyPortInput) + payloadInput = findViewById(R.id.payloadInput) + connectButton = findViewById(R.id.connectButton) + disconnectButton = findViewById(R.id.disconnectButton) + statusText = findViewById(R.id.statusText) + logText = findViewById(R.id.logText) + + connectButton.setOnClickListener { + addLog("[UI] Connect button pressed") + startVpnService() + } + + disconnectButton.setOnClickListener { + addLog("[UI] Disconnect button pressed") + stopVpnService() + } + } + + private fun startVpnService() { + val sshHost = sshHostInput.text.toString().trim() + val sshPort = sshPortInput.text.toString().trim() + val sshUser = sshUsernameInput.text.toString().trim() + val sshPass = sshPasswordInput.text.toString().trim() + val proxyHost = proxyHostInput.text.toString().trim() + val proxyPort = proxyPortInput.text.toString().trim() + val payload = payloadInput.text.toString().trim() + + if (sshHost.isEmpty() || sshPort.isEmpty() || sshUser.isEmpty() || sshPass.isEmpty()) { + addLog("[ERROR] Please fill in SSH details") + statusText.text = "Status: Error - Missing SSH details" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + return + } + + addLog("[Config] SSH: $sshHost:$sshPort") + addLog("[Config] Proxy: $proxyHost:$proxyPort") + addLog("[Config] Payload: ${if (payload.length > 50) payload.substring(0, 50) + "..." else payload}") + + val intent = android.content.Intent(this, CustomVpnService::class.java) + intent.putExtra("sshHost", sshHost) + intent.putExtra("sshPort", sshPort) + intent.putExtra("sshUser", sshUser) + intent.putExtra("sshPass", sshPass) + intent.putExtra("proxyHost", proxyHost) + intent.putExtra("proxyPort", proxyPort) + intent.putExtra("payload", payload) + + startService(intent) + statusText.text = "Status: Connecting..." + statusText.setTextColor(resources.getColor(android.R.color.holo_orange_dark)) + disconnectButton.isEnabled = true + } + + private fun stopVpnService() { + val intent = android.content.Intent(this, CustomVpnService::class.java) + stopService(intent) + statusText.text = "Status: Disconnected" + statusText.setTextColor(resources.getColor(android.R.color.holo_red_dark)) + disconnectButton.isEnabled = false + } + + fun addLog(message: String) { + runOnUiThread { + logText.append("\n$message") + val scrollAmount = logText.layout?.getLineTop(logText.lineCount) ?: 0 + if (scrollAmount > logText.height) { + logText.scrollTo(0, scrollAmount - logText.height) + } + } + } +} From 1a9851813d77aca0e08b449cd4dfeebc3b8505ab Mon Sep 17 00:00:00 2001 From: Guevara999 Date: Fri, 7 Aug 2026 20:40:55 +0300 Subject: [PATCH 027/366] Update AndroidManifest.xml --- app/src/main/AndroidManifest.xml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a05f7cd..57e9a54 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,6 @@ android:usesCleartextTraffic="true" tools:targetApi="31"> - - Date: Fri, 7 Aug 2026 21:20:58 +0300 Subject: [PATCH 028/366] Update activity_http_custom.xml --- .../main/res/layout/activity_http_custom.xml | 77 +++++-------------- 1 file changed, 21 insertions(+), 56 deletions(-) diff --git a/app/src/main/res/layout/activity_http_custom.xml b/app/src/main/res/layout/activity_http_custom.xml index 80458cb..687b8c0 100644 --- a/app/src/main/res/layout/activity_http_custom.xml +++ b/app/src/main/res/layout/activity_http_custom.xml @@ -1,68 +1,33 @@ - + android:gravity="center"> - + android:text="HTTP Custom Clone" + android:textSize="24sp" + android:textStyle="bold" /> - - - - - - - - + - - - +