Compare commits
16 Commits
v1.2.0
...
local-build
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e5056aaa | |||
| eb03204347 | |||
| 9315476a40 | |||
| bc9d8602ad | |||
| 4c31834810 | |||
| 708f1cb986 | |||
| f12efdbb08 | |||
| 69f51e5fd8 | |||
| b1355aacff | |||
| 0bb7616ed2 | |||
| b446478265 | |||
| 7c09aae659 | |||
| 9bf76bfed7 | |||
| b3281928f1 | |||
| 513121d884 | |||
| 58320fcaf5 |
@@ -1,6 +1,7 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
build/
|
||||
*.apk
|
||||
*.aab
|
||||
local.properties
|
||||
.idea/
|
||||
**/*.iml
|
||||
*.apk
|
||||
*.iml
|
||||
|
||||
@@ -1,115 +1,33 @@
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
|
||||
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val resolvedAndroidStoreFile =
|
||||
androidStoreFile?.let { storeFilePath ->
|
||||
if (storeFilePath.startsWith("~/")) {
|
||||
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
|
||||
} else {
|
||||
storeFilePath
|
||||
}
|
||||
}
|
||||
|
||||
val hasAndroidReleaseSigning =
|
||||
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
|
||||
|
||||
val wantsAndroidReleaseBuild =
|
||||
gradle.startParameter.taskNames.any { taskName ->
|
||||
taskName.contains("Release", ignoreCase = true) ||
|
||||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
|
||||
}
|
||||
|
||||
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
|
||||
error(
|
||||
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
|
||||
)
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 36
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
|
||||
storePassword = checkNotNull(androidStorePassword)
|
||||
keyAlias = checkNotNull(androidKeyAlias)
|
||||
keyPassword = checkNotNull(androidKeyPassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
}
|
||||
}
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
targetSdk = 35
|
||||
versionCode = 2026052100
|
||||
versionName = "2026.5.21"
|
||||
versionName = "1.3.2"
|
||||
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "store"
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "store"
|
||||
}
|
||||
create("thirdParty") {
|
||||
dimension = "store"
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("play") {
|
||||
java.srcDirs("src/thirdParty/java")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -117,171 +35,28 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes +=
|
||||
setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
|
||||
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
|
||||
)
|
||||
}
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
lint {
|
||||
lintConfig = file("lint.xml")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
|
||||
val outputFileName =
|
||||
if (flavorName == null) {
|
||||
"openclaw-$versionName-$buildType.apk"
|
||||
} else {
|
||||
"openclaw-$versionName-$flavorName-$buildType.apk"
|
||||
}
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
exclude("**/build/**")
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation(libs.material)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.bcprov)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.autolink)
|
||||
implementation(libs.commonmark.ext.gfm.strikethrough)
|
||||
implementation(libs.commonmark.ext.gfm.tables)
|
||||
implementation(libs.commonmark.ext.task.list.items)
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.video)
|
||||
implementation(libs.play.services.code.scanner)
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(libs.coroutines.android)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.jsch)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotest.runner.junit5)
|
||||
testImplementation(libs.kotest.assertions.core)
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
val variantName = variant.name
|
||||
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
||||
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
|
||||
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
|
||||
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
|
||||
val mergedJar =
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
|
||||
)
|
||||
|
||||
val stripTask =
|
||||
tasks.register(stripTaskName) {
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == mergeTaskName }.configureEach {
|
||||
finalizedBy(stripTask)
|
||||
}
|
||||
tasks.matching { it.name == minifyTaskName }.configureEach {
|
||||
dependsOn(stripTask)
|
||||
}
|
||||
}
|
||||
implementation(libs.material)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
remember(isConnected, remoteAddress, setupResolvedEndpoint, manualResolvedEndpoint, inputMode) {
|
||||
when {
|
||||
isConnected && !remoteAddress.isNullOrBlank() -> remoteAddress!!
|
||||
inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: stringResource(R.string.connect_not_set)
|
||||
else -> manualResolvedEndpoint ?: stringResource(R.string.connect_not_set)
|
||||
inputMode == ConnectInputMode.SetupCode -> setupResolvedEndpoint ?: "(not set)"
|
||||
else -> manualResolvedEndpoint ?: "(not set)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
if (config == null) {
|
||||
validationText = if (inputMode == ConnectInputMode.SetupCode)
|
||||
stringResource(R.string.connect_validation_setup_code) else stringResource(R.string.connect_validation_address)
|
||||
"Enter a valid Setup Code" else "Enter a valid address"
|
||||
return@Button
|
||||
}
|
||||
validationText = null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.R
|
||||
|
||||
@@ -65,6 +65,12 @@ fun SshHost(modifier: Modifier = Modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun openSession(config: SshConfig) {
|
||||
// Bug 3: 如果密码为空,弹出连接对话框让用户输入密码
|
||||
if (config.password.isBlank() && config.authMode != "key") {
|
||||
pendingConfig = config
|
||||
showConnectDialog = true
|
||||
return
|
||||
}
|
||||
// 立即创建 tab 显示"连接中"
|
||||
val mgr = SshTerminalManager(sessionId = "${config.host}:${config.port}")
|
||||
activeSessions.add(mgr)
|
||||
@@ -242,12 +248,25 @@ fun SshHost(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
if (showConnectDialog) {
|
||||
SshConnectDialog(
|
||||
onDismiss = { showConnectDialog = false },
|
||||
onConnectPassword = { config -> showConnectDialog = false; pendingConfig = config },
|
||||
onConnectKey = { config, passphrase -> showConnectDialog = false; pendingKeyConnect = KeyAuthConnect(config, passphrase) },
|
||||
savedConfigs = savedConfigs
|
||||
)
|
||||
// Bug 3: 如果是从空密码连接触发的,预填配置给用户输入密码
|
||||
val prefillConfig = pendingConfig
|
||||
if (prefillConfig != null && prefillConfig.password.isBlank()) {
|
||||
// 预填配置到 dialog 后清除 pending 标记
|
||||
SshConnectDialog(
|
||||
onDismiss = { showConnectDialog = false; pendingConfig = null },
|
||||
onConnectPassword = { config -> showConnectDialog = false; pendingConfig = config },
|
||||
onConnectKey = { config, passphrase -> showConnectDialog = false; pendingKeyConnect = KeyAuthConnect(config, passphrase) },
|
||||
savedConfigs = savedConfigs,
|
||||
initialConfig = prefillConfig
|
||||
)
|
||||
} else {
|
||||
SshConnectDialog(
|
||||
onDismiss = { showConnectDialog = false },
|
||||
onConnectPassword = { config -> showConnectDialog = false; pendingConfig = config },
|
||||
onConnectKey = { config, passphrase -> showConnectDialog = false; pendingKeyConnect = KeyAuthConnect(config, passphrase) },
|
||||
savedConfigs = savedConfigs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,17 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import android.text.TextWatcher
|
||||
import android.widget.EditText
|
||||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -68,16 +73,17 @@ fun SshConnectDialog(
|
||||
onConnectPassword: (SshConfig) -> Unit,
|
||||
onConnectKey: (config: SshConfig, passphrase: String?) -> Unit,
|
||||
savedConfigs: List<SshConfig> = emptyList(),
|
||||
onFillConfig: (SshConfig) -> Unit = {}
|
||||
onFillConfig: (SshConfig) -> Unit = {},
|
||||
initialConfig: SshConfig? = null
|
||||
) {
|
||||
var host by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("22") }
|
||||
var username by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var displayName by remember { mutableStateOf("") }
|
||||
var authMethod by remember { mutableStateOf("password") }
|
||||
var host by remember { mutableStateOf(initialConfig?.host ?: "") }
|
||||
var port by remember { mutableStateOf((initialConfig?.port ?: 22).toString()) }
|
||||
var username by remember { mutableStateOf(initialConfig?.username ?: "") }
|
||||
var password by remember { mutableStateOf(initialConfig?.password ?: "") }
|
||||
var displayName by remember { mutableStateOf(initialConfig?.displayName ?: "") }
|
||||
var authMethod by remember { mutableStateOf(initialConfig?.authMode ?: "password") }
|
||||
var keyPassphrase by remember { mutableStateOf("") }
|
||||
var selectedKeyPath by remember { mutableStateOf("") }
|
||||
var selectedKeyPath by remember { mutableStateOf(initialConfig?.savedKeyPath ?: "") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var keyPassVisible by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -423,6 +429,7 @@ fun SshTerminalScreen(
|
||||
var showForwardDialog by remember { mutableStateOf(false) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val currentFontSize = remember { mutableStateOf(SshConfigStorage.loadFontSize(context)) }
|
||||
val editTextRef = remember { mutableStateOf<EditText?>(null) }
|
||||
|
||||
val terminalFontSize = fontMap[currentFontSize.value] ?: 12
|
||||
val terminalFontSizeSp = terminalFontSize.sp
|
||||
@@ -434,24 +441,29 @@ fun SshTerminalScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 键盘事件处理
|
||||
// 获取当前输入的纯文本(用于显示)
|
||||
val currentInputText = currentInput
|
||||
|
||||
// 硬件键盘处理
|
||||
fun handleKeyEvent(event: KeyEvent): Boolean {
|
||||
if (!state.connected) return false
|
||||
return when (event.type) {
|
||||
KeyEventType.KeyUp -> {
|
||||
when (event.key) {
|
||||
Key.Enter -> {
|
||||
if (currentInput.isNotEmpty()) {
|
||||
val cmd = currentInputText.trim()
|
||||
if (cmd.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(currentInput)
|
||||
currentInput = ""
|
||||
manager.sendCommand(cmd)
|
||||
}
|
||||
}
|
||||
currentInput = ""
|
||||
true
|
||||
}
|
||||
Key.Backspace -> {
|
||||
if (currentInput.isNotEmpty()) {
|
||||
currentInput = currentInput.substring(0, currentInput.length - 1)
|
||||
val t = currentInputText
|
||||
if (t.isNotEmpty()) {
|
||||
currentInput = t.substring(0, t.length - 1)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -462,56 +474,124 @@ fun SshTerminalScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun handleCharacterInput(event: KeyEvent): Boolean {
|
||||
if (!state.connected) return false
|
||||
if (event.type != KeyEventType.KeyUp) return false
|
||||
|
||||
return when (event.key) {
|
||||
Key.Enter, Key.Backspace, Key.Tab,
|
||||
Key.Escape, Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6,
|
||||
Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, Key.F12,
|
||||
Key.DirectionUp, Key.DirectionDown, Key.DirectionLeft, Key.DirectionRight,
|
||||
Key.PageUp, Key.PageDown, Key.MoveHome, Key.MoveEnd,
|
||||
Key.Insert, Key.Delete, Key.CapsLock,
|
||||
Key.ShiftLeft, Key.ShiftRight, Key.CtrlLeft, Key.CtrlRight,
|
||||
Key.AltLeft, Key.AltRight, Key.MetaLeft, Key.MetaRight -> false
|
||||
|
||||
else -> {
|
||||
val c = event.key.nativeKeyCode.toChar()
|
||||
if (c.isISOControl()) return false
|
||||
if (event.isCtrlPressed) return false
|
||||
if (event.isAltPressed) return false
|
||||
if (event.isMetaPressed) return false
|
||||
currentInput += c
|
||||
true
|
||||
// 连接后自动让 EditText 获取焦点(弹出软键盘)
|
||||
LaunchedEffect(state.connected) {
|
||||
if (state.connected) {
|
||||
// 延迟一点确保 EditText 已创建
|
||||
kotlinx.coroutines.delay(200)
|
||||
editTextRef.value?.let { et ->
|
||||
et.requestFocus()
|
||||
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
|
||||
imm?.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val focusModifier = remember { FocusRequester() }
|
||||
|
||||
// 连接后自动获取焦点
|
||||
LaunchedEffect(state.connected) {
|
||||
if (state.connected) {
|
||||
focusModifier.requestFocus()
|
||||
// 点击终端区域让 EditText 重新获取焦点
|
||||
val requestEditTextFocus: () -> Unit = {
|
||||
editTextRef.value?.let { et ->
|
||||
et.requestFocus()
|
||||
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager
|
||||
imm?.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(bg)
|
||||
.onKeyEvent { handleKeyEvent(it) }
|
||||
.onPreviewKeyEvent { handleCharacterInput(it) }
|
||||
.focusRequester(focusModifier)
|
||||
.onFocusChanged { }
|
||||
.clickable { requestEditTextFocus() }
|
||||
) {
|
||||
// EditText 输入桥——在 Column 顶部,0高度不占空间
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.background(Color(0xFF0D1117))
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// $ 提示符已移除——终端输出本身就是带提示符的
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
EditText(ctx).apply {
|
||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
setTextColor(android.graphics.Color.parseColor("#E6EDF3"))
|
||||
textSize = 14f
|
||||
hint = if (state.connected) "type command..." else ""
|
||||
setHintTextColor(android.graphics.Color.parseColor("#6E7681"))
|
||||
imeOptions = EditorInfo.IME_ACTION_SEND
|
||||
inputType = android.text.InputType.TYPE_CLASS_TEXT
|
||||
setSingleLine()
|
||||
setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_DONE) {
|
||||
val cmd = text.toString().trim()
|
||||
if (cmd.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(cmd)
|
||||
}
|
||||
}
|
||||
setText("")
|
||||
currentInput = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
addTextChangedListener(object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun afterTextChanged(s: android.text.Editable?) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
val text = s?.toString() ?: ""
|
||||
// 检测回车字符:输入法按回车时会在文本末尾插入 \n
|
||||
if (text.endsWith('\n')) {
|
||||
val cmd = text.dropLast(1).trim()
|
||||
if (cmd.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(cmd)
|
||||
}
|
||||
}
|
||||
setText("")
|
||||
currentInput = ""
|
||||
} else {
|
||||
currentInput = text
|
||||
}
|
||||
}
|
||||
})
|
||||
setOnKeyListener { _, keyCode, event ->
|
||||
if (event.action == android.view.KeyEvent.ACTION_DOWN && keyCode == AndroidKeyEvent.KEYCODE_ENTER) {
|
||||
val cmd = text.toString().trim()
|
||||
if (cmd.isNotEmpty()) {
|
||||
coroutineScope.launch {
|
||||
manager.sendCommand(cmd)
|
||||
}
|
||||
}
|
||||
setText("")
|
||||
currentInput = ""
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
tag = "terminal_input"
|
||||
editTextRef.value = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
)
|
||||
}
|
||||
|
||||
// ── 终端内容(顶栏 + 输出 + 输入行)──
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.systemBarsPadding()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.clickable { requestEditTextFocus() }
|
||||
) {
|
||||
// ── 终端顶栏 ──
|
||||
Surface(
|
||||
@@ -623,7 +703,7 @@ fun SshTerminalScreen(
|
||||
}
|
||||
item(key = "prompt") {
|
||||
Text(
|
||||
text = "\$ $currentInput",
|
||||
text = currentInputText.ifEmpty { " " },
|
||||
color = textPrimary,
|
||||
fontSize = terminalFontSizeSp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@@ -668,7 +748,7 @@ fun SshTerminalScreen(
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "terminal@tergent:~$",
|
||||
text = "terminal@tergent",
|
||||
color = textTertiary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 16.sp
|
||||
|
||||
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 189 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 277 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 254 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 593 B After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
# Tergent SSH 终端模块 — 详细设计说明
|
||||
|
||||
> 版本: v1.3.2
|
||||
> 最后更新: 2026-05-25
|
||||
> 基于: hm/tergent-android (Gitea)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [项目概述](#1-项目概述)
|
||||
2. [包结构](#2-包结构)
|
||||
3. [SSH 核心模块](#3-ssh-核心模块)
|
||||
4. [UI 界面层](#4-ui-界面层)
|
||||
5. [数据持久化](#5-数据持久化)
|
||||
6. [辅助功能模块](#6-辅助功能模块)
|
||||
7. [关键数据流](#7-关键数据流)
|
||||
8. [修改指南](#8-修改指南)
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
Tergent 是基于 OpenClaw Android 源码的 SSH 终端 App,支持多会话、SFTP 文件传输、端口转发等企业级 SSH 功能。UI 采用 Jetpack Compose + Material3,深色终端风格。
|
||||
|
||||
**包路径**: `ai.openclaw.app.ui.tergent` — SSH 相关代码全部在此包下。
|
||||
**核心框架**: Kotlin + Jetpack Compose + JSch (SSH 库) + kotlinx.serialization
|
||||
|
||||
---
|
||||
|
||||
## 2. 包结构
|
||||
|
||||
```
|
||||
app/src/main/java/ai/openclaw/app/
|
||||
├── ui/
|
||||
│ ├── tergent/ # SSH 终端模块(你主要关心的)
|
||||
│ │ ├── SshTerminalManager.kt # SSH 连接/通信核心引擎
|
||||
│ │ ├── SshTerminalScreen.kt # 终端界面(Compose UI)
|
||||
│ │ ├── SshHost.kt # 主机列表 + 多会话管理
|
||||
│ │ ├── SshConfigStorage.kt # 配置持久化(SharedPreferences)
|
||||
│ │ ├── SftpBrowserScreen.kt # SFTP 文件浏览界面
|
||||
│ │ ├── SftpFileInfo.kt # SFTP 文件信息数据类
|
||||
│ │ ├── PortForwardManager.kt # 端口转发管理
|
||||
│ │ ├── ConnectionTester.kt # 连接测试工具
|
||||
│ │ ├── QuickCommands.kt # 快捷命令
|
||||
│ │ ├── DevicesScreen.kt # 设备管理页
|
||||
│ │ ├── NotificationsScreen.kt # 通知历史页
|
||||
│ │ ├── DiagnosticsScreen.kt # 诊断页
|
||||
│ │ ├── TergentComponents.kt # 公用 UI 组件
|
||||
│ │ └── TergentTheme.kt # 深色主题颜色定义
|
||||
│ ├── ConnectTabScreen.kt # 连接 Tab(入口页)
|
||||
│ ├── PostOnboardingTabs.kt # 主 Tab 导航
|
||||
│ └── ...
|
||||
├── node/ # 后台服务
|
||||
│ ├── ConnectionManager.kt # 网关连接管理
|
||||
│ ├── InvokeDispatcher.kt # 指令分发
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SSH 核心模块
|
||||
|
||||
### 3.1 `SshTerminalManager.kt` — SSH 引擎
|
||||
|
||||
**文件**: `app/src/main/java/ai/openclaw/app/ui/tergent/SshTerminalManager.kt`
|
||||
**依赖**: JSch (SSH 库) + Kotlin Coroutines
|
||||
|
||||
#### 数据类型
|
||||
|
||||
| 类型 | 用途 | 关键字段 |
|
||||
|------|------|----------|
|
||||
| `SshConfig` | SSH 连接配置数据类 | host, port(默认22), username, password, displayName, group(分组), authMode("password"/"key"), savedKeyPath |
|
||||
| `SshConnectionState` | 连接状态的 StateFlow | connected, host, displayName, lines(终端输出行列表), error, authMode |
|
||||
| `HostKeyInfo` | 主机密钥指纹 | host, type(算法), fingerprint, key |
|
||||
| `KnownHostsStore` | known_hosts 存储 | 基于 SharedPreferences 的 `host|fingerprint` 集合 |
|
||||
|
||||
#### 核心函数
|
||||
|
||||
| 函数 | 作用 | 参数 | 返回值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| `connect(config)` | 密码方式 SSH 连接 | SshConfig | Boolean | 发起连接,启动输出读取协程。超时15s |
|
||||
| `connectWithKey(config, keyPath, passphrase)` | 密钥方式 SSH 连接 | SshConfig + 私钥路径 + 密码 | Boolean | 加载私钥 → 连接 → 启动读取 |
|
||||
| `sendCommand(command)` | 发送指令 | String | Boolean | 将指令 + `\n` 写入 OutputStream |
|
||||
| `disconnect()` | 断开连接 | 无 | Unit | 清理通道、会话、流 |
|
||||
| `reconnect()` | 使用上次配置重连 | 无 | Boolean | 自动判断密码/密钥模式 |
|
||||
| `isConnected()` | 连接状态查询 | 无 | Boolean | 返回 _connectionState.value.connected |
|
||||
| `openSftpChannel()` | 打开 SFTP 通道 | 无 | Boolean | 基于现有 SSH 会话开 SFTP |
|
||||
| `closeSftpChannel()` | 关闭 SFTP 通道 | 无 | Unit | 清理 SFTP |
|
||||
| `listFiles(path)` | SFTP 列出目录 | String (默认".") | List<SftpFileInfo> | 排序:目录优先,按字母 |
|
||||
| `downloadFile(remote, local)` | SFTP 下载 | remote路径, local路径 | Boolean | |
|
||||
| `uploadFile(local, remote)` | SFTP 上传 | local路径, remote路径 | Boolean | |
|
||||
|
||||
#### 内部函数
|
||||
|
||||
| 函数 | 作用 | 说明 |
|
||||
|------|------|------|
|
||||
| `readOutput()` | 循环读取终端输出(IO 线程) | 使用 ByteArray(4096) 缓冲区,过滤 ANSI 转义序列,行缓存上限 5000 行 |
|
||||
| `filterAnsi(text)` | 过滤 ANSI 转义序列 | 使用正则匹配 CSI 序列 (`ESC[...`) 和 OSC 序列 (`ESC]...BEL`) |
|
||||
|
||||
#### 关键状态流
|
||||
|
||||
```
|
||||
_connectionState: MutableStateFlow<SshConnectionState>
|
||||
- connected: Boolean
|
||||
- host: String
|
||||
- lines: List<String> ← 终端输出行,随 readOutput() 不断更新
|
||||
- error: String?
|
||||
```
|
||||
|
||||
调用方通过 `connectionState: StateFlow` 收集实时输出。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 `SshTerminalScreen.kt` — 终端 UI
|
||||
|
||||
**文件**: `app/src/main/java/ai/openclaw/app/ui/tergent/SshTerminalScreen.kt`
|
||||
**技术**: Jetpack Compose + AndroidView(EditText)
|
||||
|
||||
#### Composable 函数
|
||||
|
||||
| 函数 | 作用 | 层级 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `SshConnectDialog` | SSH 连接对话框 | 弹窗 | 支持密码/密钥两种认证方式,显示已保存连接列表 |
|
||||
| `SshTerminalScreen` | 终端主界面 | 主要页面 | 终端输出 + 输入桥 + 工具栏 |
|
||||
|
||||
#### SshConnectDialog 的输入字段
|
||||
|
||||
- host / port / username / password
|
||||
- 认证方式切换:密码 / 密钥
|
||||
- 私钥路径选择 + 密码
|
||||
- 已保存连接快速填充
|
||||
- 所有字段预填支持(initialConfig 参数)
|
||||
|
||||
#### SshTerminalScreen 的布局结构
|
||||
|
||||
```
|
||||
Column
|
||||
├── 工具栏 (Row)
|
||||
│ ├── 断开按钮(exit)
|
||||
│ ├── SFTP 切换按钮
|
||||
│ ├── 快捷命令列表
|
||||
│ ├── 端口转发设置
|
||||
│ ├── 字体大小按钮
|
||||
│ └── 新建连接按钮
|
||||
├── 输出区 (LazyColumn) ← 终端输出的主体
|
||||
│ ├── items(state.lines) ← 每行一个 Text
|
||||
│ └── item(key="prompt") ← 底部输入提示行(当前输入内容)
|
||||
└── 输入桥 (Row + AndroidView)
|
||||
└── EditText ← 软键盘输入入口
|
||||
```
|
||||
|
||||
#### 输入逻辑
|
||||
|
||||
| 触发方式 | 位置 | 说明 |
|
||||
|----------|------|------|
|
||||
| EditText → setOnEditorActionListener | AndroidView → EditText | 输入法按 Send/Enter 时调用 sendCommand |
|
||||
| EditText → TextWatcher.onTextChanged | AndroidView → EditText | 检测 `\n` 字符(部分输入法用此方式) |
|
||||
| EditText → setOnKeyListener | AndroidView → EditText | 硬件键盘 Enter 键 |
|
||||
| 终端区域点击 → requestEditTextFocus | Column.onClick | 点击输出区域重新唤起键盘 |
|
||||
|
||||
**当前输入缓存**: `currentInput` (remember { mutableStateOf("") })
|
||||
**输入框显示**: 在 EditText 中显示输入内容,同时底部输出区最后一个 Text 也显示相同的 `currentInput`
|
||||
**连接后自动焦点**: LaunchedEffect(state.connected) — 连接成功后延迟 200ms 自动弹出键盘
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `SshHost.kt` — 主机管理 + 多会话
|
||||
|
||||
**文件**: `app/src/main/java/ai/openclaw/app/ui/tergent/SshHost.kt`
|
||||
**作用**: 整个 SSH 功能的入口 Composable,管理所有活跃会话
|
||||
|
||||
#### Composable 函数
|
||||
|
||||
| 函数 | 作用 | 说明 |
|
||||
|------|------|------|
|
||||
| `SshHost` | 主入口 | 标题栏 + 搜索 + 会话 Tab + 内容区 |
|
||||
| `ConnectionListView` | 连接列表(按组) | 搜索过滤 → 分组 → LazyColumn |
|
||||
| `SavedConnectionItem` | 单个连接项 | 显示名称 + 地址 + 测试按钮 + 删除 |
|
||||
|
||||
#### 数据结构
|
||||
|
||||
```
|
||||
activeSessions: mutableStateListOf<SshTerminalManager>() ← 所有活跃会话
|
||||
selectedTabIndex: Int ← 当前选中的 Tab
|
||||
savedConfigs: List<SshConfig> ← 保存的连接列表
|
||||
```
|
||||
|
||||
#### 关键逻辑
|
||||
|
||||
1. **`openSession(config)`**: 如果密码为空且非密钥模式 → 弹出对话框让用户输入密码;否则立即创建 Tab → 异步连接
|
||||
2. **`closeSession(index)`**: 断开连接 → 移除 Tab → 调整选中 Tab
|
||||
3. **密钥文件选择**: 使用 `ActivityResultContracts.OpenDocument()` 系统文件选择器
|
||||
4. **空密码回填**: 如果保存的连接密码为空,自动弹出预填充的 SshConnectDialog
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 界面层
|
||||
|
||||
### 4.1 页面路由
|
||||
|
||||
```
|
||||
PostOnboardingTabs
|
||||
├── Tab: 连接 (ConnectTabScreen)
|
||||
│ ├── SshHost ← SSH 功能(你最关注的部分)
|
||||
│ │ ├── SshConnectDialog (模态弹窗)
|
||||
│ │ ├── SshTerminalScreen (Tab 内容)
|
||||
│ │ └── SftpBrowserScreen (Tab 内容)
|
||||
│ └── DevicesScreen
|
||||
├── Tab: 对话 (ChatSheet)
|
||||
├── Tab: 设置 (SettingsSheet)
|
||||
└── Tab: 引导 (OnboardingFlow)
|
||||
```
|
||||
|
||||
### 4.2 设计主题 (`TergentTheme.kt`)
|
||||
|
||||
深色终端风格,定义在 `TergentTheme.kt` 中。所有 UI 颜色在文件顶部作为私有常量定义:
|
||||
- `bg` = `#0D1117` (背景)
|
||||
- `surface` = `#161B22` (卡片)
|
||||
- `surfaceAlt` = `#1C2333` (卡片强调)
|
||||
- `textPrimary` = `#E6EDF3`
|
||||
- `accent` = `#3FB950` (绿色,连接成功)
|
||||
- `accentBlue` = `#58A6FF` (蓝色,交互)
|
||||
- `danger` = `#F85149` (红色,断开)
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据持久化
|
||||
|
||||
### 5.1 `SshConfigStorage.kt` — 配置存储
|
||||
|
||||
**存储方式**: Android SharedPreferences
|
||||
**Key 前缀**: `tergent_ssh_configs` (文件)
|
||||
|
||||
| 函数 | 作用 | 数据格式 |
|
||||
|------|------|----------|
|
||||
| `saveConnection(context, config)` | 保存连接配置 | JSON 序列化后入 List, 存为 JSON 数组字符串 |
|
||||
| `loadConnections(context)` | 加载所有连接 | JSON 数组反序列化为 List<SshConfig> |
|
||||
| `deleteConnection(context, host, port, username)` | 删除连接 | 从列表中移除并回写 |
|
||||
| `saveCustomCommands(context, cmds)` | 保存快捷命令 | JSON 序列化(List<QuickCmd>) |
|
||||
| `loadCustomCommands(context)` | 加载快捷命令 | JSON 反序列化 |
|
||||
| `saveThemeMode(context, mode)` | 保存主题 | 字符串 "dark"/"light" |
|
||||
| `loadThemeMode(context)` | 加载主题 | 默认 "dark" |
|
||||
| `saveFontSize(context, size)` | 保存字体 | "small"/"medium"/"large" |
|
||||
| `loadFontSize(context)` | 加载字体 | 默认 "medium" |
|
||||
| `fontSizeToSp(size)` | 转换字体大小 | "small"→10sp, "medium"→12sp, "large"→14sp |
|
||||
|
||||
### 5.2 `SshHost.kt` 中的密钥文件存储
|
||||
|
||||
私钥保存到 `context.filesDir/` 下,文件名 `ssh_key_{host}_{timestamp}.pem`。
|
||||
密钥路径列表存为 `tergent_ssh_configs` Preference 中用 `|` 分隔的字符串。
|
||||
|
||||
---
|
||||
|
||||
## 6. 辅助功能模块
|
||||
|
||||
### 6.1 `SftpBrowserScreen.kt` — SFTP 文件浏览器
|
||||
|
||||
| 函数/类型 | 作用 |
|
||||
|-----------|------|
|
||||
| `SftpBrowserScreen(manager, ...)` | 主界面,文件列表 |
|
||||
| `loadDir(path)` | 加载远程目录,更新文件列表 |
|
||||
| `formatSize(bytes)` | 格式化文件大小 (B/KB/MB) |
|
||||
| 文件项点击 | 目录→进入,文件→下载提示 |
|
||||
| `formatPermissions(attrs)` | 权限字符串 (rwxr-xr-x) |
|
||||
| `SftpFileInfo` | 数据类: name, isDir, size, lastModified, permissions |
|
||||
|
||||
### 6.2 `PortForwardManager.kt` — 端口转发
|
||||
|
||||
| 函数/类型 | 作用 |
|
||||
|-----------|------|
|
||||
| `PortForward` | 数据类: localPort, remoteHost, remotePort, type(本地/远程) |
|
||||
| `addForward(session, forward)` | 添加端口转发规则 |
|
||||
| `removeForward(session, forward)` | 移除端口转发 |
|
||||
| `listForwards(session)` | 列出当前转发 |
|
||||
|
||||
### 6.3 `ConnectionTester.kt` — 连接测试
|
||||
|
||||
| 函数/类型 | 作用 |
|
||||
|-----------|------|
|
||||
| `testConnection(host, port, timeoutMs)` | 异步测试 TCP 可达性 |
|
||||
| `TestResult` | 数据类: reachable, host, port, latencyMs, rawOutput |
|
||||
|
||||
### 6.4 `QuickCommands.kt` — 快捷命令
|
||||
|
||||
| 函数/类型 | 作用 |
|
||||
|-----------|------|
|
||||
| `QuickCommand` | 数据类: name, command |
|
||||
| `loadCustomCommands(context)` | 加载自定义快捷命令 |
|
||||
| `saveCustomCommand(context, cmd)` | 保存快捷命令 |
|
||||
| `deleteCustomCommand(context, name)` | 删除快捷命令 |
|
||||
| `QuickCommandBar(...)` | Compose UI 组件:底部快捷命令栏 |
|
||||
|
||||
### 6.5 `TergentComponents.kt` — 公用 UI 组件
|
||||
|
||||
| 组件 | 作用 |
|
||||
|------|------|
|
||||
| `TergentStatusPill` | 状态胶囊 (连接/断开/错误) |
|
||||
| `TergentPrimaryButton` | 主按钮 |
|
||||
| `TergentOutlinedButton` | 描边按钮 |
|
||||
| `TergentCard` | 卡片容器 |
|
||||
| `TergentToggleChip` | 切换 Chip |
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键数据流
|
||||
|
||||
### 7.1 连接流程
|
||||
|
||||
```
|
||||
用户点击连接
|
||||
↓
|
||||
openSession(config)
|
||||
↓
|
||||
(密码为空?)→ 是 → 弹出 SshConnectDialog 预填 → 用户输入密码 → openSession
|
||||
↓ 否
|
||||
创建 SshTerminalManager → 加入 activeSessions → 选中 Tab
|
||||
↓
|
||||
coroutineScope.launch { mgr.connect(config) }
|
||||
↓
|
||||
JSch获取Session → 打开ChannelShell → 设置PTY → 连接
|
||||
↓
|
||||
启动 readOutput() 协程(IO 线程)
|
||||
↓
|
||||
_connectionState.value 不断更新 lines + connected=true
|
||||
↓
|
||||
SshTerminalScreen 通过 collectAsState() 实时刷新 UI
|
||||
```
|
||||
|
||||
### 7.2 指令发送流程
|
||||
|
||||
```
|
||||
用户在 EditText 输入指令
|
||||
↓
|
||||
按 Enter
|
||||
↓
|
||||
[三个可能的触发路径之一]
|
||||
├── setOnEditorActionListener (IME_ACTION_SEND/GO/DONE)
|
||||
├── TextWatcher.onTextChanged (检测到 \n 字符)
|
||||
└── setOnKeyListener (硬件 KEYCODE_ENTER)
|
||||
↓
|
||||
manager.sendCommand(command)
|
||||
↓
|
||||
(command + "\n").toByteArray(UTF-8) → OutputStream.write() → flush()
|
||||
↓
|
||||
SSH 服务器执行指令,返回输出
|
||||
↓
|
||||
readOutput() 读到新数据 → 更新 _connectionState.lines
|
||||
↓
|
||||
UI 自动刷新最后几行
|
||||
```
|
||||
|
||||
### 7.3 断开流程
|
||||
|
||||
```
|
||||
用户点击断开 / Tab 关闭按钮
|
||||
↓
|
||||
closeSession(index) 或 mgr.disconnect()
|
||||
↓
|
||||
running.set(false) → readOutput() 退出循环
|
||||
↓
|
||||
closeSftpChannel() → channel.disconnect() → session.disconnect()
|
||||
↓
|
||||
_connectionState.value = SshConnectionState() (重置)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 修改指南
|
||||
|
||||
### 8.1 常见修改场景
|
||||
|
||||
#### 修改终端输入行为
|
||||
|
||||
**涉及文件**: `SshTerminalScreen.kt`
|
||||
**位置**: `EditText` 的 AndroidView → factory block(约第 520-580 行)
|
||||
**关键点**:
|
||||
- `setOnEditorActionListener` — 处理输入法发送事件
|
||||
- `TextWatcher` — 检测 `\n` 字符
|
||||
- `setOnKeyListener` — 硬件键盘
|
||||
- 三个事件处理器**都**需要触发 `sendCommand`,但要注意各处理器的 `coroutineScope` 生命周期
|
||||
|
||||
#### 调整终端显示样式
|
||||
|
||||
**涉及文件**: `SshTerminalScreen.kt`
|
||||
**位置**: 文件顶部颜色常量和 `Text` 组件的 `style` 参数
|
||||
**示例**: 修改字体、字号、行距、配色
|
||||
|
||||
#### 新增 SSH 认证方式
|
||||
|
||||
**涉及文件**:
|
||||
- `SshTerminalManager.kt` — 添加 `connectWithCert(...)` 等方法
|
||||
- `SshTerminalScreen.kt` — 在 `SshConnectDialog` 添加认证方式切换
|
||||
- `SshConfig` — 添加认证方式字段
|
||||
|
||||
#### 修改连接保存逻辑
|
||||
|
||||
**涉及文件**: `SshConfigStorage.kt`
|
||||
**存储位置**: `context.getSharedPreferences("tergent_ssh_configs", 0)`
|
||||
**数据格式**: JSON 序列化的 `List<SshConfig>`
|
||||
|
||||
#### 增加新的 SSH 功能(如 SCP 传输)
|
||||
|
||||
**涉及文件**:
|
||||
- `SshTerminalManager.kt` — 通过现有 Session 开 SCP 通道
|
||||
- 新文件或集成到 `SftpBrowserScreen.kt`
|
||||
|
||||
### 8.2 编译注意事项
|
||||
|
||||
1. **Gradle 版本**: 推荐用 `gradle assemblePlayDebug`(系统 Gradle 9.4.1),wrapper 8.2 可能存在 bcprov 缓存 bug
|
||||
2. **依赖**: JSch 通过 OpenClaw libs.versions.toml 管理
|
||||
3. **编码**: 所有 .kt 文件必须 UTF-8(已有文件曾经有编码损坏问题)
|
||||
4. **两个 flavor**: `play` 和 `thirdParty`,当前用 `playDebug`
|
||||
5. **`thirdParty` flavor 有重复文件 Bug**: 如果改动了 main 中的文件可能需要在 thirdParty 中也同步改动,反之亦然
|
||||
|
||||
---
|
||||
|
||||
## 附录: 文件速查表
|
||||
|
||||
| 文件 | 行数(约) | 核心职责 | 主要修改点 |
|
||||
|------|---------|---------|-----------|
|
||||
| `SshTerminalManager.kt` | 420 | SSH 引擎, 连接/发送/断开/SFTP | 认证逻辑, 输出解析, SFTP 功能 |
|
||||
| `SshTerminalScreen.kt` | 800+ | 终端 UI, 输入桥, 连接对话框 | 输入处理, 显示样式, 对话框字段 |
|
||||
| `SshHost.kt` | 500+ | 主机列表, 多会话管理, Tab 导航 | 连接列表 UI, 会话生命周期 |
|
||||
| `SshConfigStorage.kt` | 100 | 配置读写 (SharedPreferences) | 存储字段, 序列化格式 |
|
||||
| `SftpBrowserScreen.kt` | 400 | SFTP 文件浏览器 | 文件操作, 上传/下载 UI |
|
||||
| `PortForwardManager.kt` | 70 | 端口转发 | 添加/移除/列出规则 |
|
||||
| `ConnectionTester.kt` | 60 | TCP 连接测试 | 超时, 延迟检测 |
|
||||
| `QuickCommands.kt` | 140 | 快捷命令 | 命令列表, 按钮栏 |
|
||||
| `DevicesScreen.kt` | 420 | 设备管理 | 设备列表, 状态, 连接 |
|
||||
| `DiagnosticsScreen.kt` | 300 | 诊断信息 | 传感器, 系统信息, 日志 |
|
||||
| `NotificationsScreen.kt` | 200 | 通知历史 | 通知列表, 清除 |
|
||||
| `TergentTheme.kt` | 50 | 颜色和字体常量 | 全局颜色配置 |
|
||||
| `TergentComponents.kt` | 200 | 通用 UI 组件 | 按钮, 卡片, Chip |
|
||||
@@ -1,9 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAMED
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
org.gradle.warning.mode=all
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.enableR8.fullMode=true
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=false
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.newDsl=true
|
||||
|
||||
@@ -1,76 +1,33 @@
|
||||
[versions]
|
||||
agp = "9.2.0"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.04.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
androidx-test-ext = "1.3.0"
|
||||
androidx-uiautomator = "2.4.0-beta02"
|
||||
androidx-webkit = "1.15.0"
|
||||
bcprov = "1.84"
|
||||
commonmark = "0.28.0"
|
||||
coroutines = "1.10.2"
|
||||
dnsjava = "3.6.4"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "6.0.3"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
material = "1.13.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
robolectric = "4.16.1"
|
||||
serialization-json = "1.11.0"
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
compose-bom = "2024.12.01"
|
||||
core-ktx = "1.15.0"
|
||||
lifecycle = "2.8.7"
|
||||
activity-compose = "1.9.3"
|
||||
coroutines = "1.9.0"
|
||||
serialization-json = "1.7.3"
|
||||
jsch = "0.1.55"
|
||||
material = "1.12.0"
|
||||
|
||||
[libraries]
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" }
|
||||
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" }
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" }
|
||||
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" }
|
||||
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
|
||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" }
|
||||
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
|
||||
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
|
||||
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
|
||||
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
|
||||
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
|
||||
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
|
||||
commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
|
||||
commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" }
|
||||
dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
|
||||
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" }
|
||||
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "1.79" }
|
||||
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
jsch = { module = "com.jcraft:jsch", version.ref = "jsch" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -14,6 +14,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "OpenClawNodeAndroid"
|
||||
rootProject.name = "Tergent"
|
||||
include(":app")
|
||||
include(":benchmark")
|
||||
|
||||