commit 188bc459b17da58860bc8d9c8ca7e429d42c3922 Author: Daniel Date: Sat Nov 15 22:58:08 2025 +0300 New Branch: Full project rewrite (Beta) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b6985c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..4a8c5a4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("com.android.application") + kotlin("android") +} + +android { + namespace = "com.example.vedroid" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.vedroid" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildToolsVersion = "34.0.0" +} + +dependencies { + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + + // SSH библиотека + implementation("com.github.mwiede:jsch:0.2.11") + + // Корутины для асинхронности + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Для работы с терминалом + implementation("com.jcraft:jzlib:1.1.3") // Для лучшей поддержки терминала +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5f255ec --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/vedroid/MainActivity.kt b/app/src/main/java/com/example/vedroid/MainActivity.kt new file mode 100644 index 0000000..ddd1510 --- /dev/null +++ b/app/src/main/java/com/example/vedroid/MainActivity.kt @@ -0,0 +1,11 @@ +package com.example.vedroid + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} diff --git a/app/src/main/java/com/example/vedroid/SshActivity.kt b/app/src/main/java/com/example/vedroid/SshActivity.kt new file mode 100644 index 0000000..62eab72 --- /dev/null +++ b/app/src/main/java/com/example/vedroid/SshActivity.kt @@ -0,0 +1,333 @@ +package com.example.vedroid + +import android.content.SharedPreferences +import android.content.Intent // ✅ Добавляем этот импорт +import android.os.Bundle +import android.widget.* +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import com.jcraft.jsch.ChannelExec +import com.jcraft.jsch.JSch +import org.json.JSONArray +import org.json.JSONObject +import com.example.vedroid.model.SshProfile + +class SshActivity : AppCompatActivity() { + + private lateinit var hostInput: EditText + private lateinit var portInput: EditText + private lateinit var usernameInput: EditText + private lateinit var passwordInput: EditText + private lateinit var connectButton: Button + private lateinit var executeButton: Button + private lateinit var outputText: TextView + private lateinit var profilesSpinner: Spinner + private lateinit var saveProfileButton: Button + private lateinit var deleteProfileButton: Button + private lateinit var terminalButton: Button // ✅ Добавляем terminalButton + + private val jsch = JSch() + private lateinit var prefs: SharedPreferences + private val profiles = mutableListOf() + private lateinit var adapter: ArrayAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_ssh) + + prefs = getSharedPreferences("ssh_profiles", MODE_PRIVATE) + initViews() + loadProfiles() + setupClickListeners() + } + + private fun initViews() { + hostInput = findViewById(R.id.hostInput) + portInput = findViewById(R.id.portInput) + usernameInput = findViewById(R.id.usernameInput) + passwordInput = findViewById(R.id.passwordInput) + connectButton = findViewById(R.id.connectButton) + executeButton = findViewById(R.id.executeButton) + outputText = findViewById(R.id.outputText) + profilesSpinner = findViewById(R.id.profilesSpinner) + saveProfileButton = findViewById(R.id.saveProfileButton) + deleteProfileButton = findViewById(R.id.deleteProfileButton) + terminalButton = findViewById(R.id.terminalButton) // ✅ Инициализируем terminalButton + + executeButton.isEnabled = false + + // Настройка спиннера профилей + adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, mutableListOf()) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + profilesSpinner.adapter = adapter + } + + private fun openTerminal(profile: SshProfile) { + val intent = Intent(this, TerminalActivity::class.java).apply { + // Передаем отдельные поля вместо всего объекта + putExtra("profile_name", profile.name) + putExtra("host", profile.host) + putExtra("port", profile.port) + putExtra("username", profile.username) + putExtra("password", profile.password) + } + startActivity(intent) + } + + private fun setupClickListeners() { + connectButton.setOnClickListener { + connectSsh() + } + + executeButton.setOnClickListener { + executeCommand("ls -la") + } + + saveProfileButton.setOnClickListener { + showSaveProfileDialog() + } + + deleteProfileButton.setOnClickListener { + deleteCurrentProfile() + } + + terminalButton.setOnClickListener { // ✅ Добавляем обработчик для terminalButton + val position = profilesSpinner.selectedItemPosition + if (position > 0) { + val profile = profiles[position - 1] + openTerminal(profile) + } else { + // Создаем временный профиль из текущих данных + val tempProfile = SshProfile( + name = "Temp Terminal", + host = hostInput.text.toString(), + port = portInput.text.toString().toIntOrNull() ?: 22, + username = usernameInput.text.toString(), + password = passwordInput.text.toString() + ) + openTerminal(tempProfile) + } + } + + profilesSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: android.view.View?, position: Int, id: Long) { + if (position > 0) { // position 0 is "New Profile" + loadProfile(profiles[position - 1]) + } + } + override fun onNothingSelected(parent: AdapterView<*>?) {} + } + } + + private fun loadProfiles() { + val profilesJson = prefs.getString("profiles", "[]") ?: "[]" + val jsonArray = JSONArray(profilesJson) + + profiles.clear() + val profileNames = mutableListOf("New Profile") + + for (i in 0 until jsonArray.length()) { + val json = jsonArray.getJSONObject(i) + val profile = SshProfile( + name = json.getString("name"), + host = json.getString("host"), + port = json.getInt("port"), + username = json.getString("username"), + password = json.getString("password") + ) + profiles.add(profile) + profileNames.add(profile.name) + } + + adapter.clear() + adapter.addAll(profileNames) + } + + private fun saveProfiles() { + val jsonArray = JSONArray() + profiles.forEach { profile -> + val json = JSONObject().apply { + put("name", profile.name) + put("host", profile.host) + put("port", profile.port) + put("username", profile.username) + put("password", profile.password) + } + jsonArray.put(json) + } + + prefs.edit().putString("profiles", jsonArray.toString()).apply() + loadProfiles() // Reload to update spinner + } + + private fun showSaveProfileDialog() { + val dialogView = layoutInflater.inflate(R.layout.dialog_save_profile, null) + val nameInput = dialogView.findViewById(R.id.profileNameInput) + + // Pre-fill with current connection details + nameInput.setText("${usernameInput.text}@${hostInput.text}") + + AlertDialog.Builder(this) + .setTitle("Save Profile") + .setView(dialogView) + .setPositiveButton("Save") { _, _ -> + val profileName = nameInput.text.toString() + if (profileName.isNotEmpty()) { + saveProfile(profileName) + } + } + .setNegativeButton("Cancel", null) + .show() + } + + private fun saveProfile(name: String) { + val profile = SshProfile( + name = name, + host = hostInput.text.toString(), + port = portInput.text.toString().toIntOrNull() ?: 22, + username = usernameInput.text.toString(), + password = passwordInput.text.toString() + ) + + profiles.removeAll { it.name == name } // Remove existing with same name + profiles.add(profile) + saveProfiles() + + // Select the newly saved profile + profilesSpinner.setSelection(adapter.getPosition(name)) + + appendOutput("✅ Profile '$name' saved") + } + + private fun deleteCurrentProfile() { + val position = profilesSpinner.selectedItemPosition + if (position > 0) { + val profile = profiles[position - 1] + AlertDialog.Builder(this) + .setTitle("Delete Profile") + .setMessage("Delete profile '${profile.name}'?") + .setPositiveButton("Delete") { _, _ -> + profiles.removeAt(position - 1) + saveProfiles() + profilesSpinner.setSelection(0) // Select "New Profile" + appendOutput("🗑️ Profile '${profile.name}' deleted") + } + .setNegativeButton("Cancel", null) + .show() + } + } + + private fun loadProfile(profile: SshProfile) { + hostInput.setText(profile.host) + portInput.setText(profile.port.toString()) + usernameInput.setText(profile.username) + passwordInput.setText(profile.password) + + appendOutput("📁 Loaded profile: ${profile.name}") + } + + private fun connectSsh() { + val host = hostInput.text.toString() + val port = portInput.text.toString().toIntOrNull() ?: 22 + val username = usernameInput.text.toString() + val password = passwordInput.text.toString() + + if (host.isEmpty() || username.isEmpty() || password.isEmpty()) { + appendOutput("❌ Please fill all fields") + return + } + + GlobalScope.launch(Dispatchers.IO) { + try { + appendOutput("🔌 Connecting to $host:$port...") + + val session = jsch.getSession(username, host, port).apply { + setPassword(password) + setConfig("StrictHostKeyChecking", "no") // ⚠️ Only for testing! + connect(30000) // 30 second timeout + } + + appendOutput("✅ Connected successfully!") + + runOnUiThread { + executeButton.isEnabled = true + } + + } catch (e: Exception) { + appendOutput("❌ Connection failed: ${e.message}") + e.printStackTrace() + } + } + } + + private fun executeCommand(command: String) { + val host = hostInput.text.toString() + val port = portInput.text.toString().toIntOrNull() ?: 22 + val username = usernameInput.text.toString() + val password = passwordInput.text.toString() + + if (host.isEmpty() || username.isEmpty() || password.isEmpty()) { + appendOutput("❌ Please fill all fields") + return + } + + GlobalScope.launch(Dispatchers.IO) { + try { + appendOutput("💻 Executing: $command") + + val session = jsch.getSession(username, host, port).apply { + setPassword(password) + setConfig("StrictHostKeyChecking", "no") + connect(30000) + } + + val channel = session.openChannel("exec") as ChannelExec + channel.setCommand(command) + + val inputStream = channel.inputStream + val errorStream = channel.errStream + + channel.connect() + + val output = inputStream.bufferedReader().readText() + val error = errorStream.bufferedReader().readText() + + channel.disconnect() + session.disconnect() + + runOnUiThread { + if (output.isNotEmpty()) { + appendOutput("📄 Output:\n$output") + } + if (error.isNotEmpty()) { + appendOutput("⚠️ Error:\n$error") + } + appendOutput("🔚 Command completed") + } + + } catch (e: Exception) { + appendOutput("❌ SSH operation failed: ${e.message}") + e.printStackTrace() + } + } + } + + private fun appendOutput(text: String) { + runOnUiThread { + val current = outputText.text.toString() + outputText.text = if (current.isEmpty()) text else "$current\n$text" + } + } +} + +// Простой data class для профилей +data class SshProfile( + val name: String, + val host: String, + val port: Int, + val username: String, + val password: String +) diff --git a/app/src/main/java/com/example/vedroid/TerminalActivity.kt b/app/src/main/java/com/example/vedroid/TerminalActivity.kt new file mode 100644 index 0000000..7131f11 --- /dev/null +++ b/app/src/main/java/com/example/vedroid/TerminalActivity.kt @@ -0,0 +1,312 @@ +package com.example.vedroid + +import android.os.Bundle +import android.text.method.ScrollingMovementMethod +import android.view.KeyEvent +import android.view.inputmethod.EditorInfo +import android.widget.* +import androidx.appcompat.app.AppCompatActivity +import com.jcraft.jsch.ChannelShell +import com.jcraft.jsch.JSch +import kotlinx.coroutines.* +import java.io.InputStream +import java.io.OutputStream +import java.nio.charset.StandardCharsets + +class TerminalActivity : AppCompatActivity() { + + private lateinit var terminalView: TextView + private lateinit var inputEditText: EditText + private lateinit var terminalScrollView: ScrollView + private lateinit var ctrlCButton: Button + private lateinit var ctrlDButton: Button + private lateinit var clearButton: Button + private lateinit var sendButton: Button + + private val jsch = JSch() + private var channel: ChannelShell? = null + private var inputStream: InputStream? = null + private var outputStream: OutputStream? = null + + private var isConnected = false + private val terminalScope = CoroutineScope(Dispatchers.Main + Job()) + private val commandHistory = mutableListOf() + private var historyIndex = -1 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_terminal) + + val host = intent.getStringExtra("host") ?: "" + val port = intent.getIntExtra("port", 22) + val username = intent.getStringExtra("username") ?: "" + val password = intent.getStringExtra("password") ?: "" + + if (host.isEmpty() || username.isEmpty() || password.isEmpty()) { + showError("Invalid connection data") + finish() + return + } + + initViews() + setupClickListeners() + connectToTerminal(host, port, username, password) + } + + private fun initViews() { + terminalView = findViewById(R.id.terminalView) + inputEditText = findViewById(R.id.inputEditText) + terminalScrollView = findViewById(R.id.terminalScrollView) + ctrlCButton = findViewById(R.id.ctrlCButton) + ctrlDButton = findViewById(R.id.ctrlDButton) + clearButton = findViewById(R.id.clearButton) + sendButton = findViewById(R.id.sendButton) + + terminalView.movementMethod = ScrollingMovementMethod() + terminalView.text = "" + + // Настройка истории команд + inputEditText.setOnKeyListener { _, keyCode, event -> + if (event.action == KeyEvent.ACTION_DOWN) { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + showPreviousCommand() + return@setOnKeyListener true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + showNextCommand() + return@setOnKeyListener true + } + } + } + false + } + } + + private fun setupClickListeners() { + sendButton.setOnClickListener { + sendCommand() + } + + inputEditText.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + sendCommand() + true + } else { + false + } + } + + ctrlCButton.setOnClickListener { + sendControlC() + } + + ctrlDButton.setOnClickListener { + sendControlD() + } + + clearButton.setOnClickListener { + clearTerminal() + } + } + + private fun connectToTerminal(host: String, port: Int, username: String, password: String) { + terminalScope.launch(Dispatchers.IO) { + try { + appendToTerminal("🔌 Connecting to $host:$port...\n") + + val session = jsch.getSession(username, host, port).apply { + setPassword(password) + setConfig("StrictHostKeyChecking", "no") + setConfig("PreferredAuthentications", "password") + + // Важные настройки для терминала + setConfig("compression.s2c", "none") + setConfig("compression.c2s", "none") + setConfig("StrictHostKeyChecking", "no") + + connect(30000) + } + + channel = session.openChannel("shell") as ChannelShell + channel?.apply { + // Упрощенные настройки терминала + setPtyType("vt100") // Простой терминал вместо xterm + setPtySize(80, 24, 0, 0) + + // Минимальные настройки окружения + setEnv("TERM", "vt100") + setEnv("LANG", "C") // Простая локаль + + connect(5000) + } + + inputStream = channel?.inputStream + outputStream = channel?.outputStream + + isConnected = true + + appendToTerminal("✅ Connected to SSH terminal\n") + appendToTerminal("💡 Type commands in the input field below\n\n") + + // Запускаем чтение вывода + launch(Dispatchers.IO) { + readTerminalOutput() + } + + } catch (e: Exception) { + appendToTerminal("❌ Connection failed: ${e.message}\n") + } + } + } + + private fun readTerminalOutput() { + val buffer = ByteArray(1024) + try { + while (isConnected && channel?.isConnected == true) { + val length = inputStream?.read(buffer) ?: -1 + if (length > 0) { + val output = String(buffer, 0, length, StandardCharsets.UTF_8) + val cleanedOutput = cleanTerminalOutput(output) + appendToTerminal(cleanedOutput) + } else if (length < 0) { + break + } + } + } catch (e: Exception) { + if (isConnected) { + appendToTerminal("\n❌ Connection lost\n") + } + } finally { + disconnect() + } + } + + private fun cleanTerminalOutput(output: String): String { + return output + .replace("\u001B\\[[?]?[0-9;]*[A-Za-z]".toRegex(), "") // ANSI escape sequences + .replace("\u001B\\].*?\u0007".toRegex(), "") // OSC sequences + .replace("\u0007", "") // Bell character + .replace("\u0008", "") // Backspace + .replace("\r\n", "\n") // Normalize line endings + .replace("\r", "\n") + } + + private fun sendCommand() { + val command = inputEditText.text.toString().trim() + if (command.isNotEmpty() && isConnected) { + // Добавляем в историю + if (commandHistory.isEmpty() || commandHistory.last() != command) { + commandHistory.add(command) + if (commandHistory.size > 50) { + commandHistory.removeAt(0) + } + } + historyIndex = commandHistory.size + + // Показываем команду в терминале + appendToTerminal("$ ${command}\n") + + // Отправляем команду + terminalScope.launch(Dispatchers.IO) { + try { + outputStream?.write("$command\n".toByteArray(StandardCharsets.UTF_8)) + outputStream?.flush() + } catch (e: Exception) { + appendToTerminal("❌ Error sending command\n") + } + } + + inputEditText.text.clear() + } + } + + private fun showPreviousCommand() { + if (commandHistory.isNotEmpty()) { + if (historyIndex > 0) historyIndex-- + if (historyIndex in commandHistory.indices) { + inputEditText.setText(commandHistory[historyIndex]) + inputEditText.setSelection(inputEditText.text.length) + } + } + } + + private fun showNextCommand() { + if (commandHistory.isNotEmpty()) { + if (historyIndex < commandHistory.size - 1) { + historyIndex++ + inputEditText.setText(commandHistory[historyIndex]) + inputEditText.setSelection(inputEditText.text.length) + } else { + historyIndex = commandHistory.size + inputEditText.text.clear() + } + } + } + + private fun sendControlC() { + if (isConnected) { + terminalScope.launch(Dispatchers.IO) { + try { + outputStream?.write(3) // Ctrl+C + outputStream?.flush() + appendToTerminal("^C\n") + } catch (e: Exception) { + appendToTerminal("❌ Error sending Ctrl+C\n") + } + } + } + } + + private fun sendControlD() { + if (isConnected) { + terminalScope.launch(Dispatchers.IO) { + try { + outputStream?.write(4) // Ctrl+D + outputStream?.flush() + appendToTerminal("^D\n") + } catch (e: Exception) { + appendToTerminal("❌ Error sending Ctrl+D\n") + } + } + } + } + + private fun appendToTerminal(text: String) { + runOnUiThread { + val current = terminalView.text.toString() + terminalView.text = current + text + + terminalScrollView.post { + terminalScrollView.fullScroll(android.view.View.FOCUS_DOWN) + } + } + } + + private fun clearTerminal() { + runOnUiThread { + terminalView.text = "" + appendToTerminal("Terminal cleared\n") + } + } + + private fun showError(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + + private fun disconnect() { + isConnected = false + try { + channel?.disconnect() + } catch (e: Exception) { + // Ignore + } + appendToTerminal("\n🔌 Disconnected\n") + } + + override fun onDestroy() { + super.onDestroy() + disconnect() + terminalScope.cancel() + } +} diff --git a/app/src/main/java/com/example/vedroid/model/SshProfile.kt b/app/src/main/java/com/example/vedroid/model/SshProfile.kt new file mode 100644 index 0000000..2a29ee4 --- /dev/null +++ b/app/src/main/java/com/example/vedroid/model/SshProfile.kt @@ -0,0 +1,10 @@ +package com.example.vedroid.model + +// Теперь не нужно Serializable +data class SshProfile( + val name: String, + val host: String, + val port: Int, + val username: String, + val password: String +) diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..37ea8e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..becd642 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_ssh.xml b/app/src/main/res/layout/activity_ssh.xml new file mode 100644 index 0000000..0ee8f7e --- /dev/null +++ b/app/src/main/res/layout/activity_ssh.xml @@ -0,0 +1,118 @@ + + + + + + + + + +