Compare commits
36
Commits
63105b7843
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c17c3f04b | ||
|
|
dc01c225f7 | ||
|
|
1f692044d2 | ||
|
|
8161f3bf09 | ||
|
|
1d11afefec | ||
|
|
5006d1c7a8 | ||
|
|
004d4622c4 | ||
|
|
824f0a07e2 | ||
|
|
71dd92a4c2 | ||
|
|
ebdfeddd9a | ||
|
|
017864a0db | ||
|
|
6472d876b3 | ||
|
|
9e4e979e7a | ||
|
|
cb7e6acb59 | ||
|
|
827c74b3c3 | ||
|
|
c2d828b90a | ||
|
|
cbe716300c | ||
|
|
9afac6f508 | ||
|
|
6f4309c746 | ||
|
|
c2b08ff73e | ||
|
|
8e48a0fa1e | ||
|
|
34758975b7 | ||
|
|
e3d0a97921 | ||
|
|
5f548f5a17 | ||
|
|
944390bec1 | ||
|
|
62fcf07e1b | ||
|
|
e7927f6631 | ||
|
|
1cef9e7ebb | ||
|
|
82cf8df238 | ||
|
|
ba029e83dc | ||
|
|
728daa9acd | ||
|
|
ed915d0816 | ||
|
|
e4004decb4 | ||
|
|
7386c3035b | ||
|
|
15a0abc7a7 | ||
|
|
ed26f54855 |
@@ -0,0 +1,36 @@
|
|||||||
|
name: Android acceptance
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
android:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: https://gitea.twinpay.one
|
||||||
|
- name: Set up Java 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '21'
|
||||||
|
- name: Set up Android SDK
|
||||||
|
shell: powershell
|
||||||
|
run: ./scripts/setup-android-ci.ps1
|
||||||
|
- name: Test and build exact commit
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ((git rev-parse HEAD).Trim() -ne $env:GITHUB_SHA) { throw 'Checkout SHA mismatch' }
|
||||||
|
Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory
|
||||||
|
# A fresh runner compiles both variants from scratch. Keep their compiler
|
||||||
|
# heaps separate in time; the previous shared 2 GiB heap exhausted itself.
|
||||||
|
$buildArgs = @('--no-daemon', '--console=plain', '--max-workers=1', '-Pkotlin.daemon.jvmargs=-Xmx4096m')
|
||||||
|
.\gradlew.bat testDebugUnitTest assembleDebug @buildArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
.\gradlew.bat compileReleaseKotlin @buildArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
AITURK Mobil
|
||||||
|
Copyright 2026 TurkServis
|
||||||
|
|
||||||
|
AITURK Mobil, Hy4ri/hermes-mobile (Apache-2.0) projesinden uyarlanmıştır.
|
||||||
|
AITURK Mobil is adapted from Hy4ri/hermes-mobile (Apache-2.0).
|
||||||
|
|
||||||
|
This product includes software developed by M57 (Hy4ri):
|
||||||
|
Hermes Mobile - https://github.com/Hy4ri/hermes-mobile
|
||||||
|
Copyright (c) 2026 M57 (Hy4ri)
|
||||||
|
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||||
|
|
||||||
|
Summary of changes made by TurkServis (Apache-2.0, section 4(b)):
|
||||||
|
- Display name changed to "AITURK Mobil"; user-visible "Hermes" / "Hermes
|
||||||
|
Mobile" texts replaced with AITURK branding in all locales.
|
||||||
|
- applicationId changed to online.turkservis.aiturk.mobil (the Kotlin
|
||||||
|
namespace and source packages remain com.m57.hermescontrol).
|
||||||
|
- Turkish translation added (app/src/main/res/values-tr) and Turkish added
|
||||||
|
to the in-app language picker.
|
||||||
|
- Launcher icon replaced with the AITURK mark.
|
||||||
|
- The in-app GitHub self-updater (Hy4ri/hermes-mobile releases) is switched
|
||||||
|
off; builds are distributed from https://turkservis.online/indir.
|
||||||
|
- About screen shows this attribution; the support card names the original
|
||||||
|
developer.
|
||||||
|
|
||||||
|
AITURK and TurkServis names and the AITURK mark are not covered by the
|
||||||
|
Apache-2.0 license grant of the original project.
|
||||||
+10
-1
@@ -18,9 +18,10 @@ android {
|
|||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
buildToolsVersion = "37.0.0"
|
buildToolsVersion = "37.0.0"
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.m57.hermescontrol"
|
applicationId = "online.turkservis.aiturk.mobil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||||
// Falls back to defaults for local development.
|
// Falls back to defaults for local development.
|
||||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||||
@@ -201,6 +202,9 @@ dependencies {
|
|||||||
implementation(libs.media3.ui)
|
implementation(libs.media3.ui)
|
||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
|
|
||||||
|
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
|
||||||
|
implementation(libs.zxing.android.embedded)
|
||||||
|
|
||||||
// Encrypted storage
|
// Encrypted storage
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
@@ -218,6 +222,7 @@ dependencies {
|
|||||||
testRuntimeOnly(libs.junit.vintage.engine)
|
testRuntimeOnly(libs.junit.vintage.engine)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.okhttp.tls)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
testImplementation(libs.turbine)
|
testImplementation(libs.turbine)
|
||||||
@@ -322,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
|||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
maxParallelForks = 2
|
maxParallelForks = 2
|
||||||
|
// Source-level expectations use decimal dots; the developer's Windows
|
||||||
|
// display language must not change test output (the APK locale is unaffected).
|
||||||
|
systemProperty("user.language", "en")
|
||||||
|
systemProperty("user.country", "US")
|
||||||
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||||
if (!includeBenchmarks) {
|
if (!includeBenchmarks) {
|
||||||
filter { excludeTestsMatching("*Benchmark") }
|
filter { excludeTestsMatching("*Benchmark") }
|
||||||
|
|||||||
Vendored
+5
@@ -57,3 +57,8 @@
|
|||||||
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
||||||
-keep class * extends androidx.navigation3.runtime.NavKey { *; }
|
-keep class * extends androidx.navigation3.runtime.NavKey { *; }
|
||||||
|
|
||||||
|
# ── AITURK IDE pairing ───────────────────────────────────────────────────
|
||||||
|
# Android looks this hostname-aware overload up by reflection (X509TrustManagerExtensions).
|
||||||
|
-keepclassmembers class com.m57.hermescontrol.data.aiturk.PinAwareTrustManager {
|
||||||
|
public java.util.List checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String, java.lang.String);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.EventListener
|
||||||
|
import okhttp3.Handshake
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Opt-in live Android/Conscrypt regression. Arguments contain no credentials. */
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AiturkRelayDeviceTest {
|
||||||
|
@Test
|
||||||
|
fun pinnedHelloOverPublicRelay() {
|
||||||
|
val args = InstrumentationRegistry.getArguments()
|
||||||
|
val relayId = args.getString("aiturkRelayId")
|
||||||
|
val fingerprint = args.getString("aiturkFingerprint")
|
||||||
|
assumeTrue("Supply the IDE relay ID and public certificate fingerprint", relayId != null && fingerprint != null)
|
||||||
|
requireNotNull(relayId)
|
||||||
|
requireNotNull(fingerprint)
|
||||||
|
val started = System.nanoTime()
|
||||||
|
fun mark(stage: String) {
|
||||||
|
Log.i("AiturkRelayTest", "$stage ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)}ms")
|
||||||
|
}
|
||||||
|
val listener = object : EventListener() {
|
||||||
|
override fun secureConnectStart(call: Call) = mark("TLS start")
|
||||||
|
override fun secureConnectEnd(call: Call, handshake: Handshake?) = mark("TLS complete")
|
||||||
|
override fun requestHeadersEnd(call: Call, request: Request) = mark("HTTP sent")
|
||||||
|
override fun responseHeadersEnd(call: Call, response: Response) = mark("HTTP ${response.code}")
|
||||||
|
override fun callFailed(call: Call, ioe: IOException) = mark("FAILED ${ioe.javaClass.simpleName}")
|
||||||
|
}
|
||||||
|
val base = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.eventListener(listener)
|
||||||
|
.build()
|
||||||
|
runBlocking { AuthManager.awaitInitialization() }
|
||||||
|
val hostPort = "$relayId.relay.aiturk.invalid:443"
|
||||||
|
val previousPin = AiturkLanTrust.pinFor(hostPort)
|
||||||
|
AiturkLanTrust.pin(hostPort, fingerprint)
|
||||||
|
val client = if (args.getString("aiturkProductionClient") == "true") {
|
||||||
|
OkHttpProvider.base.newBuilder().eventListener(listener).callTimeout(30, TimeUnit.SECONDS).build()
|
||||||
|
} else {
|
||||||
|
AiturkLanTrust.pinnedClient(base, fingerprint)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
repeat(2) {
|
||||||
|
client.newCall(Request.Builder().url("https://$relayId.relay.aiturk.invalid/aiturk/pair/hello").build())
|
||||||
|
.execute().use { response ->
|
||||||
|
assertEquals(200, response.code)
|
||||||
|
assertTrue(response.body.string().contains("aiturk-ide"))
|
||||||
|
}
|
||||||
|
mark("hello $it complete")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client.connectionPool.evictAll()
|
||||||
|
client.dispatcher.executorService.shutdown()
|
||||||
|
if (previousPin == null) AiturkLanTrust.unpin(hostPort) else AiturkLanTrust.pin(hostPort, previousPin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.view.inputmethod.InputConnection
|
||||||
|
import android.view.inputmethod.InputContentInfo
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Real BasicTextField paste and InputConnection entry points; no direct listener invocation. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||||
|
class ComposerImagePasteTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val attachments = mutableStateListOf<Attachment>()
|
||||||
|
private val errors = mutableListOf<Int>()
|
||||||
|
private val ownedFiles = mutableListOf<File>()
|
||||||
|
private val input = TextFieldState("keep draft", TextRange(1, 3))
|
||||||
|
private var sends = 0
|
||||||
|
private var connection: InputConnection? = null
|
||||||
|
private var editorInfo: EditorInfo? = null
|
||||||
|
private lateinit var controller: ChatImagePasteController
|
||||||
|
|
||||||
|
private fun prepareComposer(interceptIme: Boolean = false) {
|
||||||
|
compose.setContent {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
controller =
|
||||||
|
remember {
|
||||||
|
ChatImagePasteController(
|
||||||
|
scope = scope,
|
||||||
|
importImage = ChatPastedImageStore(context)::importImage,
|
||||||
|
captureTarget = { ChatAttachmentTarget("test", 1, "http://test.local", null, null) },
|
||||||
|
commit = { _, images ->
|
||||||
|
attachments.addAll(images)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onError = errors::add,
|
||||||
|
isImageUri = { uri ->
|
||||||
|
context.contentResolver.getType(uri)?.startsWith("image/", ignoreCase = true) == true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InterceptPlatformTextInput(interceptor = { request, next ->
|
||||||
|
if (interceptIme) {
|
||||||
|
val info = EditorInfo()
|
||||||
|
connection = request.createInputConnection(info)
|
||||||
|
editorInfo = info
|
||||||
|
awaitCancellation()
|
||||||
|
} else {
|
||||||
|
next.startInputMethod(request)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
ChatInputBar(
|
||||||
|
inputState = input,
|
||||||
|
onSend = { sends++ },
|
||||||
|
onMicTap = {},
|
||||||
|
isListening = false,
|
||||||
|
isAgentTyping = false,
|
||||||
|
isConnected = true,
|
||||||
|
isSessionReady = true,
|
||||||
|
commandCatalog = CommandCatalog(),
|
||||||
|
pendingAttachments = attachments,
|
||||||
|
receiveContentListener = controller,
|
||||||
|
isReceivingContent = controller.isReceiving,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("chat_input").performClick()
|
||||||
|
compose.runOnIdle { input.edit { selection = TextRange(1, 3) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageUri(): Uri {
|
||||||
|
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.png")
|
||||||
|
ownedFiles.add(file)
|
||||||
|
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
|
||||||
|
try {
|
||||||
|
file.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) }
|
||||||
|
} finally {
|
||||||
|
bitmap.recycle()
|
||||||
|
}
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pdfUri(): Uri {
|
||||||
|
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.pdf")
|
||||||
|
ownedFiles.add(file)
|
||||||
|
file.writeText("%PDF-1.4\nfixture\n")
|
||||||
|
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun paste(clip: ClipData) {
|
||||||
|
compose.runOnIdle {
|
||||||
|
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip)
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("chat_input").performSemanticsAction(SemanticsActions.PasteText) { it() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertImported(source: Uri) {
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("keep draft", input.text.toString())
|
||||||
|
assertEquals(TextRange(1, 3), input.selection)
|
||||||
|
assertEquals(0, sends)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertNotEquals(source.toString(), attachments.single().uri)
|
||||||
|
val copy = File(context.cacheDir, "pasted_images/${attachments.single().name}")
|
||||||
|
assertTrue(copy.isFile)
|
||||||
|
assertEquals(copy.length(), attachments.single().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clipboardImage_becomesPrivateAttachmentWithoutReplacingSelectionOrSending() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
paste(ClipData.newUri(context.contentResolver, "test image", source))
|
||||||
|
assertImported(source)
|
||||||
|
// Clipboard replacement cannot revoke access to the independently owned chip.
|
||||||
|
compose.runOnIdle {
|
||||||
|
context
|
||||||
|
.getSystemService(ClipboardManager::class.java)
|
||||||
|
.setPrimaryClip(ClipData.newPlainText("replacement", "different"))
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imeCommitContent_usesRealInputConnectionAndPreservesDraft() {
|
||||||
|
prepareComposer(interceptIme = true)
|
||||||
|
compose.waitUntil(timeoutMillis = 5_000) { connection != null }
|
||||||
|
val source = imageUri()
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertTrue(
|
||||||
|
editorInfo!!.contentMimeTypes.orEmpty().any { ClipDescription.compareMimeTypes("image/png", it) },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
connection!!.commitContent(
|
||||||
|
InputContentInfo(source, ClipDescription("image", arrayOf("image/png")), null),
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertImported(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mixedClipboard_keepsTextItemsOnTheNormalPastePath() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
val clip = ClipData(ClipDescription("mixed", arrayOf("image/png", "text/plain")), ClipData.Item("hello"))
|
||||||
|
clip.addItem(ClipData.Item(source))
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertFalse(input.text.contains("content://"))
|
||||||
|
assertEquals(0, sends)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dualRepresentationItem_preservesTextPasteAndImportsImage() {
|
||||||
|
prepareComposer()
|
||||||
|
val source = imageUri()
|
||||||
|
val clip =
|
||||||
|
ClipData(
|
||||||
|
ClipDescription("dual", arrayOf("image/png", "text/plain")),
|
||||||
|
ClipData.Item("hello", null, null, source),
|
||||||
|
)
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertEquals("image/png", attachments.single().mimeType)
|
||||||
|
assertNotEquals(source.toString(), attachments.single().uri)
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mixedClipboard_importsImageDespitePdfAndPreservesText() {
|
||||||
|
prepareComposer()
|
||||||
|
val image = imageUri()
|
||||||
|
val pdf = pdfUri()
|
||||||
|
val clip =
|
||||||
|
ClipData(
|
||||||
|
ClipDescription("mixed", arrayOf("image/png", "application/pdf", "text/plain")),
|
||||||
|
ClipData.Item("hello"),
|
||||||
|
)
|
||||||
|
clip.addItem(ClipData.Item(image))
|
||||||
|
clip.addItem(ClipData.Item(pdf))
|
||||||
|
paste(clip)
|
||||||
|
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertEquals("image/png", attachments.single().mimeType)
|
||||||
|
assertNotEquals(image.toString(), attachments.single().uri)
|
||||||
|
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||||
|
assertTrue(errors.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun plainTextPaste_stillReplacesOnlyTheSelection() {
|
||||||
|
prepareComposer()
|
||||||
|
paste(ClipData.newPlainText("text", "hello"))
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("khellop draft", input.text.toString())
|
||||||
|
assertTrue(attachments.isEmpty())
|
||||||
|
assertEquals(0, sends)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanupOnlyTestOwnedFiles() {
|
||||||
|
if (::controller.isInitialized) compose.runOnIdle { controller.close() }
|
||||||
|
ownedFiles.forEach { it.delete() }
|
||||||
|
attachments.forEach { File(context.cacheDir, "pasted_images/${it.name}").delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -1,8 +1,11 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -23,7 +26,6 @@ import androidx.compose.ui.test.performClick
|
|||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.swipeLeft
|
import androidx.compose.ui.test.swipeLeft
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -58,6 +60,7 @@ class ComposerInteractionTest {
|
|||||||
private var fileTaps = 0
|
private var fileTaps = 0
|
||||||
|
|
||||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun setComposer(
|
private fun setComposer(
|
||||||
reasoningLevel: String? = "medium",
|
reasoningLevel: String? = "medium",
|
||||||
model: String = "openai/gpt-5.5",
|
model: String = "openai/gpt-5.5",
|
||||||
@@ -67,18 +70,17 @@ class ComposerInteractionTest {
|
|||||||
showModelProvider: Boolean = false,
|
showModelProvider: Boolean = false,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
var listening by remember { mutableStateOf(false) }
|
var listening by remember { mutableStateOf(false) }
|
||||||
var currentModel by remember { mutableStateOf(model) }
|
var currentModel by remember { mutableStateOf(model) }
|
||||||
|
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||||
val composer: @Composable () -> Unit = {
|
val composer: @Composable () -> Unit = {
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = {
|
onSend = {
|
||||||
sends++
|
sends++
|
||||||
input = TextFieldValue("")
|
input.clearText()
|
||||||
},
|
},
|
||||||
onMicTap = {
|
onMicTap = {
|
||||||
micTaps++
|
micTaps++
|
||||||
|
|||||||
+7
-10
@@ -1,9 +1,8 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.assertTextEquals
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
@@ -11,12 +10,12 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
|||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
class ComposerReadinessTest {
|
class ComposerReadinessTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
val compose = createComposeRule()
|
||||||
@@ -26,10 +25,9 @@ class ComposerReadinessTest {
|
|||||||
val ready = mutableStateOf(false)
|
val ready = mutableStateOf(false)
|
||||||
var sends = 0
|
var sends = 0
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = { sends++ },
|
onSend = { sends++ },
|
||||||
onMicTap = {},
|
onMicTap = {},
|
||||||
isListening = false,
|
isListening = false,
|
||||||
@@ -64,10 +62,9 @@ class ComposerReadinessTest {
|
|||||||
@Test
|
@Test
|
||||||
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
val input = rememberTextFieldState()
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputState = input,
|
||||||
onInputChange = { input = it },
|
|
||||||
onSend = {},
|
onSend = {},
|
||||||
onMicTap = {},
|
onMicTap = {},
|
||||||
isListening = false,
|
isListening = false,
|
||||||
|
|||||||
+120
-2
@@ -1,27 +1,42 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.hasScrollAction
|
||||||
|
import androidx.compose.ui.test.isDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.compose.ui.test.swipeUp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.BusySendMode
|
import com.m57.hermescontrol.data.model.BusySendMode
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
|
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class PendingSendRecoveryTest {
|
class PendingSendRecoveryTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
private val uncertain =
|
private val uncertain =
|
||||||
PendingSend(
|
PendingSend(
|
||||||
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
|
|||||||
state = PendingSendState.UNKNOWN,
|
state = PendingSendState.UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun imageAttachment(): Attachment {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val file = File(context.cacheDir, "photo.png")
|
||||||
|
val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888)
|
||||||
|
bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160))
|
||||||
|
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
bitmap.recycle()
|
||||||
|
return Attachment(file.toURI().toString(), file.name, "image/png", file.length())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun uncertainRetryRequiresExplicitConfirmation() {
|
fun uncertainRetryRequiresExplicitConfirmation() {
|
||||||
var retried: String? = null
|
var retried: String? = null
|
||||||
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
|
|||||||
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() {
|
||||||
|
val image = imageAttachment()
|
||||||
|
val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image))
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
UserBubble(
|
||||||
|
ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments),
|
||||||
|
pendingSendState = accepted.state,
|
||||||
|
)
|
||||||
|
PendingSendRecovery(listOf(accepted), true, true, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText(accepted.text).assertIsDisplayed()
|
||||||
|
compose.waitUntil(5_000) {
|
||||||
|
compose
|
||||||
|
.onNodeWithContentDescription("photo.png")
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot.height > 0
|
||||||
|
}
|
||||||
|
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
|
||||||
|
compose.onNodeWithContentDescription("Accepted by server").assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() {
|
||||||
|
val image = imageAttachment()
|
||||||
|
val file = Attachment("file:///private/note", "note.txt", "text/plain", 4)
|
||||||
|
var openedImage: String? = null
|
||||||
|
var openedFile: String? = null
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(
|
||||||
|
listOf(uncertain.copy(attachments = listOf(image, file))),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
onOpenAttachment = { openedFile = it.name },
|
||||||
|
onImageClick = { openedImage = it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").performClick()
|
||||||
|
compose.onNodeWithText("photo.png").assertIsDisplayed()
|
||||||
|
compose.waitUntil(5_000) {
|
||||||
|
compose
|
||||||
|
.onNodeWithContentDescription("photo.png")
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot.height > 0
|
||||||
|
}
|
||||||
|
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertEquals("photo.png", openedImage) }
|
||||||
|
// The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children.
|
||||||
|
if (!compose.onNodeWithText("note.txt").isDisplayed()) {
|
||||||
|
compose.onNode(hasScrollAction()).performTouchInput { swipeUp() }
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("note.txt").assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertEquals("note.txt", openedFile) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun chineseRecoveryCountAndDismissAreLocalized() {
|
||||||
|
// Native dialog windows resolve the Activity locale, not a synthetic LocalContext.
|
||||||
|
val resources = compose.activity.resources
|
||||||
|
val original = Configuration(resources.configuration)
|
||||||
|
val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) }
|
||||||
|
try {
|
||||||
|
compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) }
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(listOf(uncertain), false, false, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithText("移除")
|
||||||
|
.performScrollTo()
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithText(
|
||||||
|
"要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。",
|
||||||
|
).assertIsDisplayed()
|
||||||
|
} finally {
|
||||||
|
compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
|
|||||||
+7
-3
@@ -3,9 +3,11 @@ package com.m57.hermescontrol.ui.chat.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -19,6 +21,7 @@ import androidx.compose.ui.test.SemanticsMatcher
|
|||||||
import androidx.compose.ui.test.assert
|
import androidx.compose.ui.test.assert
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
import androidx.compose.ui.test.captureToImage
|
import androidx.compose.ui.test.captureToImage
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
@@ -26,7 +29,6 @@ import androidx.compose.ui.test.onNodeWithText
|
|||||||
import androidx.compose.ui.test.onRoot
|
import androidx.compose.ui.test.onRoot
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performTouchInput
|
import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
@@ -52,6 +54,7 @@ import java.io.File
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@MediumTest
|
@MediumTest
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
class VoiceNoteGestureTest {
|
class VoiceNoteGestureTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
@@ -88,8 +91,7 @@ class VoiceNoteGestureTest {
|
|||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = TextFieldValue(draft),
|
inputState = rememberTextFieldState(initialText = draft),
|
||||||
onInputChange = {},
|
|
||||||
onSend = { sendCount++ },
|
onSend = { sendCount++ },
|
||||||
onMicTap = { micTaps++ },
|
onMicTap = { micTaps++ },
|
||||||
isListening = false,
|
isListening = false,
|
||||||
@@ -275,6 +277,8 @@ class VoiceNoteGestureTest {
|
|||||||
assertEquals("release must submit the note", 1, holdEnds)
|
assertEquals("release must submit the note", 1, holdEnds)
|
||||||
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
||||||
}
|
}
|
||||||
|
// The state-based input must retain the draft when the recording strip is removed.
|
||||||
|
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.test.hasContentDescription
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.ViewModelStore
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import java.net.URL
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Opt-in test against a fresh, isolated real cron backend, never a user server. */
|
||||||
|
class ScheduleHttpAcceptanceTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun editorCreatesEditsAndScheduledJobExecutesViaHttp() {
|
||||||
|
val args = InstrumentationRegistry.getArguments()
|
||||||
|
assumeTrue(args.getString("isolatedCronAcceptance") == "true")
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
check(BuildConfig.DEBUG && context.packageName.endsWith(".dev"))
|
||||||
|
val url = "http://127.0.0.1:18765"
|
||||||
|
AuthManager.init(context)
|
||||||
|
kotlinx.coroutines.runBlocking { AuthManager.awaitInitialization() }
|
||||||
|
val oldUrl = AuthManager.getBaseUrl()
|
||||||
|
val oldToken = AuthManager.getToken()
|
||||||
|
val store = ViewModelStore()
|
||||||
|
val owner = object : ViewModelStoreOwner { override val viewModelStore = store }
|
||||||
|
try {
|
||||||
|
AuthManager.setBaseUrl(url)
|
||||||
|
AuthManager.setToken(null)
|
||||||
|
ApiClient.rebuild()
|
||||||
|
val vm = ViewModelProvider(owner)[CronJobsViewModel::class.java]
|
||||||
|
compose.setContent {
|
||||||
|
val state by vm.uiState.collectAsState()
|
||||||
|
MaterialTheme {
|
||||||
|
if (state.editorState.isOpen) {
|
||||||
|
CronJobEditorDialog(
|
||||||
|
state = state.editorState,
|
||||||
|
onFieldChange = vm::updateEditorField,
|
||||||
|
onToggleNoAgent = vm::toggleNoAgent,
|
||||||
|
onToggleRunContinuity = vm::toggleRunContinuity,
|
||||||
|
onSetMonitorMode = vm::setMonitorMode,
|
||||||
|
onSelectBlueprint = vm::selectBlueprint,
|
||||||
|
onBlueprintFieldChange = vm::updateBlueprintValue,
|
||||||
|
onSave = vm::saveEditor,
|
||||||
|
onDismiss = vm::closeEditor,
|
||||||
|
onClearToast = vm::clearEditorToast,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Harmless execution fixture. Schedule is changed using the real clock below.
|
||||||
|
compose.runOnIdle {
|
||||||
|
vm.openNewJobDialog()
|
||||||
|
vm.updateEditorField("name", "Android HTTP clock acceptance")
|
||||||
|
vm.updateEditorField("script", "probe.py")
|
||||||
|
vm.updateEditorField("schedule", "59 23 * * *")
|
||||||
|
vm.toggleNoAgent()
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("23:59").performScrollTo().performClick()
|
||||||
|
compose.onNodeWithText("59").performClick()
|
||||||
|
compose.onNode(hasContentDescription("55", substring = true)).performClick()
|
||||||
|
compose.onNodeWithText(context.getString(R.string.action_ok)).performClick()
|
||||||
|
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick()
|
||||||
|
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen }
|
||||||
|
compose.waitUntil(30_000) { vm.uiState.value.jobs.size == 1 }
|
||||||
|
val id = vm.uiState.value.jobs.single().id
|
||||||
|
compose.runOnIdle { vm.openEditJobDialog(id) }
|
||||||
|
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isLoading }
|
||||||
|
compose.runOnIdle { assertEquals("55 23 * * *", vm.uiState.value.editorState.schedule) }
|
||||||
|
compose.onNodeWithText("23:55").performScrollTo()
|
||||||
|
compose.onNodeWithText(context.getString(R.string.cron_repeat_label)).performClick()
|
||||||
|
compose.onAllNodesWithText(context.getString(R.string.cron_repeat_minutely))[0].performClick()
|
||||||
|
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save)).performClick()
|
||||||
|
compose.waitUntil(30_000) { !vm.uiState.value.editorState.isOpen }
|
||||||
|
val deadline = android.os.SystemClock.elapsedRealtime() + 90_000
|
||||||
|
var evidence = JSONObject()
|
||||||
|
while (android.os.SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
evidence = JSONObject(URL("$url/acceptance/evidence").readText())
|
||||||
|
if (evidence.optString("result") == "PASS") break
|
||||||
|
android.os.SystemClock.sleep(500)
|
||||||
|
}
|
||||||
|
assertEquals(evidence.toString(), "PASS", evidence.optString("result"))
|
||||||
|
assertEquals(id, evidence.getString("job_id"))
|
||||||
|
assertEquals(1, evidence.getInt("runs"))
|
||||||
|
val requests = evidence.getJSONArray("requests")
|
||||||
|
assertEquals(2, requests.length())
|
||||||
|
assertEquals("55 23 * * *", requests.getJSONObject(0).getJSONObject("body").getString("schedule"))
|
||||||
|
assertEquals("* * * * *", requests.getJSONObject(1).getJSONObject("body").getJSONObject("updates").getString("schedule"))
|
||||||
|
assertTrue(requests.getJSONObject(1).getString("path").endsWith(id))
|
||||||
|
} finally {
|
||||||
|
compose.runOnIdle { store.clear() }
|
||||||
|
AuthManager.setBaseUrl(oldUrl)
|
||||||
|
AuthManager.setToken(oldToken)
|
||||||
|
ApiClient.rebuild()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.hasContentDescription
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SchedulePickerTest {
|
||||||
|
@get:Rule val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullEditorHasReachableSaveAction() {
|
||||||
|
var saved = false
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
CronJobEditorDialog(
|
||||||
|
state = CronJobEditorState(isOpen = true, isNew = true, schedule = "59 23 * * *"),
|
||||||
|
onFieldChange = { _, _ -> },
|
||||||
|
onToggleNoAgent = {},
|
||||||
|
onToggleRunContinuity = {},
|
||||||
|
onSetMonitorMode = {},
|
||||||
|
onSelectBlueprint = {},
|
||||||
|
onBlueprintFieldChange = { _, _ -> },
|
||||||
|
onSave = { saved = true },
|
||||||
|
onDismiss = {},
|
||||||
|
onClearToast = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.waitForIdle()
|
||||||
|
android.os.SystemClock.sleep(500)
|
||||||
|
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
|
||||||
|
File(context.getExternalFilesDir(null), "scheduler-editor-360.png").outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save))
|
||||||
|
.assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertTrue(saved) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatDropdownAndClockPreserveSchedule() {
|
||||||
|
var schedule by mutableStateOf("59 23 * * *")
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
Surface {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||||
|
SchedulePickerSection(schedule) { schedule = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun text(id: Int) = context.getString(id)
|
||||||
|
fun screenshot(name: String) {
|
||||||
|
compose.waitForIdle()
|
||||||
|
android.os.SystemClock.sleep(500)
|
||||||
|
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
|
||||||
|
File(context.getExternalFilesDir(null), "$name.png").outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
screenshot("schedule-daily-360")
|
||||||
|
compose.onNodeWithText("23:59").performClick()
|
||||||
|
screenshot("schedule-clock-360")
|
||||||
|
compose.onNodeWithText("59").performClick()
|
||||||
|
compose.onNode(hasContentDescription("55", substring = true)).performClick()
|
||||||
|
compose.onNodeWithText(text(R.string.action_ok)).performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
screenshot("schedule-repeat-360")
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_repeat_yearly))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 23 1 1 *", schedule) }
|
||||||
|
screenshot("schedule-yearly-360")
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_repeat_hourly))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 * * * *", schedule) }
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_schedule_mode_daily))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
|
||||||
|
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||||
|
compose.onAllNodesWithText(text(R.string.cron_repeat_minutely))[0].performClick()
|
||||||
|
compose.runOnIdle { assertEquals("* * * * *", schedule) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Dev</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Dev</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Dev</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Dev</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- AITURK IDE pairing: QR scan. Requested at runtime; typing the code works without it. -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
@@ -38,8 +41,23 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- AITURK IDE pairing link: aiturk://pair?v=1&u=...&fp=...&c=... -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="aiturk" android:host="pair" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.aiturkpair.AiturkQrCaptureActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="fullSensor"
|
||||||
|
android:stateNotNeeded="true"
|
||||||
|
android:theme="@style/zxing_CaptureTheme"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".assistant.AssistantService"
|
android:name=".assistant.AssistantService"
|
||||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||||
|
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -28,8 +29,14 @@ class HermesControlApp :
|
|||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
val startupTimeMs = System.currentTimeMillis()
|
||||||
|
appScope.launch(Dispatchers.IO) {
|
||||||
|
cleanStalePastedImages(cacheDir, startupTimeMs)
|
||||||
|
}
|
||||||
AuthManager.init(this)
|
AuthManager.init(this)
|
||||||
NetworkMonitor.init(this)
|
NetworkMonitor.init(this)
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
.init(this)
|
||||||
SessionListCacheStore.init(this)
|
SessionListCacheStore.init(this)
|
||||||
// Reply-notification turn boundaries survive process death (armed before
|
// Reply-notification turn boundaries survive process death (armed before
|
||||||
// each mobile prompt, consumed by the matching completion).
|
// each mobile prompt, consumed by the matching completion).
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
|||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLink
|
||||||
import com.m57.hermescontrol.ui.common.ErrorState
|
import com.m57.hermescontrol.ui.common.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||||
@@ -62,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
consumeNotificationIntent(intent)
|
consumeNotificationIntent(intent)
|
||||||
|
AiturkPairDeepLink.offer(intent)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||||
@@ -129,6 +131,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
consumeNotificationIntent(intent)
|
consumeNotificationIntent(intent)
|
||||||
|
AiturkPairDeepLink.offer(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ import com.m57.hermescontrol.data.local.AuthManager
|
|||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkUnavailableScreen
|
||||||
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
||||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||||
@@ -66,6 +69,7 @@ import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
|||||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairScreen as AiturkPairScreenContent
|
||||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||||
|
|
||||||
@@ -79,6 +83,9 @@ private fun appEntryProvider(
|
|||||||
onAuthLogin = {
|
onAuthLogin = {
|
||||||
NavigationController.navigateTo(AuthLoginScreen)
|
NavigationController.navigateTo(AuthLoginScreen)
|
||||||
},
|
},
|
||||||
|
onAiturkPair = {
|
||||||
|
NavigationController.navigateTo(AiturkPairScreen)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
|
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
|
||||||
DisableDrawerGestures()
|
DisableDrawerGestures()
|
||||||
@@ -96,9 +103,25 @@ private fun appEntryProvider(
|
|||||||
DisableDrawerGestures()
|
DisableDrawerGestures()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entry<AiturkPairScreen> {
|
||||||
|
AiturkPairScreenContent(
|
||||||
|
onConnected = {
|
||||||
|
NavigationController.resetTo(ChatScreen)
|
||||||
|
},
|
||||||
|
onBack = {
|
||||||
|
NavigationController.goBack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DisableDrawerGestures()
|
||||||
|
}
|
||||||
|
|
||||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||||
addEntryProvider(clazz = screen.key::class) {
|
addEntryProvider(clazz = screen.key::class) {
|
||||||
screen.content(sessionId, openDrawer)
|
if (AiturkFeatureGate.shows(screen.key)) {
|
||||||
|
screen.content(sessionId, openDrawer)
|
||||||
|
} else {
|
||||||
|
AiturkUnavailableScreen(titleRes = screen.labelRes, onOpenDrawer = openDrawer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +216,7 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
|
|
||||||
val backStack = remember(startScreen) { NavBackStack(startScreen) }
|
val backStack = remember(startScreen) { NavBackStack(startScreen) }
|
||||||
NavigationController.backStack = backStack
|
NavigationController.backStack = backStack
|
||||||
|
AiturkPairDeepLinkEffect()
|
||||||
|
|
||||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
@@ -307,6 +331,7 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
)
|
)
|
||||||
ScreenRegistry.ALL_SCREENS
|
ScreenRegistry.ALL_SCREENS
|
||||||
.filter { it.drawerSection == section }
|
.filter { it.drawerSection == section }
|
||||||
|
.filter { AiturkFeatureGate.shows(it.key) }
|
||||||
.forEach { entry ->
|
.forEach { entry ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(entry.icon, contentDescription = null) },
|
icon = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable data object AuthLoginScreen : NavKey
|
@Serializable data object AuthLoginScreen : NavKey
|
||||||
|
|
||||||
|
@Serializable data object AiturkPairScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object ChatScreen : NavKey
|
@Serializable data object ChatScreen : NavKey
|
||||||
|
|
||||||
@Serializable data object SettingsScreen : NavKey
|
@Serializable data object SettingsScreen : NavKey
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What differs when the app talks to a paired AITURK IDE instead of a plain
|
||||||
|
* Hermes dashboard, kept in one place so the upstream files only carry
|
||||||
|
* one-line hooks.
|
||||||
|
*
|
||||||
|
* AITURK IDE's agent is an older/forked Hermes gateway: some JSON-RPC methods
|
||||||
|
* this app knows (`connectors.*`, `vault.*`, `subagent.list` …) are not
|
||||||
|
* registered there and answer `-32601 unknown method`, and the IDE's phone
|
||||||
|
* proxy refuses secret and settings routes with `403 {"error":"forbidden"}`.
|
||||||
|
* Neither is an error the user can act on, so:
|
||||||
|
*
|
||||||
|
* - an awaited call that hits `-32601` fails quietly (no chat banner) and the
|
||||||
|
* method is remembered until the next socket, so it is not sent again;
|
||||||
|
* - every text shown for such an answer is a plain sentence, never the raw
|
||||||
|
* `JsonRpcError(code=-32601, …)` or `unknown method` wording;
|
||||||
|
* - idle pooled tunnel connections are dropped when the phone changes network
|
||||||
|
* and the chat socket is reopened when the app returns to the foreground.
|
||||||
|
*
|
||||||
|
* Nothing here runs for a connection that is not a paired AITURK IDE.
|
||||||
|
*/
|
||||||
|
object AiturkCompat {
|
||||||
|
const val METHOD_NOT_FOUND = -32601
|
||||||
|
|
||||||
|
private const val FALLBACK_FEATURE_UNAVAILABLE = "This feature is not available in this version of AITURK IDE."
|
||||||
|
private const val FALLBACK_PHONE_FORBIDDEN =
|
||||||
|
"For security this cannot be done from the phone. Do it in AITURK IDE on your computer."
|
||||||
|
private const val FALLBACK_BACKEND_NOT_READY =
|
||||||
|
"The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes."
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
/** Tests set this instead of pairing a real address. */
|
||||||
|
@Volatile
|
||||||
|
internal var linkedOverride: Boolean? = null
|
||||||
|
|
||||||
|
/** Tests replace the string lookup (no Android resources on the JVM). */
|
||||||
|
@Volatile
|
||||||
|
internal var stringLookup: ((Int) -> String?)? = null
|
||||||
|
|
||||||
|
private val missingMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||||
|
private val started = AtomicBoolean(false)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
/** Called once from the Application: string lookup and the network-change hook. */
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
if (started.compareAndSet(false, true)) {
|
||||||
|
scope.launch {
|
||||||
|
NetworkMonitor.networkChanges.collect { available -> if (available) onNetworkChanged() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the active connection is a paired AITURK IDE (LAN or tunnel). */
|
||||||
|
fun isLinked(): Boolean =
|
||||||
|
linkedOverride ?: runCatching { AiturkLanTrust.isPairedUrl(AuthManager.baseUrl()) }.getOrDefault(false)
|
||||||
|
|
||||||
|
/** REST and chat must resolve the same phone-selected profile in the IDE proxy. */
|
||||||
|
fun webSocketProfile(profile: String?): Map<String, String> =
|
||||||
|
if (isLinked()) mapOf("profile" to (profile?.takeIf { it.isNotBlank() } ?: "default")) else emptyMap()
|
||||||
|
|
||||||
|
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** True when an awaited call's error must not also be shown as a chat banner. */
|
||||||
|
fun quietRpcError(code: Int): Boolean = code == METHOD_NOT_FOUND && isLinked()
|
||||||
|
|
||||||
|
/** Remembers that the IDE on the other end does not have [method]. */
|
||||||
|
fun noteRpcError(
|
||||||
|
method: String?,
|
||||||
|
code: Int,
|
||||||
|
) {
|
||||||
|
if (method != null && quietRpcError(code)) missingMethods.add(method)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [method] already answered "unknown method" on this socket. */
|
||||||
|
fun isKnownMissing(method: String): Boolean = method in missingMethods && isLinked()
|
||||||
|
|
||||||
|
/** A new socket may be a newer IDE: ask again. */
|
||||||
|
fun onSocketOpened() {
|
||||||
|
missingMethods.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The exception a locally refused call fails with (same code the IDE would send). */
|
||||||
|
fun missingMethodError(): HermesWsClient.HermesRpcException =
|
||||||
|
HermesWsClient.HermesRpcException(featureUnavailableText(), code = METHOD_NOT_FOUND)
|
||||||
|
|
||||||
|
/** Message of the exception an awaited call fails with. */
|
||||||
|
fun rpcExceptionMessage(
|
||||||
|
code: Int,
|
||||||
|
message: String,
|
||||||
|
): String = if (quietRpcError(code)) featureUnavailableText() else message
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat banner text for an RPC error event. [legacy] is the upstream text,
|
||||||
|
* used unchanged for every connection that is not a paired AITURK IDE.
|
||||||
|
*/
|
||||||
|
fun rpcBannerText(
|
||||||
|
error: Any?,
|
||||||
|
legacy: String,
|
||||||
|
): String {
|
||||||
|
if (!isLinked()) return legacy
|
||||||
|
val code = (error as? JsonRpcError)?.code ?: ((error as? Map<*, *>)?.get("code") as? Number)?.toInt()
|
||||||
|
if (code == METHOD_NOT_FOUND) return featureUnavailableText()
|
||||||
|
val message = (error as? JsonRpcError)?.message ?: (error as? Map<*, *>)?.get("message") as? String
|
||||||
|
return message?.takeIf { it.isNotBlank() } ?: legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── REST ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain sentence for the two answers that mean "not from the phone" /
|
||||||
|
* "not in this IDE": the proxy's `403 {"error":"forbidden"}` and a route
|
||||||
|
* the IDE does not have (`404 {"detail":"Not Found"}`, the proxy's
|
||||||
|
* `404 {"error":"not_found"}`). Null for everything else, including a 404
|
||||||
|
* that names a real missing record ("Session not found").
|
||||||
|
*/
|
||||||
|
fun httpErrorMessage(
|
||||||
|
code: Int,
|
||||||
|
errorBody: String?,
|
||||||
|
): String? {
|
||||||
|
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND && code != HTTP_UNAVAILABLE) return null
|
||||||
|
if (!isLinked()) return null
|
||||||
|
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
||||||
|
val error = (body?.get("error") as? JsonPrimitive)?.content
|
||||||
|
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
||||||
|
return when {
|
||||||
|
code == HTTP_UNAVAILABLE && error == "backend_unavailable" ->
|
||||||
|
string(R.string.aiturk_backend_not_ready) ?: FALLBACK_BACKEND_NOT_READY
|
||||||
|
code == HTTP_FORBIDDEN && error == "forbidden" -> phoneForbiddenText()
|
||||||
|
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A brand-new chat has no row in the IDE's session store until its first
|
||||||
|
* message, so `GET /api/sessions/<id>` answers 404 on every poll.
|
||||||
|
*/
|
||||||
|
fun skipSessionDetail(transcriptEmpty: Boolean): Boolean = transcriptEmpty && isLinked()
|
||||||
|
|
||||||
|
// ── Network and lifecycle ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default network changed (Wi-Fi ⇄ mobile data). Idle pooled
|
||||||
|
* connections still ride the old network's tunnel stream; drop them so the
|
||||||
|
* next REST call opens a fresh stream instead of waiting for a read timeout.
|
||||||
|
*/
|
||||||
|
internal fun onNetworkChanged(evictIdle: () -> Unit = { OkHttpProvider.base.connectionPool.evictAll() }) {
|
||||||
|
if (isLinked()) runCatching(evictIdle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app came back to the foreground. An idle socket is closed on purpose
|
||||||
|
* in the background; reopen it so the user does not have to tap "reconnect".
|
||||||
|
* A socket that is open, opening, or signed out is left alone.
|
||||||
|
*/
|
||||||
|
fun onAppForeground(
|
||||||
|
status: ConnectionStatus = HermesWsClient.connectionStatus.value,
|
||||||
|
autoReconnect: () -> Boolean = { runCatching { AuthManager.isAutoReconnect() }.getOrDefault(false) },
|
||||||
|
connect: () -> Unit = { HermesWsClient.connect() },
|
||||||
|
) {
|
||||||
|
if (status == ConnectionStatus.DISCONNECTED && isLinked() && autoReconnect()) connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** "v1.2.3 (45)": what the pair screen and Settings → About show. */
|
||||||
|
fun versionLabel(
|
||||||
|
name: String = BuildConfig.VERSION_NAME,
|
||||||
|
code: Int = BuildConfig.VERSION_CODE,
|
||||||
|
): String = "v$name ($code)"
|
||||||
|
|
||||||
|
fun featureUnavailableText(): String = string(R.string.aiturk_feature_unavailable) ?: FALLBACK_FEATURE_UNAVAILABLE
|
||||||
|
|
||||||
|
fun phoneForbiddenText(): String = string(R.string.aiturk_phone_forbidden) ?: FALLBACK_PHONE_FORBIDDEN
|
||||||
|
|
||||||
|
private fun string(
|
||||||
|
@StringRes id: Int,
|
||||||
|
): String? = stringLookup?.invoke(id) ?: runCatching { appContext?.getString(id) }.getOrNull()
|
||||||
|
|
||||||
|
internal fun resetForTest() {
|
||||||
|
linkedOverride = null
|
||||||
|
stringLookup = null
|
||||||
|
missingMethods.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val HTTP_FORBIDDEN = 403
|
||||||
|
private const val HTTP_NOT_FOUND = 404
|
||||||
|
private const val HTTP_UNAVAILABLE = 503
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a freshly claimed AITURK IDE pairing in the app's existing connection
|
||||||
|
* model: a [ConnectionProfile] named after the computer, token auth, WS `?token=`.
|
||||||
|
*/
|
||||||
|
object AiturkConnectionRegistrar {
|
||||||
|
const val FALLBACK_PC_NAME = "AITURK IDE"
|
||||||
|
|
||||||
|
suspend fun register(device: AiturkPairedDevice) {
|
||||||
|
// The address the claim was sent to: the LAN `https://ip:port/` or the
|
||||||
|
// virtual tunnel host `https://<relayId>.relay.aiturk.invalid/`.
|
||||||
|
val baseUrl = device.baseUrl
|
||||||
|
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
|
||||||
|
|
||||||
|
// Trust first: every later request to this host:port — over the LAN or
|
||||||
|
// through the tunnel — is checked against the pin, end to end.
|
||||||
|
AiturkLanTrust.pin(device.hostPort, device.fingerprint)
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
|
||||||
|
|
||||||
|
// Re-pairing the same computer reuses its profile instead of piling up duplicates.
|
||||||
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
|
val existing = profiles.firstOrNull { it.baseUrl == baseUrl }
|
||||||
|
val profile =
|
||||||
|
existing?.copy(name = name)
|
||||||
|
?: ConnectionProfile(name = name, host = "", port = 0, baseUrl = baseUrl)
|
||||||
|
AuthManager.saveConnectionProfiles(
|
||||||
|
if (existing != null) {
|
||||||
|
profiles.map { if (it.id == profile.id) profile else it }
|
||||||
|
} else {
|
||||||
|
profiles + profile
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AuthManager.setSelectedProfileId(profile.id)
|
||||||
|
AuthManager.syncCookieStoreForProfile(profile.id)
|
||||||
|
|
||||||
|
// Same order as the manual token login (AuthLoginViewModel.connect).
|
||||||
|
AuthManager.setBaseUrl(baseUrl)
|
||||||
|
AuthManager.setToken(device.token)
|
||||||
|
AuthManager.setSessionCookie(null)
|
||||||
|
AuthManager.setWsAuthParam("token")
|
||||||
|
// Rebuilds Retrofit, tells chat the server changed, then re-dials the socket.
|
||||||
|
ProfileSwitchCoordinator.switchConnectionProfile(profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS trust for AITURK IDE LAN pairing.
|
||||||
|
*
|
||||||
|
* The IDE's LAN proxy presents a self-signed certificate. Its DER SHA-256
|
||||||
|
* fingerprint travels in the QR code (or the gateway rendezvous record) and is
|
||||||
|
* stored here per `host:port`. For a paired `host:port` the certificate is
|
||||||
|
* accepted ONLY when its fingerprint matches (system CAs are not consulted);
|
||||||
|
* for every other address the platform's default trust manager and OkHttp's
|
||||||
|
* default hostname verifier decide exactly as before.
|
||||||
|
*/
|
||||||
|
object AiturkLanTrust {
|
||||||
|
/** Where pins are persisted. Swappable so JVM tests need no Android keystore. */
|
||||||
|
interface PinPersistence {
|
||||||
|
fun load(): Map<String, String>
|
||||||
|
|
||||||
|
fun save(pins: Map<String, String>)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PREF_KEY = "aiturk_lan_pins"
|
||||||
|
private val IPV4_HOST_PORT = Regex("""\d{1,3}(\.\d{1,3}){3}:\d{1,5}""")
|
||||||
|
|
||||||
|
private val pins = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var persistence: PinPersistence = EncryptedPrefsPersistence
|
||||||
|
|
||||||
|
private val platformTrustManager: X509TrustManager by lazy { defaultTrustManager() }
|
||||||
|
private val platformHostnameVerifier: HostnameVerifier by lazy { OkHttpClient.Builder().build().hostnameVerifier }
|
||||||
|
|
||||||
|
val trustManager: X509TrustManager by lazy { PinAwareTrustManager(platformTrustManager, ::pinFor, ::pinsForHost) }
|
||||||
|
val sslSocketFactory: SSLSocketFactory by lazy { socketFactoryFor(trustManager) }
|
||||||
|
val hostnameVerifier: HostnameVerifier by lazy { PinAwareHostnameVerifier(platformHostnameVerifier, ::pinFor) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single hook for [com.m57.hermescontrol.data.remote.OkHttpProvider.base]:
|
||||||
|
* every REST / WebSocket / media / image client derives from that builder.
|
||||||
|
* If the TLS objects cannot be built the builder is left untouched (platform
|
||||||
|
* defaults), which only means a paired IDE cannot be reached.
|
||||||
|
*/
|
||||||
|
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder =
|
||||||
|
runCatching {
|
||||||
|
builder
|
||||||
|
.sslSocketFactory(sslSocketFactory, trustManager)
|
||||||
|
.hostnameVerifier(hostnameVerifier)
|
||||||
|
// A relay pairing lives on a virtual host: DNS, socket and proxy
|
||||||
|
// choice are special-cased for that host only (see AiturkTunnel).
|
||||||
|
.let(AiturkTunnel::applyTo)
|
||||||
|
}.getOrDefault(builder)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client that talks ONLY to a server presenting [fingerprint] — used for
|
||||||
|
* `hello` / `claim` before the pin is stored. Nothing else is trusted on it.
|
||||||
|
*/
|
||||||
|
fun pinnedClient(
|
||||||
|
base: OkHttpClient,
|
||||||
|
fingerprint: String,
|
||||||
|
tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
|
||||||
|
): OkHttpClient {
|
||||||
|
val lookup: (String) -> String? = { fingerprint }
|
||||||
|
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
|
||||||
|
return base
|
||||||
|
.newBuilder()
|
||||||
|
.sslSocketFactory(socketFactoryFor(manager), manager)
|
||||||
|
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
|
||||||
|
.let { AiturkTunnel.applyTo(it, tunnel) }
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pin(
|
||||||
|
hostPort: String,
|
||||||
|
fingerprint: String,
|
||||||
|
) {
|
||||||
|
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
|
||||||
|
// Pins are for the IDE's own TLS proxy only: its LAN `ip:port`, or the
|
||||||
|
// virtual tunnel host. A real DNS name keeps the platform's checks.
|
||||||
|
require(IPV4_HOST_PORT.matches(hostPort) || isRelayHostPort(hostPort)) {
|
||||||
|
"Only an AITURK IDE address can be pinned"
|
||||||
|
}
|
||||||
|
ensureLoaded()
|
||||||
|
// Saving on top of an unread store would drop the pins already in it.
|
||||||
|
check(loaded) { "Secure storage is not ready" }
|
||||||
|
pins[hostPort] = fingerprint
|
||||||
|
persistence.save(pins.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpin(hostPort: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (pins.remove(hostPort) != null) persistence.save(pins.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinFor(hostPort: String): String? {
|
||||||
|
ensureLoaded()
|
||||||
|
return pins[hostPort]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pins of every paired port on [host] (the Android chain-cleaner path knows no port). */
|
||||||
|
internal fun pinsForHost(host: String): Collection<String> {
|
||||||
|
ensureLoaded()
|
||||||
|
return pins.filterKeys { it.substringBeforeLast(':') == host }.values
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [baseUrl] is an HTTPS address of a paired AITURK IDE. */
|
||||||
|
fun isPairedUrl(baseUrl: String?): Boolean {
|
||||||
|
val url = baseUrl?.toHttpUrlOrNull() ?: return false
|
||||||
|
return url.isHttps && pinFor("${url.host}:${url.port}") != null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resetForTest(newPersistence: PinPersistence) {
|
||||||
|
persistence = newPersistence
|
||||||
|
pins.clear()
|
||||||
|
loaded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoaded() {
|
||||||
|
if (loaded) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (loaded) return
|
||||||
|
// Storage not ready yet (or broken): behave as "nothing paired" for
|
||||||
|
// now and look again on the next call. load() never blocks.
|
||||||
|
val stored = runCatching { persistence.load() }.getOrNull() ?: return
|
||||||
|
stored.forEach { (key, value) -> pins.putIfAbsent(key, value) }
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DER SHA-256 of [certificate] compared in constant time with a base64url pin. */
|
||||||
|
internal fun matches(
|
||||||
|
certificate: X509Certificate,
|
||||||
|
fingerprint: String,
|
||||||
|
): Boolean {
|
||||||
|
val expected = AiturkPairing.decodeFingerprint(fingerprint) ?: return false
|
||||||
|
val actual = MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
|
||||||
|
return MessageDigest.isEqual(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRelayHostPort(hostPort: String): Boolean =
|
||||||
|
hostPort.endsWith(":${AiturkRelay.PORT}") && AiturkRelay.isVirtualHost(hostPort.substringBeforeLast(':'))
|
||||||
|
|
||||||
|
// Wrapped so that TLS over a tunnel stream is layered on a socket without a
|
||||||
|
// file descriptor; for every other socket the wrapper is a pass-through.
|
||||||
|
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
|
||||||
|
AiturkTunnelSslSocketFactory(
|
||||||
|
SSLContext
|
||||||
|
.getInstance("TLS")
|
||||||
|
.apply { init(null, arrayOf(manager), null) }
|
||||||
|
.socketFactory,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun defaultTrustManager(): X509TrustManager {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pins live in the app's encrypted preferences next to the connection tokens. */
|
||||||
|
private object EncryptedPrefsPersistence : PinPersistence {
|
||||||
|
private fun prefs(): android.content.SharedPreferences {
|
||||||
|
// Never wait here: this runs inside TLS handshakes. A connection to a
|
||||||
|
// paired IDE cannot start before AuthManager is ready anyway, because
|
||||||
|
// its address and token come from the same store.
|
||||||
|
check(AuthManager.initializationState.value == AuthManager.InitializationState.Ready) {
|
||||||
|
"Secure storage is not ready"
|
||||||
|
}
|
||||||
|
return runBlocking { AuthManager.awaitPrefs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): Map<String, String> {
|
||||||
|
val prefs = prefs()
|
||||||
|
val raw = prefs.getString(PREF_KEY, null) ?: return emptyMap()
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content }
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(pins: Map<String, String>) {
|
||||||
|
val prefs = prefs()
|
||||||
|
val encoded = JsonObject(pins.mapValues { JsonPrimitive(it.value) }).toString()
|
||||||
|
prefs.edit().putString(PREF_KEY, encoded).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the leaf certificate against the pin of the peer `host:port` when one
|
||||||
|
* exists; otherwise defers to [delegate] untouched.
|
||||||
|
*/
|
||||||
|
internal class PinAwareTrustManager(
|
||||||
|
private val delegate: X509TrustManager,
|
||||||
|
private val pinFor: (String) -> String?,
|
||||||
|
private val pinsForHost: (String) -> Collection<String>,
|
||||||
|
) : X509ExtendedTrustManager() {
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
socket: Socket?,
|
||||||
|
) {
|
||||||
|
val pin = peerOf(socket)?.let(pinFor)
|
||||||
|
when {
|
||||||
|
pin != null -> requirePinned(chain, pin)
|
||||||
|
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, socket)
|
||||||
|
else -> delegate.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
engine: SSLEngine?,
|
||||||
|
) {
|
||||||
|
val pin = engine?.let { peerKey(it.peerHost, it.peerPort) }?.let(pinFor)
|
||||||
|
when {
|
||||||
|
pin != null -> requirePinned(chain, pin)
|
||||||
|
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, engine)
|
||||||
|
else -> delegate.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No peer information: never apply a pin, platform validation only. */
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) = delegate.checkServerTrusted(chain, authType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android's hostname-aware entry point (`X509TrustManagerExtensions`, used by
|
||||||
|
* OkHttp's certificate chain cleaner). Looked up reflectively by the platform.
|
||||||
|
* The port is unknown here, so any pin stored for [host] may match. This is
|
||||||
|
* not the handshake's trust decision (that is the socket/engine overload).
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
host: String,
|
||||||
|
): List<X509Certificate> {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||||
|
if (pinsForHost(host).any { AiturkLanTrust.matches(leaf, it) }) return listOf(leaf)
|
||||||
|
val hostAware =
|
||||||
|
runCatching {
|
||||||
|
delegate.javaClass.getMethod(
|
||||||
|
"checkServerTrusted",
|
||||||
|
Array<X509Certificate>::class.java,
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
if (hostAware == null) {
|
||||||
|
delegate.checkServerTrusted(chain, authType)
|
||||||
|
return chain.toList()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return hostAware.invoke(delegate, chain, authType, host) as? List<X509Certificate> ?: chain.toList()
|
||||||
|
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||||
|
throw e.targetException as? CertificateException ?: CertificateException(e.targetException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
socket: Socket?,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
engine: SSLEngine?,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
|
||||||
|
|
||||||
|
private fun requirePinned(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
pin: String,
|
||||||
|
) {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||||
|
if (!AiturkLanTrust.matches(leaf, pin)) {
|
||||||
|
throw CertificateException("AITURK IDE certificate fingerprint mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peerOf(socket: Socket?): String? {
|
||||||
|
if (socket == null) return null
|
||||||
|
// Prefer the identity the TLS layer was asked to reach (the URL's host
|
||||||
|
// and port); fall back to the connected address.
|
||||||
|
val session = (socket as? SSLSocket)?.let { runCatching { it.handshakeSession }.getOrNull() }
|
||||||
|
return session?.let { peerKey(it.peerHost, it.peerPort) }
|
||||||
|
?: peerKey(socket.inetAddress?.hostAddress, socket.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peerKey(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
): String? = if (host.isNullOrBlank() || port <= 0) null else "$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hostname check: fingerprint for a paired `host:port`, [delegate] for everything else. */
|
||||||
|
internal class PinAwareHostnameVerifier(
|
||||||
|
private val delegate: HostnameVerifier,
|
||||||
|
private val pinFor: (String) -> String?,
|
||||||
|
) : HostnameVerifier {
|
||||||
|
override fun verify(
|
||||||
|
hostname: String,
|
||||||
|
session: SSLSession,
|
||||||
|
): Boolean {
|
||||||
|
val pin = pinFor("$hostname:${session.peerPort}") ?: return delegate.verify(hostname, session)
|
||||||
|
val leaf =
|
||||||
|
runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }.getOrNull()
|
||||||
|
?: return false
|
||||||
|
return AiturkLanTrust.matches(leaf, pin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by [AiturkLanTrust.pinnedClient]: anything that is not the pinned certificate is refused. */
|
||||||
|
private object RejectAllTrustManager : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
): Unit = throw CertificateException("Not trusted")
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
): Unit = throw CertificateException("Not trusted")
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,673 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.await
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancelChildren
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InterruptedIOException
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.NoRouteToHostException
|
||||||
|
import java.net.PortUnreachableException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
|
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
|
||||||
|
enum class AiturkPairError {
|
||||||
|
/** Gateway `resolve` 404: unknown, expired or exhausted code. */
|
||||||
|
CODE_NOT_FOUND,
|
||||||
|
|
||||||
|
/** Gateway `resolve` 429. */
|
||||||
|
RATE_LIMITED,
|
||||||
|
|
||||||
|
/** Gateway unreachable or answered something unexpected. */
|
||||||
|
GATEWAY_UNAVAILABLE,
|
||||||
|
|
||||||
|
/** No LAN candidate answered `hello` with the pinned certificate in time. */
|
||||||
|
PC_UNREACHABLE,
|
||||||
|
|
||||||
|
/** Tunnel closed with 4503: AITURK IDE is not running or Phone connection is off. */
|
||||||
|
HOST_OFFLINE,
|
||||||
|
|
||||||
|
/** Tunnel closed with 4504, or the computer stayed silent inside the tunnel. */
|
||||||
|
HOST_TIMEOUT,
|
||||||
|
|
||||||
|
/** Tunnel closed with 4429: too many streams for this relay. */
|
||||||
|
RELAY_BUSY,
|
||||||
|
|
||||||
|
/** Tunnel closed with 4404: the relay id no longer exists. */
|
||||||
|
RELAY_UNKNOWN,
|
||||||
|
|
||||||
|
/** turkservis.online could not be reached at all (no internet, DNS, timeout). */
|
||||||
|
NO_INTERNET,
|
||||||
|
|
||||||
|
/** Inside the tunnel the certificate did not match the pairing fingerprint. */
|
||||||
|
TLS_PIN,
|
||||||
|
|
||||||
|
/** The tunnel ended in a way the contract does not name. */
|
||||||
|
RELAY_ERROR,
|
||||||
|
|
||||||
|
/** Only LAN candidates were left and the local-network permission is denied. */
|
||||||
|
LAN_PERMISSION,
|
||||||
|
|
||||||
|
/** `claim` 400 `invalid_request` (or 413). */
|
||||||
|
INVALID_REQUEST,
|
||||||
|
|
||||||
|
/** `claim` 401 `wrong_code`. */
|
||||||
|
WRONG_CODE,
|
||||||
|
|
||||||
|
/** `claim` 410 `pairing_closed` (not open, expired or code already used). */
|
||||||
|
PAIRING_CLOSED,
|
||||||
|
|
||||||
|
/** `claim` 410 `pairing_locked` (5 wrong attempts). */
|
||||||
|
PAIRING_LOCKED,
|
||||||
|
|
||||||
|
/** The computer answered, but not with what the contract promises. */
|
||||||
|
UNEXPECTED_RESPONSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [baseUrl] is the address the `claim` was sent to (trailing slash): either the
|
||||||
|
* LAN `https://<ip:port>/` ([endpoint] set) or the virtual tunnel host
|
||||||
|
* `https://<relayId>.relay.aiturk.invalid/` ([endpoint] null). In both cases the
|
||||||
|
* certificate is pinned to [fingerprint] under [hostPort].
|
||||||
|
*/
|
||||||
|
data class AiturkPairedDevice(
|
||||||
|
val baseUrl: String,
|
||||||
|
val hostPort: String,
|
||||||
|
val endpoint: AiturkEndpoint?,
|
||||||
|
val fingerprint: String,
|
||||||
|
val token: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val pcName: String,
|
||||||
|
val version: String,
|
||||||
|
) {
|
||||||
|
val viaRelay: Boolean get() = endpoint == null
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface AiturkPairResult<out T> {
|
||||||
|
data class Success<T>(
|
||||||
|
val value: T,
|
||||||
|
) : AiturkPairResult<T>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [code] is a short diagnostic shown in small print (`relay:host_offline`,
|
||||||
|
* `tls:pin`, `lan:timeout`, `relay:network,lan:refused` …). It is assembled only from
|
||||||
|
* the fixed vocabulary in [AiturkDiag] plus an HTTP status number: never a
|
||||||
|
* token, pairing code, address or exception message.
|
||||||
|
*/
|
||||||
|
data class Failure(
|
||||||
|
val error: AiturkPairError,
|
||||||
|
val code: String = "",
|
||||||
|
) : AiturkPairResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed vocabulary of the diagnostic codes. */
|
||||||
|
object AiturkDiag {
|
||||||
|
const val TIMEOUT = "timeout"
|
||||||
|
const val DNS = "dns"
|
||||||
|
const val TLS = "tls"
|
||||||
|
const val REFUSED = "refused"
|
||||||
|
const val UNREACHABLE = "unreachable"
|
||||||
|
const val PERMISSION = "permission"
|
||||||
|
const val IO = "io"
|
||||||
|
const val BAD_RESPONSE = "bad_response"
|
||||||
|
const val NO_CANDIDATES = "no_candidates"
|
||||||
|
const val PAIRING_CLOSED = "pairing_closed"
|
||||||
|
const val PIN = "pin"
|
||||||
|
const val NETWORK = "network"
|
||||||
|
|
||||||
|
private const val MAX_CAUSE_DEPTH = 6
|
||||||
|
private val safe = Regex("[a-z]{1,8}:[a-z0-9_]{1,24}(,[a-z]{1,8}:[a-z0-9_]{1,24})*")
|
||||||
|
|
||||||
|
/** Defence in depth for the UI: anything outside the vocabulary shape is not shown. */
|
||||||
|
fun sanitize(code: String?): String = code?.takeIf { it.length <= MAX_CODE_LENGTH && safe.matches(it) }.orEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which kind of network failure [error] is. Looks at the exception TYPE and,
|
||||||
|
* for connect failures, at the errno name the platform puts in the message
|
||||||
|
* (`ECONNREFUSED`, `EHOSTUNREACH`, …). The message itself is never returned.
|
||||||
|
*/
|
||||||
|
fun networkKind(error: Throwable): String {
|
||||||
|
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
|
||||||
|
val text = chain.joinToString(" ") { it.message.orEmpty() }.uppercase(Locale.ROOT)
|
||||||
|
|
||||||
|
fun mentions(vararg needles: String) = needles.any { it in text }
|
||||||
|
return when {
|
||||||
|
chain.any { it is SSLException || it is CertificateException } -> TLS
|
||||||
|
|
||||||
|
chain.any { it is UnknownHostException } -> DNS
|
||||||
|
|
||||||
|
mentions("EPERM", "EACCES", "OPERATION NOT PERMITTED", "PERMISSION DENIED") -> PERMISSION
|
||||||
|
|
||||||
|
chain.any { it is NoRouteToHostException || it is PortUnreachableException } -> UNREACHABLE
|
||||||
|
|
||||||
|
mentions("EHOSTUNREACH", "ENETUNREACH", "NO ROUTE TO HOST", "NETWORK IS UNREACHABLE") -> UNREACHABLE
|
||||||
|
|
||||||
|
chain.any { it is SocketTimeoutException } || mentions("ETIMEDOUT") -> TIMEOUT
|
||||||
|
|
||||||
|
chain.any { it is ConnectException } -> REFUSED
|
||||||
|
|
||||||
|
// OkHttp's callTimeout surfaces as InterruptedIOException("timeout").
|
||||||
|
chain.any { it is InterruptedIOException } -> TIMEOUT
|
||||||
|
|
||||||
|
else -> IO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same question for a request that went through the tunnel. The relay's
|
||||||
|
* own verdict (close code) wins; a certificate that does not match the
|
||||||
|
* pairing fingerprint is [PIN]; silence inside an open tunnel is [TIMEOUT];
|
||||||
|
* everything else is the network between the phone and turkservis.online.
|
||||||
|
*/
|
||||||
|
fun tunnelKind(error: Throwable): String {
|
||||||
|
AiturkTunnelException.find(error)?.let { return it.kind }
|
||||||
|
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
|
||||||
|
return when {
|
||||||
|
chain.any { it is CertificateException || it is javax.net.ssl.SSLPeerUnverifiedException } -> PIN
|
||||||
|
chain.any { it is InterruptedIOException } -> TIMEOUT
|
||||||
|
chain.any { it is SSLException } -> TLS
|
||||||
|
else -> NETWORK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `relay:<kind>`, except the pin failure which the contract names `tls:pin`. */
|
||||||
|
fun relayCode(kind: String): String = if (kind == PIN) "tls:$PIN" else "relay:$kind"
|
||||||
|
|
||||||
|
private const val MAX_CODE_LENGTH = 96
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone side of the AITURK IDE pairing handshake.
|
||||||
|
*
|
||||||
|
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
|
||||||
|
* Contract: v1 (`aiturk-mobil-eslestirme-spec`) plus v3 (`aiturk-mobil-tunel-spec`).
|
||||||
|
*/
|
||||||
|
class AiturkPairingClient(
|
||||||
|
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
|
||||||
|
private val baseClient: OkHttpClient = defaultBaseClient,
|
||||||
|
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
|
||||||
|
private val relayHelloTimeoutMs: Long = RELAY_HELLO_TIMEOUT_MS,
|
||||||
|
private val tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
|
||||||
|
) {
|
||||||
|
/** Code path: `resolve` the rendezvous record for a normalized code. */
|
||||||
|
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val body =
|
||||||
|
buildJsonObject { put("lookup", AiturkPairing.lookup(normalizedCode)) }
|
||||||
|
.toString()
|
||||||
|
.toRequestBody(JSON)
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(under(gatewayBaseUrl, RESOLVE_PATH))
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
val client =
|
||||||
|
baseClient
|
||||||
|
.newBuilder()
|
||||||
|
.callTimeout(GATEWAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).await().use { response ->
|
||||||
|
when (response.code) {
|
||||||
|
200 -> parseOffer(readJson(response))
|
||||||
|
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND, "resolve:not_found")
|
||||||
|
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED, "resolve:rate_limited")
|
||||||
|
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:http_${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:network")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tunnel first, LAN second, and exactly ONE `claim`:
|
||||||
|
* 1. [AiturkPairingOffer.relay] present → `hello` to the virtual tunnel host
|
||||||
|
* (TLS inside the tunnel, pinned to the offer's fingerprint). Answered →
|
||||||
|
* the single `claim` goes through the tunnel and the LAN candidates are
|
||||||
|
* never contacted.
|
||||||
|
* 2. Relay absent or failed → `hello` to every LAN candidate in parallel
|
||||||
|
* (same pin), single `claim` to the first answer.
|
||||||
|
*
|
||||||
|
* [lanAllowed] false (Android 17+ local-network permission denied) skips step
|
||||||
|
* 2 and reports `lan:permission`. When both steps fail the relay's failure is
|
||||||
|
* the user-facing error and both codes are kept (`relay:…,lan:…`).
|
||||||
|
*/
|
||||||
|
suspend fun pair(
|
||||||
|
offer: AiturkPairingOffer,
|
||||||
|
normalizedCode: String,
|
||||||
|
deviceName: String,
|
||||||
|
lanAllowed: Boolean = true,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
var relayFailure: AiturkPairResult.Failure? = null
|
||||||
|
// The only trust on this client is the offer's fingerprint: on the
|
||||||
|
// LAN and inside the tunnel alike.
|
||||||
|
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint, tunnel)
|
||||||
|
// Validated again here: an offer built by hand must not widen the target.
|
||||||
|
val relayId = offer.relay?.let(AiturkRelay::parse)
|
||||||
|
if (relayId != null) {
|
||||||
|
val relayBase = AiturkRelay.baseUrl(relayId).toHttpUrl()
|
||||||
|
when (val hello = relayHello(pinned, relayBase)) {
|
||||||
|
is Hello.Ok -> {
|
||||||
|
if (!hello.pairingOpen) {
|
||||||
|
return@withContext AiturkPairResult.Failure(
|
||||||
|
AiturkPairError.PAIRING_CLOSED,
|
||||||
|
"relay:${AiturkDiag.PAIRING_CLOSED}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@withContext claim(
|
||||||
|
pinned,
|
||||||
|
relayBase,
|
||||||
|
AiturkRelay.hostPort(relayId),
|
||||||
|
null,
|
||||||
|
offer.fingerprint,
|
||||||
|
normalizedCode,
|
||||||
|
deviceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Hello.Failed -> {
|
||||||
|
relayFailure = AiturkPairResult.Failure(relayError(hello.kind), AiturkDiag.relayCode(hello.kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lan = pairOverLan(pinned, offer, normalizedCode, deviceName, lanAllowed)
|
||||||
|
if (lan is AiturkPairResult.Failure && relayFailure != null && lan.code.startsWith("lan:")) {
|
||||||
|
// Nothing was claimed on the LAN either: the relay is the designed
|
||||||
|
// path, so its failure is what the user needs to hear about.
|
||||||
|
return@withContext AiturkPairResult.Failure(relayFailure.error, "${relayFailure.code},${lan.code}")
|
||||||
|
}
|
||||||
|
lan
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pairOverLan(
|
||||||
|
pinned: OkHttpClient,
|
||||||
|
offer: AiturkPairingOffer,
|
||||||
|
normalizedCode: String,
|
||||||
|
deviceName: String,
|
||||||
|
lanAllowed: Boolean,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> {
|
||||||
|
if (offer.endpoints.isEmpty()) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE, "lan:${AiturkDiag.NO_CANDIDATES}")
|
||||||
|
}
|
||||||
|
if (!lanAllowed) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.LAN_PERMISSION, "lan:${AiturkDiag.PERMISSION}")
|
||||||
|
}
|
||||||
|
val reachable =
|
||||||
|
when (val hello = firstReachable(pinned, offer.endpoints)) {
|
||||||
|
is Hello.Ok -> hello
|
||||||
|
is Hello.Failed -> return AiturkPairResult.Failure(lanError(hello.kind), "lan:${hello.kind}")
|
||||||
|
}
|
||||||
|
if (!reachable.pairingOpen) {
|
||||||
|
// The computer says pairing is not open: a claim could only be
|
||||||
|
// answered with 410 pairing_closed, so do not spend an attempt.
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED, "lan:${AiturkDiag.PAIRING_CLOSED}")
|
||||||
|
}
|
||||||
|
val endpoint = checkNotNull(reachable.endpoint)
|
||||||
|
return claim(
|
||||||
|
pinned,
|
||||||
|
endpoint.baseUrl.toHttpUrl(),
|
||||||
|
endpoint.hostPort,
|
||||||
|
endpoint,
|
||||||
|
offer.fingerprint,
|
||||||
|
normalizedCode,
|
||||||
|
deviceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed interface Hello {
|
||||||
|
data class Ok(
|
||||||
|
val endpoint: AiturkEndpoint?,
|
||||||
|
val pairingOpen: Boolean,
|
||||||
|
) : Hello
|
||||||
|
|
||||||
|
/** [kind] is one of the [AiturkDiag] words or `http_<status>`. */
|
||||||
|
data class Failed(
|
||||||
|
val kind: String,
|
||||||
|
) : Hello
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun relayHello(
|
||||||
|
pinned: OkHttpClient,
|
||||||
|
relayBase: HttpUrl,
|
||||||
|
): Hello {
|
||||||
|
val client =
|
||||||
|
pinned
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
return withTimeoutOrNull(relayHelloTimeoutMs + HELLO_MARGIN_MS) { hello(client, relayBase, null) }
|
||||||
|
?: Hello.Failed(AiturkDiag.TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun firstReachable(
|
||||||
|
pinned: OkHttpClient,
|
||||||
|
endpoints: List<AiturkEndpoint>,
|
||||||
|
): Hello {
|
||||||
|
val helloClient =
|
||||||
|
pinned
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
val failures = CopyOnWriteArrayList<String>()
|
||||||
|
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
|
||||||
|
val winner =
|
||||||
|
withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
|
||||||
|
coroutineScope {
|
||||||
|
val answers = Channel<Hello>(endpoints.size)
|
||||||
|
endpoints.forEach { endpoint ->
|
||||||
|
launch { answers.send(hello(helloClient, endpoint.baseUrl.toHttpUrl(), endpoint)) }
|
||||||
|
}
|
||||||
|
var ok: Hello.Ok? = null
|
||||||
|
var received = 0
|
||||||
|
while (ok == null && received < endpoints.size) {
|
||||||
|
when (val answer = answers.receive()) {
|
||||||
|
is Hello.Ok -> ok = answer
|
||||||
|
is Hello.Failed -> failures += answer.kind
|
||||||
|
}
|
||||||
|
received++
|
||||||
|
}
|
||||||
|
coroutineContext.cancelChildren()
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (winner != null) return winner
|
||||||
|
// Candidates still silent when the window closed count as timeouts.
|
||||||
|
if (failures.size < endpoints.size) failures += AiturkDiag.TIMEOUT
|
||||||
|
return Hello.Failed(mostTelling(failures))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun hello(
|
||||||
|
client: OkHttpClient,
|
||||||
|
base: HttpUrl,
|
||||||
|
endpoint: AiturkEndpoint?,
|
||||||
|
): Hello =
|
||||||
|
try {
|
||||||
|
val request = Request.Builder().url(under(base, HELLO_PATH)).get().build()
|
||||||
|
client.newCall(request).await().use { response ->
|
||||||
|
val json = readJson(response)
|
||||||
|
when {
|
||||||
|
response.code != 200 -> {
|
||||||
|
Hello.Failed("http_${response.code}")
|
||||||
|
}
|
||||||
|
|
||||||
|
json?.string("app") != IDE_APP_ID -> {
|
||||||
|
Hello.Failed(AiturkDiag.BAD_RESPONSE)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Hello.Ok(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Hello.Failed(if (endpoint == null) AiturkDiag.tunnelKind(e) else AiturkDiag.networkKind(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun claim(
|
||||||
|
client: OkHttpClient,
|
||||||
|
base: HttpUrl,
|
||||||
|
hostPort: String,
|
||||||
|
endpoint: AiturkEndpoint?,
|
||||||
|
fingerprint: String,
|
||||||
|
normalizedCode: String,
|
||||||
|
deviceName: String,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> {
|
||||||
|
val viaRelay = endpoint == null
|
||||||
|
val body =
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", normalizedCode)
|
||||||
|
put("device_name", sanitizeDeviceName(deviceName))
|
||||||
|
}.toString().toRequestBody(JSON)
|
||||||
|
val request = Request.Builder().url(under(base, CLAIM_PATH)).post(body).build()
|
||||||
|
// The code is single-use and wrong attempts are counted: never let the
|
||||||
|
// HTTP stack silently replay this POST.
|
||||||
|
val claimClient =
|
||||||
|
client
|
||||||
|
.newBuilder()
|
||||||
|
.retryOnConnectionFailure(false)
|
||||||
|
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
claimClient.newCall(request).await().use { response ->
|
||||||
|
val json = readJson(response)
|
||||||
|
val errorName = json?.string("error")
|
||||||
|
when {
|
||||||
|
response.code == 200 -> {
|
||||||
|
parseClaim(json, base, hostPort, endpoint, fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val error = claimError(response.code, errorName)
|
||||||
|
AiturkPairResult.Failure(error, "claim:${claimCode(error, response.code)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// The claim may or may not have reached the computer: it is never
|
||||||
|
// retried and never re-sent over the other path.
|
||||||
|
if (viaRelay) {
|
||||||
|
val kind = AiturkDiag.tunnelKind(e)
|
||||||
|
AiturkPairResult.Failure(relayError(kind), "claim:relay_$kind")
|
||||||
|
} else {
|
||||||
|
val kind = AiturkDiag.networkKind(e)
|
||||||
|
AiturkPairResult.Failure(lanError(kind), "claim:lan_$kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseClaim(
|
||||||
|
json: JsonObject?,
|
||||||
|
base: HttpUrl,
|
||||||
|
hostPort: String,
|
||||||
|
endpoint: AiturkEndpoint?,
|
||||||
|
fingerprint: String,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> {
|
||||||
|
val token = json?.string("token")
|
||||||
|
if (token == null || !TOKEN_PATTERN.matches(token)) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "claim:${AiturkDiag.BAD_RESPONSE}")
|
||||||
|
}
|
||||||
|
return AiturkPairResult.Success(
|
||||||
|
AiturkPairedDevice(
|
||||||
|
baseUrl = base.toString(),
|
||||||
|
hostPort = hostPort,
|
||||||
|
endpoint = endpoint,
|
||||||
|
fingerprint = fingerprint,
|
||||||
|
token = token,
|
||||||
|
deviceId = json.string("device_id").orEmpty(),
|
||||||
|
pcName = json.string("pc_name").orEmpty().trim().take(MAX_NAME_LENGTH),
|
||||||
|
version = json.string("version").orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
|
||||||
|
val bad = AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:${AiturkDiag.BAD_RESPONSE}")
|
||||||
|
val fingerprint = json?.string("fp")
|
||||||
|
if (json == null || fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint)) return bad
|
||||||
|
// The rendezvous record is untrusted input: only private IPv4 targets
|
||||||
|
// (0..4) and, optionally, a lowercase UUIDv4 relay id.
|
||||||
|
val urls =
|
||||||
|
when (val raw = json["urls"]) {
|
||||||
|
null, is JsonNull -> emptyList()
|
||||||
|
is JsonArray -> raw.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||||
|
else -> return bad
|
||||||
|
}
|
||||||
|
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
|
||||||
|
if (endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) return bad
|
||||||
|
val relay =
|
||||||
|
when (val raw = json["relay"]) {
|
||||||
|
null, is JsonNull -> null
|
||||||
|
is JsonPrimitive -> raw.takeIf { it.isString }?.content?.let(AiturkRelay::parse) ?: return bad
|
||||||
|
else -> return bad
|
||||||
|
}
|
||||||
|
if (endpoints.isEmpty() && relay == null) return bad
|
||||||
|
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint, relay))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [path] under [base], keeping whatever path prefix the base carries (the relay's). */
|
||||||
|
private fun under(
|
||||||
|
base: HttpUrl,
|
||||||
|
path: String,
|
||||||
|
): HttpUrl = base.newBuilder().addPathSegments(path).build()
|
||||||
|
|
||||||
|
private fun readJson(response: Response): JsonObject? =
|
||||||
|
runCatching {
|
||||||
|
val buffer = Buffer()
|
||||||
|
response.body.source().use { source ->
|
||||||
|
while (buffer.size < MAX_BODY_BYTES && source.read(buffer, MAX_BODY_BYTES - buffer.size) != -1L) {
|
||||||
|
// keep reading up to the cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Json.parseToJsonElement(buffer.readUtf8()) as? JsonObject
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_GATEWAY = "https://turkservis.online/"
|
||||||
|
const val HELLO_TIMEOUT_MS = 2_500L
|
||||||
|
const val RELAY_HELLO_TIMEOUT_MS = 10_000L
|
||||||
|
const val MAX_NAME_LENGTH = 60
|
||||||
|
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
|
||||||
|
private const val HELLO_PATH = "aiturk/pair/hello"
|
||||||
|
private const val CLAIM_PATH = "aiturk/pair/claim"
|
||||||
|
private const val IDE_APP_ID = "aiturk-ide"
|
||||||
|
private const val HELLO_MARGIN_MS = 500L
|
||||||
|
private const val CLAIM_TIMEOUT_MS = 15_000L
|
||||||
|
private const val GATEWAY_TIMEOUT_MS = 15_000L
|
||||||
|
private const val MAX_BODY_BYTES = 16L * 1024
|
||||||
|
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
|
||||||
|
|
||||||
|
private val LAN_KIND_PRIORITY =
|
||||||
|
listOf(
|
||||||
|
AiturkDiag.PERMISSION,
|
||||||
|
AiturkDiag.TLS,
|
||||||
|
AiturkDiag.BAD_RESPONSE,
|
||||||
|
AiturkDiag.REFUSED,
|
||||||
|
AiturkDiag.UNREACHABLE,
|
||||||
|
AiturkDiag.TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bare client on purpose: no cookie jar, no user-configured custom
|
||||||
|
// headers and no Authorization stamping reach the gateway or `claim`.
|
||||||
|
private val defaultBaseClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeDeviceName(raw: String): String =
|
||||||
|
raw
|
||||||
|
.filterNot { it.isISOControl() }
|
||||||
|
.trim()
|
||||||
|
.take(MAX_NAME_LENGTH)
|
||||||
|
.ifBlank { "Android" }
|
||||||
|
|
||||||
|
/** Tunnel `hello`/`claim` failure kind → what the user is told (contract v3 §3). */
|
||||||
|
internal fun relayError(kind: String): AiturkPairError =
|
||||||
|
when (kind) {
|
||||||
|
AiturkTunnelException.HOST_OFFLINE -> AiturkPairError.HOST_OFFLINE
|
||||||
|
AiturkTunnelException.UNKNOWN -> AiturkPairError.RELAY_UNKNOWN
|
||||||
|
AiturkTunnelException.BUSY -> AiturkPairError.RELAY_BUSY
|
||||||
|
AiturkTunnelException.TIMEOUT -> AiturkPairError.HOST_TIMEOUT
|
||||||
|
AiturkTunnelException.NETWORK -> AiturkPairError.NO_INTERNET
|
||||||
|
AiturkDiag.PIN -> AiturkPairError.TLS_PIN
|
||||||
|
else -> AiturkPairError.RELAY_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LAN failure kind → what the user is told. */
|
||||||
|
internal fun lanError(kind: String): AiturkPairError =
|
||||||
|
if (kind == AiturkDiag.PERMISSION) AiturkPairError.LAN_PERMISSION else AiturkPairError.PC_UNREACHABLE
|
||||||
|
|
||||||
|
/** Of several failed LAN candidates, the one that says most about the cause. */
|
||||||
|
internal fun mostTelling(kinds: List<String>): String =
|
||||||
|
LAN_KIND_PRIORITY.firstOrNull { it in kinds }
|
||||||
|
?: kinds.firstOrNull { it.startsWith("http_") }
|
||||||
|
?: kinds.firstOrNull()
|
||||||
|
?: AiturkDiag.TIMEOUT
|
||||||
|
|
||||||
|
private fun claimCode(
|
||||||
|
error: AiturkPairError,
|
||||||
|
httpCode: Int,
|
||||||
|
): String =
|
||||||
|
when (error) {
|
||||||
|
AiturkPairError.INVALID_REQUEST -> "invalid_request"
|
||||||
|
AiturkPairError.WRONG_CODE -> "wrong_code"
|
||||||
|
AiturkPairError.PAIRING_CLOSED -> "pairing_closed"
|
||||||
|
AiturkPairError.PAIRING_LOCKED -> "pairing_locked"
|
||||||
|
else -> "http_$httpCode"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun claimError(
|
||||||
|
httpCode: Int,
|
||||||
|
errorName: String?,
|
||||||
|
): AiturkPairError =
|
||||||
|
when (errorName) {
|
||||||
|
"invalid_request" -> {
|
||||||
|
AiturkPairError.INVALID_REQUEST
|
||||||
|
}
|
||||||
|
|
||||||
|
"wrong_code" -> {
|
||||||
|
AiturkPairError.WRONG_CODE
|
||||||
|
}
|
||||||
|
|
||||||
|
"pairing_closed" -> {
|
||||||
|
AiturkPairError.PAIRING_CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
"pairing_locked" -> {
|
||||||
|
AiturkPairError.PAIRING_LOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
when (httpCode) {
|
||||||
|
400, 413 -> AiturkPairError.INVALID_REQUEST
|
||||||
|
401 -> AiturkPairError.WRONG_CODE
|
||||||
|
410 -> AiturkPairError.PAIRING_CLOSED
|
||||||
|
else -> AiturkPairError.UNEXPECTED_RESPONSE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
||||||
|
*
|
||||||
|
* QR / deep-link payload:
|
||||||
|
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
|
||||||
|
*
|
||||||
|
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
|
||||||
|
* sends by default; `u` (0..4) is only present when the user allowed direct
|
||||||
|
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
|
||||||
|
*/
|
||||||
|
data class AiturkEndpoint(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
) {
|
||||||
|
/** Key used by the certificate pin store. */
|
||||||
|
val hostPort: String get() = "$host:$port"
|
||||||
|
|
||||||
|
/** Canonical dashboard base URL (always HTTPS, trailing slash). */
|
||||||
|
val baseUrl: String get() = "https://$host:$port/"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pairing offer: where the IDE listens and which certificate it must present
|
||||||
|
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
|
||||||
|
*/
|
||||||
|
data class AiturkPairingOffer(
|
||||||
|
val endpoints: List<AiturkEndpoint>,
|
||||||
|
val fingerprint: String,
|
||||||
|
val relay: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AiturkPairingPayload(
|
||||||
|
val offer: AiturkPairingOffer,
|
||||||
|
val code: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AiturkPayloadError {
|
||||||
|
/** Not an `aiturk://pair` link at all. */
|
||||||
|
NOT_AITURK,
|
||||||
|
|
||||||
|
/** `v` is present but not 1: the app is too old for this QR. */
|
||||||
|
UNSUPPORTED_VERSION,
|
||||||
|
|
||||||
|
/** Missing or malformed field (`v`, `u`, `fp`, `c`). */
|
||||||
|
MALFORMED,
|
||||||
|
|
||||||
|
/** `fp` missing or not a 43-char base64url SHA-256. */
|
||||||
|
BAD_FINGERPRINT,
|
||||||
|
|
||||||
|
/** A `u` entry is not `<private IPv4>:<port>`. */
|
||||||
|
BAD_ADDRESS,
|
||||||
|
|
||||||
|
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
|
||||||
|
BAD_ADDRESS_COUNT,
|
||||||
|
|
||||||
|
/** `r` is present but is not a lowercase UUIDv4 relay id. */
|
||||||
|
BAD_RELAY,
|
||||||
|
|
||||||
|
/** `c` is not 8 characters of the pairing alphabet. */
|
||||||
|
BAD_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface AiturkPayloadResult {
|
||||||
|
data class Ok(
|
||||||
|
val payload: AiturkPairingPayload,
|
||||||
|
) : AiturkPayloadResult
|
||||||
|
|
||||||
|
data class Invalid(
|
||||||
|
val error: AiturkPayloadError,
|
||||||
|
) : AiturkPayloadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
object AiturkPairing {
|
||||||
|
const val SCHEME = "aiturk"
|
||||||
|
const val HOST = "pair"
|
||||||
|
const val VERSION = "1"
|
||||||
|
const val MAX_ENDPOINTS = 4
|
||||||
|
const val CODE_LENGTH = 8
|
||||||
|
const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
const val FINGERPRINT_LENGTH = 43
|
||||||
|
const val LOOKUP_PREFIX = "aiturk-mobile-pair-v1:"
|
||||||
|
|
||||||
|
private val fingerprintPattern = Regex("^[A-Za-z0-9_-]{43}$")
|
||||||
|
private val endpointPattern = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$""")
|
||||||
|
|
||||||
|
/** Quick check used by the deep-link entry point. */
|
||||||
|
fun looksLikePairingLink(raw: String?): Boolean =
|
||||||
|
raw != null && raw.trim().startsWith("$SCHEME://$HOST", ignoreCase = true)
|
||||||
|
|
||||||
|
fun parse(raw: String): AiturkPayloadResult {
|
||||||
|
val uri =
|
||||||
|
runCatching { URI(raw.trim()) }.getOrNull()
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||||
|
if (!SCHEME.equals(uri.scheme, ignoreCase = true) || !HOST.equals(uri.host, ignoreCase = true)) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||||
|
}
|
||||||
|
val params =
|
||||||
|
runCatching { parseQuery(uri.rawQuery.orEmpty()) }.getOrNull()
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||||
|
|
||||||
|
fun single(name: String): String? = params.filter { it.first == name }.map { it.second }.singleOrNull()
|
||||||
|
|
||||||
|
// Version first: a newer QR may carry fields this build cannot judge.
|
||||||
|
val version = single("v") ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||||
|
if (version != VERSION) return AiturkPayloadResult.Invalid(AiturkPayloadError.UNSUPPORTED_VERSION)
|
||||||
|
|
||||||
|
val fingerprint = single("fp")
|
||||||
|
if (fingerprint == null || !isValidFingerprint(fingerprint)) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
|
||||||
|
// never silently dropped in favour of the LAN candidates.
|
||||||
|
val rawRelays = params.filter { it.first == "r" }.map { it.second }
|
||||||
|
val relay =
|
||||||
|
when (rawRelays.size) {
|
||||||
|
0 -> null
|
||||||
|
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||||
|
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
|
||||||
|
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
|
||||||
|
}
|
||||||
|
val endpoints =
|
||||||
|
rawEndpoints.map {
|
||||||
|
parseEndpoint(it) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val code =
|
||||||
|
single("c")?.let(::normalizeCode)
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_CODE)
|
||||||
|
|
||||||
|
return AiturkPayloadResult.Ok(
|
||||||
|
AiturkPairingPayload(
|
||||||
|
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
|
||||||
|
code = code,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper-cases (locale-independent), strips whitespace and `-`, and returns the
|
||||||
|
* code only when it is exactly 8 characters of [CODE_ALPHABET]; null otherwise.
|
||||||
|
*/
|
||||||
|
fun normalizeCode(raw: String): String? {
|
||||||
|
val stripped = raw.uppercase(Locale.ROOT).filterNot { it.isWhitespace() || it == '-' }
|
||||||
|
return stripped.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps only alphabet characters (upper-cased), capped at 8 — for the text field. */
|
||||||
|
fun cleanCodeInput(raw: String): String =
|
||||||
|
raw
|
||||||
|
.uppercase(Locale.ROOT)
|
||||||
|
.filter { it in CODE_ALPHABET }
|
||||||
|
.take(CODE_LENGTH)
|
||||||
|
|
||||||
|
/** `hex(SHA-256("aiturk-mobile-pair-v1:" + CODE))` — the raw code never leaves the phone. */
|
||||||
|
fun lookup(normalizedCode: String): String {
|
||||||
|
val digest =
|
||||||
|
MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest((LOOKUP_PREFIX + normalizedCode).toByteArray(Charsets.US_ASCII))
|
||||||
|
return digest.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidFingerprint(value: String): Boolean = fingerprintPattern.matches(value) && decodeFingerprint(value) != null
|
||||||
|
|
||||||
|
/** The 32 SHA-256 bytes behind a base64url (unpadded) fingerprint, or null. */
|
||||||
|
fun decodeFingerprint(value: String): ByteArray? =
|
||||||
|
runCatching { Base64.getUrlDecoder().decode(value) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.size == 32 }
|
||||||
|
|
||||||
|
/** `<private IPv4>:<port>`; no DNS names, no leading zeros, no public ranges. */
|
||||||
|
fun parseEndpoint(raw: String): AiturkEndpoint? {
|
||||||
|
val match = endpointPattern.matchEntire(raw) ?: return null
|
||||||
|
val parts = match.groupValues.drop(1)
|
||||||
|
if (parts.any { it.length > 1 && it.startsWith('0') }) return null
|
||||||
|
val octets = parts.take(4).map { it.toInt() }
|
||||||
|
if (octets.any { it > 255 }) return null
|
||||||
|
val port = parts[4].toInt()
|
||||||
|
if (port !in 1..65535) return null
|
||||||
|
val (a, b) = octets
|
||||||
|
val isPrivate = a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168)
|
||||||
|
if (!isPrivate) return null
|
||||||
|
return AiturkEndpoint(octets.joinToString("."), port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway `resolve` URLs are `https://<private IPv4>:<port>` (optional trailing slash). */
|
||||||
|
fun parseEndpointUrl(raw: String): AiturkEndpoint? {
|
||||||
|
if (!raw.startsWith("https://")) return null
|
||||||
|
return parseEndpoint(raw.removePrefix("https://").removeSuffix("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuery(rawQuery: String): List<Pair<String, String>> =
|
||||||
|
rawQuery
|
||||||
|
.split('&')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.map { part ->
|
||||||
|
val index = part.indexOf('=')
|
||||||
|
require(index > 0) { "Query parameter without a value" }
|
||||||
|
decode(part.substring(0, index)) to decode(part.substring(index + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
||||||
|
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
|
||||||
|
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
|
||||||
|
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
|
||||||
|
* internet; [AiturkTunnel] turns connections to it into an end-to-end
|
||||||
|
* encrypted stream through the relay.
|
||||||
|
*/
|
||||||
|
object AiturkRelay {
|
||||||
|
const val HOST_SUFFIX = ".relay.aiturk.invalid"
|
||||||
|
const val PORT = 443
|
||||||
|
|
||||||
|
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||||
|
private val relayId = Regex(UUID4)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
|
||||||
|
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
|
||||||
|
* surrounding whitespace.
|
||||||
|
*/
|
||||||
|
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
|
||||||
|
|
||||||
|
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
|
||||||
|
|
||||||
|
/** Key used by the certificate pin store for a relay pairing. */
|
||||||
|
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
|
||||||
|
|
||||||
|
/** The saved connection base: no path prefix, trailing slash. */
|
||||||
|
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
|
||||||
|
|
||||||
|
/** The relay id behind a virtual host name, or null for any other host. */
|
||||||
|
fun relayIdOfHost(host: String?): String? =
|
||||||
|
host
|
||||||
|
?.takeIf { it.endsWith(HOST_SUFFIX) }
|
||||||
|
?.removeSuffix(HOST_SUFFIX)
|
||||||
|
?.let(::parse)
|
||||||
|
|
||||||
|
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
|
||||||
|
}
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import okhttp3.Dns
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Proxy
|
||||||
|
import java.net.ProxySelector
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.SocketAddress
|
||||||
|
import java.net.SocketException
|
||||||
|
import java.net.SocketImpl
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.URI
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.SocketFactory
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end encrypted tunnel to a paired AITURK IDE through turkservis.online
|
||||||
|
* (contract v3, `aiturk-mobil-tunel-spec`).
|
||||||
|
*
|
||||||
|
* The relay only carries bytes. A paired relay connection has the virtual base
|
||||||
|
* address `https://<relayId>.relay.aiturk.invalid` (no path prefix). For such a
|
||||||
|
* host — and for no other — the shared OkHttp client:
|
||||||
|
* - resolves the name to a fixed placeholder address ([AiturkTunnelDns]);
|
||||||
|
* - opens `wss://turkservis.online/v1/aiturk/mobile-relay/t/<relayId>` instead
|
||||||
|
* of a TCP connection and exposes it as a socket ([AiturkTunnelSocket]);
|
||||||
|
* - runs TLS ON TOP of that socket, pinned to the IDE's certificate
|
||||||
|
* fingerprint ([AiturkLanTrust]). turkservis.online and anything in front of
|
||||||
|
* it see ciphertext only: no device token, path, chat text or file.
|
||||||
|
*/
|
||||||
|
object AiturkTunnel {
|
||||||
|
const val DEFAULT_RELAY_WS = "wss://turkservis.online/v1/aiturk/mobile-relay/t/"
|
||||||
|
const val MAX_FRAME_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
/** How the byte stream is opened. Replaced by JVM tests with a local fake relay. */
|
||||||
|
@Volatile
|
||||||
|
internal var connector: AiturkTunnelConnector = WebSocketTunnelConnector()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single hook for an OkHttp builder. Every host that is not a virtual
|
||||||
|
* relay host keeps the platform's DNS, plain TCP sockets and proxy choice.
|
||||||
|
*/
|
||||||
|
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder = applyTo(builder) { connector }
|
||||||
|
|
||||||
|
internal fun applyTo(
|
||||||
|
builder: OkHttpClient.Builder,
|
||||||
|
connector: () -> AiturkTunnelConnector,
|
||||||
|
): OkHttpClient.Builder =
|
||||||
|
builder
|
||||||
|
.dns(AiturkTunnelDns())
|
||||||
|
.socketFactory(AiturkTunnelSocketFactory(connector))
|
||||||
|
.proxySelector(AiturkTunnelProxySelector())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a tunnel stream could not be opened or ended. [kind] is one of the fixed
|
||||||
|
* words below (it becomes the `relay:<kind>` diagnostic code).
|
||||||
|
*/
|
||||||
|
class AiturkTunnelException(
|
||||||
|
val kind: String,
|
||||||
|
val closeCode: Int? = null,
|
||||||
|
cause: Throwable? = null,
|
||||||
|
) : IOException("AITURK tunnel: $kind", cause) {
|
||||||
|
companion object {
|
||||||
|
const val HOST_OFFLINE = "host_offline"
|
||||||
|
const val UNKNOWN = "unknown"
|
||||||
|
const val BUSY = "busy"
|
||||||
|
const val TIMEOUT = "timeout"
|
||||||
|
const val OVERFLOW = "overflow"
|
||||||
|
const val NETWORK = "network"
|
||||||
|
const val REJECTED = "rejected"
|
||||||
|
const val CLOSED = "closed"
|
||||||
|
|
||||||
|
/** Relay close code → kind; null for a normal end of stream. */
|
||||||
|
fun kindForCloseCode(code: Int): String? =
|
||||||
|
when (code) {
|
||||||
|
1000 -> null
|
||||||
|
4503 -> HOST_OFFLINE
|
||||||
|
4404 -> UNKNOWN
|
||||||
|
4429 -> BUSY
|
||||||
|
4504 -> TIMEOUT
|
||||||
|
4408 -> OVERFLOW
|
||||||
|
4400, 4403 -> REJECTED
|
||||||
|
else -> CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun find(error: Throwable?): AiturkTunnelException? =
|
||||||
|
generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }
|
||||||
|
.take(MAX_CAUSE_DEPTH)
|
||||||
|
.filterIsInstance<AiturkTunnelException>()
|
||||||
|
.firstOrNull()
|
||||||
|
|
||||||
|
private const val MAX_CAUSE_DEPTH = 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An open byte stream to the IDE's TLS proxy. Blocking, like a socket. */
|
||||||
|
interface AiturkTunnelChannel {
|
||||||
|
val input: InputStream
|
||||||
|
val output: OutputStream
|
||||||
|
var soTimeoutMs: Int
|
||||||
|
val isClosed: Boolean
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun interface AiturkTunnelConnector {
|
||||||
|
/** Blocks until the stream is open; throws [AiturkTunnelException] otherwise. */
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun open(
|
||||||
|
relayId: String,
|
||||||
|
timeoutMs: Int,
|
||||||
|
): AiturkTunnelChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `*.relay.aiturk.invalid` → placeholder address (hostname kept); everything else → [delegate]. */
|
||||||
|
class AiturkTunnelDns(
|
||||||
|
private val delegate: Dns = Dns.SYSTEM,
|
||||||
|
) : Dns {
|
||||||
|
override fun lookup(hostname: String): List<InetAddress> =
|
||||||
|
if (AiturkRelay.isVirtualHost(hostname)) {
|
||||||
|
listOf(InetAddress.getByAddress(hostname, PLACEHOLDER))
|
||||||
|
} else {
|
||||||
|
delegate.lookup(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// TEST-NET-1 (RFC 5737): never routed, and never dialled by the tunnel socket.
|
||||||
|
internal val PLACEHOLDER = byteArrayOf(192.toByte(), 0, 2, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Virtual relay hosts are never sent through a proxy; other hosts use the system selector. */
|
||||||
|
class AiturkTunnelProxySelector : ProxySelector() {
|
||||||
|
override fun select(uri: URI?): List<Proxy> {
|
||||||
|
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return listOf(Proxy.NO_PROXY)
|
||||||
|
return runCatching { ProxySelector.getDefault()?.select(uri) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: listOf(Proxy.NO_PROXY)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun connectFailed(
|
||||||
|
uri: URI?,
|
||||||
|
sa: SocketAddress?,
|
||||||
|
ioe: IOException?,
|
||||||
|
) {
|
||||||
|
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return
|
||||||
|
runCatching { ProxySelector.getDefault()?.connectFailed(uri, sa, ioe) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OkHttp asks for an unconnected socket and only then names the peer, so the
|
||||||
|
* socket decides in `connect()`: a virtual relay host becomes a tunnel stream,
|
||||||
|
* any other address is an ordinary TCP connection made by [java.net.Socket]
|
||||||
|
* itself.
|
||||||
|
*/
|
||||||
|
class AiturkTunnelSocketFactory internal constructor(
|
||||||
|
private val connector: () -> AiturkTunnelConnector,
|
||||||
|
) : SocketFactory() {
|
||||||
|
override fun createSocket(): Socket = AiturkTunnelSocket(connector)
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
localHost: InetAddress?,
|
||||||
|
localPort: Int,
|
||||||
|
): Socket = createSocket(host, port)
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: InetAddress?,
|
||||||
|
port: Int,
|
||||||
|
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
address: InetAddress?,
|
||||||
|
port: Int,
|
||||||
|
localAddress: InetAddress?,
|
||||||
|
localPort: Int,
|
||||||
|
): Socket = createSocket(address, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plain TCP socket unless it is connected to a virtual relay host. */
|
||||||
|
class AiturkTunnelSocket internal constructor(
|
||||||
|
private val connector: () -> AiturkTunnelConnector,
|
||||||
|
) : Socket() {
|
||||||
|
@Volatile
|
||||||
|
internal var channel: AiturkTunnelChannel? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var tunnelPeer: InetSocketAddress? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var requestedSoTimeout = 0
|
||||||
|
|
||||||
|
override fun connect(endpoint: SocketAddress?) = connect(endpoint, 0)
|
||||||
|
|
||||||
|
override fun connect(
|
||||||
|
endpoint: SocketAddress?,
|
||||||
|
timeout: Int,
|
||||||
|
) {
|
||||||
|
val peer = endpoint as? InetSocketAddress
|
||||||
|
val relayId = peer?.hostString?.let(AiturkRelay::relayIdOfHost)
|
||||||
|
if (peer == null || relayId == null) {
|
||||||
|
super.connect(endpoint, timeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (channel != null) throw SocketException("Already connected")
|
||||||
|
val opened = connector().open(relayId, if (timeout > 0) timeout else DEFAULT_CONNECT_TIMEOUT_MS)
|
||||||
|
opened.soTimeoutMs = requestedSoTimeout
|
||||||
|
tunnelPeer = peer
|
||||||
|
channel = opened
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getInputStream(): InputStream = channel?.input ?: super.getInputStream()
|
||||||
|
|
||||||
|
override fun getOutputStream(): OutputStream = channel?.output ?: super.getOutputStream()
|
||||||
|
|
||||||
|
override fun setSoTimeout(timeout: Int) {
|
||||||
|
requestedSoTimeout = timeout
|
||||||
|
val tunnel = channel
|
||||||
|
if (tunnel != null) tunnel.soTimeoutMs = timeout else super.setSoTimeout(timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSoTimeout(): Int = channel?.soTimeoutMs ?: super.getSoTimeout()
|
||||||
|
|
||||||
|
override fun setTcpNoDelay(on: Boolean) {
|
||||||
|
if (channel == null) super.setTcpNoDelay(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setKeepAlive(on: Boolean) {
|
||||||
|
if (channel == null) super.setKeepAlive(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isConnected(): Boolean = channel != null || super.isConnected()
|
||||||
|
|
||||||
|
override fun isClosed(): Boolean = channel?.isClosed ?: super.isClosed()
|
||||||
|
|
||||||
|
override fun isInputShutdown(): Boolean = if (channel != null) false else super.isInputShutdown()
|
||||||
|
|
||||||
|
override fun isOutputShutdown(): Boolean = if (channel != null) false else super.isOutputShutdown()
|
||||||
|
|
||||||
|
override fun getInetAddress(): InetAddress? = tunnelPeer?.address ?: super.getInetAddress()
|
||||||
|
|
||||||
|
override fun getPort(): Int = tunnelPeer?.port ?: super.getPort()
|
||||||
|
|
||||||
|
override fun getRemoteSocketAddress(): SocketAddress? = tunnelPeer ?: super.getRemoteSocketAddress()
|
||||||
|
|
||||||
|
override fun shutdownInput() {
|
||||||
|
if (channel == null) super.shutdownInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shutdownOutput() {
|
||||||
|
if (channel == null) super.shutdownOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
channel?.close()
|
||||||
|
super.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view of a tunnel that is handed to the TLS layer: a socket WITHOUT a
|
||||||
|
* platform socket implementation (and so without a file descriptor). Android's
|
||||||
|
* TLS provider then takes its stream-based path and reads/writes through
|
||||||
|
* [getInputStream] / [getOutputStream] instead of a native descriptor.
|
||||||
|
*/
|
||||||
|
internal class AiturkTunnelStreamSocket(
|
||||||
|
private val owner: AiturkTunnelSocket,
|
||||||
|
) : Socket(null as SocketImpl?) {
|
||||||
|
private val tunnel: AiturkTunnelChannel get() = owner.channel ?: throw SocketException("Socket is not connected")
|
||||||
|
|
||||||
|
override fun getInputStream(): InputStream = tunnel.input
|
||||||
|
|
||||||
|
override fun getOutputStream(): OutputStream = tunnel.output
|
||||||
|
|
||||||
|
override fun setSoTimeout(timeout: Int) = owner.setSoTimeout(timeout)
|
||||||
|
|
||||||
|
override fun getSoTimeout(): Int = owner.soTimeout
|
||||||
|
|
||||||
|
override fun isConnected(): Boolean = true
|
||||||
|
|
||||||
|
override fun isBound(): Boolean = true
|
||||||
|
|
||||||
|
override fun isClosed(): Boolean = owner.isClosed
|
||||||
|
|
||||||
|
override fun isInputShutdown(): Boolean = false
|
||||||
|
|
||||||
|
override fun isOutputShutdown(): Boolean = false
|
||||||
|
|
||||||
|
override fun getInetAddress(): InetAddress? = owner.inetAddress
|
||||||
|
|
||||||
|
override fun getPort(): Int = owner.port
|
||||||
|
|
||||||
|
override fun getRemoteSocketAddress(): SocketAddress? = owner.remoteSocketAddress
|
||||||
|
|
||||||
|
override fun getLocalAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0))
|
||||||
|
|
||||||
|
override fun getLocalPort(): Int = 0
|
||||||
|
|
||||||
|
override fun getLocalSocketAddress(): SocketAddress = InetSocketAddress(localAddress, 0)
|
||||||
|
|
||||||
|
override fun setTcpNoDelay(on: Boolean) = Unit
|
||||||
|
|
||||||
|
override fun getTcpNoDelay(): Boolean = true
|
||||||
|
|
||||||
|
override fun setKeepAlive(on: Boolean) = Unit
|
||||||
|
|
||||||
|
override fun getKeepAlive(): Boolean = false
|
||||||
|
|
||||||
|
override fun setSoLinger(
|
||||||
|
on: Boolean,
|
||||||
|
linger: Int,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun getSoLinger(): Int = -1
|
||||||
|
|
||||||
|
override fun setSendBufferSize(size: Int) = Unit
|
||||||
|
|
||||||
|
override fun getSendBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
|
||||||
|
|
||||||
|
override fun setReceiveBufferSize(size: Int) = Unit
|
||||||
|
|
||||||
|
override fun getReceiveBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
|
||||||
|
|
||||||
|
override fun shutdownInput() = Unit
|
||||||
|
|
||||||
|
override fun shutdownOutput() = Unit
|
||||||
|
|
||||||
|
override fun close() = owner.close()
|
||||||
|
|
||||||
|
override fun toString(): String = "AiturkTunnelStreamSocket"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a TLS socket factory so that TLS over a tunnel is layered on the
|
||||||
|
* descriptor-less [AiturkTunnelStreamSocket]. Every other socket is passed to
|
||||||
|
* [delegate] untouched.
|
||||||
|
*/
|
||||||
|
internal class AiturkTunnelSslSocketFactory(
|
||||||
|
private val delegate: SSLSocketFactory,
|
||||||
|
) : SSLSocketFactory() {
|
||||||
|
override fun createSocket(
|
||||||
|
s: Socket?,
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
autoClose: Boolean,
|
||||||
|
): Socket =
|
||||||
|
if (s is AiturkTunnelSocket && s.channel != null) {
|
||||||
|
// Closing the TLS socket must always close the tunnel stream.
|
||||||
|
delegate.createSocket(AiturkTunnelStreamSocket(s), host, port, true)
|
||||||
|
} else {
|
||||||
|
delegate.createSocket(s, host, port, autoClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
|
||||||
|
|
||||||
|
override fun getSupportedCipherSuites(): Array<String> = delegate.supportedCipherSuites
|
||||||
|
|
||||||
|
override fun createSocket(): Socket = delegate.createSocket()
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
): Socket = delegate.createSocket(host, port)
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
localHost: InetAddress?,
|
||||||
|
localPort: Int,
|
||||||
|
): Socket = delegate.createSocket(host, port, localHost, localPort)
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
host: InetAddress?,
|
||||||
|
port: Int,
|
||||||
|
): Socket = delegate.createSocket(host, port)
|
||||||
|
|
||||||
|
override fun createSocket(
|
||||||
|
address: InetAddress?,
|
||||||
|
port: Int,
|
||||||
|
localAddress: InetAddress?,
|
||||||
|
localPort: Int,
|
||||||
|
): Socket = delegate.createSocket(address, port, localAddress, localPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the stream as a WebSocket to the relay with a separate, bare OkHttp
|
||||||
|
* client: platform TLS, no cookie jar, no custom headers, no `Origin`.
|
||||||
|
* Binary frames are raw bytes in both directions.
|
||||||
|
*/
|
||||||
|
class WebSocketTunnelConnector(
|
||||||
|
private val relayWsBase: String = AiturkTunnel.DEFAULT_RELAY_WS,
|
||||||
|
client: OkHttpClient? = null,
|
||||||
|
) : AiturkTunnelConnector {
|
||||||
|
private val client: OkHttpClient by lazy {
|
||||||
|
client ?: OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.pingInterval(PING_SECONDS, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(
|
||||||
|
relayId: String,
|
||||||
|
timeoutMs: Int,
|
||||||
|
): AiturkTunnelChannel {
|
||||||
|
// Only a validated relay id is ever placed in the URL.
|
||||||
|
val id = AiturkRelay.parse(relayId) ?: throw AiturkTunnelException(AiturkTunnelException.UNKNOWN)
|
||||||
|
val channel = WebSocketTunnelChannel()
|
||||||
|
val socket = client.newWebSocket(Request.Builder().url(relayWsBase + id).build(), channel.listener)
|
||||||
|
channel.attach(socket)
|
||||||
|
val opened =
|
||||||
|
try {
|
||||||
|
channel.awaitOpen(timeoutMs.toLong())
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
socket.cancel()
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw AiturkTunnelException(AiturkTunnelException.NETWORK, cause = e)
|
||||||
|
}
|
||||||
|
if (!opened) {
|
||||||
|
socket.cancel()
|
||||||
|
throw channel.failure ?: AiturkTunnelException(AiturkTunnelException.NETWORK)
|
||||||
|
}
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PING_SECONDS = 25L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class WebSocketTunnelChannel : AiturkTunnelChannel {
|
||||||
|
private val incoming = LinkedBlockingQueue<ByteString>()
|
||||||
|
private val openLatch = CountDownLatch(1)
|
||||||
|
private val flow = Object()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var socket: WebSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isOpen = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var ended = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closedLocally = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var failure: AiturkTunnelException? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var queuedBytes = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
override var soTimeoutMs: Int = 0
|
||||||
|
|
||||||
|
override val isClosed: Boolean get() = closedLocally
|
||||||
|
|
||||||
|
fun attach(webSocket: WebSocket) {
|
||||||
|
socket = webSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the relay accepted the stream; false when it failed or timed out. */
|
||||||
|
fun awaitOpen(timeoutMs: Long): Boolean = openLatch.await(timeoutMs, TimeUnit.MILLISECONDS) && isOpen
|
||||||
|
|
||||||
|
val listener: WebSocketListener =
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
isOpen = true
|
||||||
|
openLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
bytes: ByteString,
|
||||||
|
) {
|
||||||
|
if (bytes.size == 0) return
|
||||||
|
synchronized(flow) {
|
||||||
|
// Applies back-pressure to the relay connection instead of
|
||||||
|
// buffering without bound when the reader is slow.
|
||||||
|
while (queuedBytes > MAX_BUFFERED_BYTES && !ended && !closedLocally) flow.wait(FLOW_WAIT_MS)
|
||||||
|
queuedBytes += bytes.size
|
||||||
|
}
|
||||||
|
incoming.put(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
// The stream is binary only.
|
||||||
|
end(AiturkTunnelException(AiturkTunnelException.REJECTED))
|
||||||
|
webSocket.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosing(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) {
|
||||||
|
end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
|
||||||
|
webSocket.close(NORMAL_CLOSE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
code: Int,
|
||||||
|
reason: String,
|
||||||
|
) = end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
|
||||||
|
|
||||||
|
override fun onFailure(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
t: Throwable,
|
||||||
|
response: Response?,
|
||||||
|
) {
|
||||||
|
// An HTTP answer instead of the upgrade means the relay refused
|
||||||
|
// the stream; anything else is the network.
|
||||||
|
val kind = if (response != null) AiturkTunnelException.REJECTED else AiturkTunnelException.NETWORK
|
||||||
|
end(AiturkTunnelException(kind, cause = t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun end(error: AiturkTunnelException?) {
|
||||||
|
synchronized(flow) {
|
||||||
|
if (ended) return
|
||||||
|
if (error != null && !closedLocally) failure = error
|
||||||
|
ended = true
|
||||||
|
flow.notifyAll()
|
||||||
|
}
|
||||||
|
incoming.offer(END)
|
||||||
|
openLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val input: InputStream =
|
||||||
|
object : InputStream() {
|
||||||
|
private var current: ByteString? = null
|
||||||
|
private var position = 0
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
val one = ByteArray(1)
|
||||||
|
return if (read(one, 0, 1) == -1) -1 else one[0].toInt() and BYTE_MASK
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(
|
||||||
|
b: ByteArray,
|
||||||
|
off: Int,
|
||||||
|
len: Int,
|
||||||
|
): Int {
|
||||||
|
if (len == 0) return 0
|
||||||
|
var chunk = current
|
||||||
|
if (chunk == null) {
|
||||||
|
chunk = next() ?: return -1
|
||||||
|
current = chunk
|
||||||
|
position = 0
|
||||||
|
}
|
||||||
|
val count = minOf(len, chunk.size - position)
|
||||||
|
chunk.copyInto(position, b, off, count)
|
||||||
|
position += count
|
||||||
|
if (position == chunk.size) current = null
|
||||||
|
synchronized(flow) {
|
||||||
|
queuedBytes -= count
|
||||||
|
flow.notifyAll()
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun available(): Int = current?.let { it.size - position } ?: 0
|
||||||
|
|
||||||
|
override fun close() = this@WebSocketTunnelChannel.close()
|
||||||
|
|
||||||
|
/** The next chunk, or null at a clean end of stream. */
|
||||||
|
private fun next(): ByteString? {
|
||||||
|
if (closedLocally) throw SocketException("Socket closed")
|
||||||
|
val timeout = soTimeoutMs
|
||||||
|
val chunk =
|
||||||
|
try {
|
||||||
|
if (timeout > 0) incoming.poll(timeout.toLong(), TimeUnit.MILLISECONDS) else incoming.take()
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw java.io.InterruptedIOException("interrupted")
|
||||||
|
} ?: throw SocketTimeoutException("Read timed out")
|
||||||
|
if (chunk !== END) return chunk
|
||||||
|
incoming.offer(END)
|
||||||
|
if (closedLocally) throw SocketException("Socket closed")
|
||||||
|
failure?.let { throw AiturkTunnelException(it.kind, it.closeCode, it) }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val output: OutputStream =
|
||||||
|
object : OutputStream() {
|
||||||
|
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
|
||||||
|
|
||||||
|
override fun write(
|
||||||
|
b: ByteArray,
|
||||||
|
off: Int,
|
||||||
|
len: Int,
|
||||||
|
) {
|
||||||
|
var offset = off
|
||||||
|
var remaining = len
|
||||||
|
while (remaining > 0) {
|
||||||
|
val count = minOf(remaining, AiturkTunnel.MAX_FRAME_BYTES)
|
||||||
|
send(b.toByteString(offset, count))
|
||||||
|
offset += count
|
||||||
|
remaining -= count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = this@WebSocketTunnelChannel.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send(frame: ByteString) {
|
||||||
|
val webSocket = socket ?: throw SocketException("Socket is not connected")
|
||||||
|
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WRITE_STALL_MS)
|
||||||
|
// Keep the outbound queue small: the relay drops a stream whose
|
||||||
|
// pending data grows past its limit.
|
||||||
|
while (webSocket.queueSize() > MAX_PENDING_WRITE_BYTES) {
|
||||||
|
checkWritable()
|
||||||
|
if (System.nanoTime() > deadline) throw SocketTimeoutException("Write timed out")
|
||||||
|
try {
|
||||||
|
Thread.sleep(WRITE_POLL_MS)
|
||||||
|
} catch (e: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
throw java.io.InterruptedIOException("interrupted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkWritable()
|
||||||
|
if (!webSocket.send(frame)) {
|
||||||
|
checkWritable()
|
||||||
|
throw AiturkTunnelException(AiturkTunnelException.CLOSED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkWritable() {
|
||||||
|
if (closedLocally) throw SocketException("Socket closed")
|
||||||
|
if (ended) throw failure?.let { AiturkTunnelException(it.kind, it.closeCode, it) } ?: SocketException("Connection closed by peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
synchronized(flow) {
|
||||||
|
if (closedLocally) return
|
||||||
|
closedLocally = true
|
||||||
|
flow.notifyAll()
|
||||||
|
}
|
||||||
|
val webSocket = socket
|
||||||
|
if (webSocket != null && !webSocket.close(NORMAL_CLOSE, null)) webSocket.cancel()
|
||||||
|
incoming.offer(END)
|
||||||
|
openLatch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val END: ByteString = ByteString.of(0)
|
||||||
|
const val NORMAL_CLOSE = 1000
|
||||||
|
const val BYTE_MASK = 0xFF
|
||||||
|
const val MAX_BUFFERED_BYTES = 1L * 1024 * 1024
|
||||||
|
const val MAX_PENDING_WRITE_BYTES = 256L * 1024
|
||||||
|
const val FLOW_WAIT_MS = 50L
|
||||||
|
const val WRITE_POLL_MS = 2L
|
||||||
|
const val WRITE_STALL_MS = 30_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tunnel last said about the computer behind a paired connection, for
|
||||||
|
* the chat's connection banner: "reconnecting…" alone does not tell the user
|
||||||
|
* that AITURK IDE is closed.
|
||||||
|
*/
|
||||||
|
object AiturkRelayStatus {
|
||||||
|
private val _hostOffline = MutableStateFlow(false)
|
||||||
|
val hostOffline: StateFlow<Boolean> = _hostOffline.asStateFlow()
|
||||||
|
|
||||||
|
/** [error] is what ended (or prevented) the app's WebSocket; null clears the note. */
|
||||||
|
fun onSocketEnded(error: Throwable?) {
|
||||||
|
_hostOffline.value = AiturkTunnelException.find(error)?.kind == AiturkTunnelException.HOST_OFFLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSocketOpened() {
|
||||||
|
_hostOffline.value = false
|
||||||
|
AiturkCompat.onSocketOpened()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -799,8 +799,10 @@ object AuthManager {
|
|||||||
val raw = serverStore.getLatestState().wsAuthParam
|
val raw = serverStore.getLatestState().wsAuthParam
|
||||||
val authParam = if (raw.isBlank()) "token" else raw
|
val authParam = if (raw.isBlank()) "token" else raw
|
||||||
return endpointForBuild().webSocketUrl(
|
return endpointForBuild().webSocketUrl(
|
||||||
|
path = "api/ws",
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = getToken().orEmpty(),
|
credential = getToken().orEmpty(),
|
||||||
|
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
|||||||
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||||
|
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||||
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
|||||||
messageProvenance =
|
messageProvenance =
|
||||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||||
?: MessageProvenance.UNKNOWN,
|
?: MessageProvenance.UNKNOWN,
|
||||||
)
|
).normalizedGatewayNotice()
|
||||||
|
|
||||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||||
ChatMessageEntity(
|
ChatMessageEntity(
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ data class MessagingPlatformUpdateResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TelegramOnboardingStartRequest(
|
data class TelegramOnboardingStartRequest(
|
||||||
@SerialName("bot_name") val botName: String = "Hermes Agent",
|
@SerialName("bot_name") val botName: String = "AITURK",
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.doubleOrNull
|
import kotlinx.serialization.json.doubleOrNull
|
||||||
import kotlinx.serialization.json.intOrNull
|
import kotlinx.serialization.json.intOrNull
|
||||||
@@ -100,20 +101,10 @@ data class SessionMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val contentText: String
|
val contentText: String
|
||||||
get() =
|
get() = content?.transcriptText().orEmpty()
|
||||||
when (content) {
|
|
||||||
is JsonPrimitive -> content.content
|
|
||||||
null -> ""
|
|
||||||
else -> content.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayContentText: String?
|
val displayContentText: String?
|
||||||
get() =
|
get() = display_content?.transcriptText()
|
||||||
when (display_content) {
|
|
||||||
is JsonPrimitive -> display_content.content
|
|
||||||
null -> null
|
|
||||||
else -> display_content.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val reasoningText: String
|
val reasoningText: String
|
||||||
get() =
|
get() =
|
||||||
@@ -160,4 +151,25 @@ data class SessionMessage(
|
|||||||
get() = tool_call_id.orEmpty()
|
get() = tool_call_id.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
|
||||||
|
private fun JsonElement.transcriptText(): String =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonArray -> {
|
||||||
|
mapNotNull { part ->
|
||||||
|
val block = part as? JsonObject ?: return@mapNotNull null
|
||||||
|
val type = block["type"]
|
||||||
|
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
|
||||||
|
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
}.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val WHITESPACE = Regex("\\s+")
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ object ApiClient {
|
|||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addInterceptor(authInterceptor)
|
.addInterceptor(authInterceptor)
|
||||||
.addInterceptor(ProfileScopeInterceptor)
|
.addInterceptor(ProfileScopeInterceptor)
|
||||||
|
.addInterceptor(CatalogTimeoutInterceptor)
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.authenticator(TokenRefreshAuthenticator)
|
.authenticator(TokenRefreshAuthenticator)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Cold provider discovery can exceed the ordinary 30-second screen timeout. */
|
||||||
|
object CatalogTimeoutInterceptor : Interceptor {
|
||||||
|
const val TIMEOUT_MS = 120_000L
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val catalog = request.method == "GET" && request.url.encodedPath.endsWith("/api/model/options")
|
||||||
|
val next = if (catalog) chain.withReadTimeout(TIMEOUT_MS.toInt(), TimeUnit.MILLISECONDS) else chain
|
||||||
|
return next.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun refresh(): String? =
|
fun refresh(): String? =
|
||||||
try {
|
try {
|
||||||
|
if (isAiturkPaired()) return null
|
||||||
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||||
AuthManager.setToken(token)
|
AuthManager.setToken(token)
|
||||||
token
|
token
|
||||||
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
suspend fun refreshAsync(): String? =
|
suspend fun refreshAsync(): String? =
|
||||||
refreshMutex.withLock {
|
refreshMutex.withLock {
|
||||||
try {
|
try {
|
||||||
|
if (isAiturkPaired()) return@withLock null
|
||||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
||||||
AuthManager.setToken(token)
|
AuthManager.setToken(token)
|
||||||
token
|
token
|
||||||
@@ -37,6 +40,11 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AITURK: a paired IDE issues a long-lived device token and its LAN proxy
|
||||||
|
// answers 404 on `/` (the dashboard page must never leave the computer), so
|
||||||
|
// there is nothing to refresh and the stored token must stay as it is.
|
||||||
|
private fun isAiturkPaired(): Boolean = AiturkLanTrust.isPairedUrl(AuthManager.baseUrl())
|
||||||
|
|
||||||
internal suspend fun fetch(
|
internal suspend fun fetch(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ fun mapHttpError(
|
|||||||
if (code == 401) {
|
if (code == 401) {
|
||||||
return NetworkError.AuthExpired()
|
return NetworkError.AuthExpired()
|
||||||
}
|
}
|
||||||
|
// AITURK: the IDE's phone proxy refusing a route, or the IDE not having it, gets a plain sentence.
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
.httpErrorMessage(code, errorBody)
|
||||||
|
?.let { return NetworkError.Http(code, it) }
|
||||||
// Try to parse FastAPI-style error detail from response body
|
// Try to parse FastAPI-style error detail from response body
|
||||||
val detail =
|
val detail =
|
||||||
if (errorBody != null) {
|
if (errorBody != null) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
|
|||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -39,6 +40,9 @@ object OkHttpProvider {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
|
// AITURK: a paired IDE's host:port is verified by certificate
|
||||||
|
// fingerprint; every other address keeps the platform TLS checks.
|
||||||
|
.let(AiturkLanTrust::applyTo)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ object UpdateNoticeManager {
|
|||||||
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
|
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
|
||||||
* hit the GitHub API or nag about updates. Tests flip this to true.
|
* hit the GitHub API or nag about updates. Tests flip this to true.
|
||||||
*/
|
*/
|
||||||
var enabled: Boolean = !BuildConfig.DEBUG
|
// AITURK: off in every build. The upstream checker installs Hy4ri/hermes-mobile
|
||||||
|
// release APKs (different package id and signing key); AITURK Mobil builds are
|
||||||
|
// published at https://turkservis.online/indir.
|
||||||
|
var enabled: Boolean = false
|
||||||
internal set
|
internal set
|
||||||
|
|
||||||
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ object EventParser {
|
|||||||
|
|
||||||
"status.update" -> {
|
"status.update" -> {
|
||||||
val status = payload?.get("status") as? String
|
val status = payload?.get("status") as? String
|
||||||
WsEvent.StatusUpdate(status, payload)
|
WsEvent.StatusUpdate(status, payload, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"error" -> {
|
"error" -> {
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ class KanbanEventsClient {
|
|||||||
path = KANBAN_EVENTS_PATH,
|
path = KANBAN_EVENTS_PATH,
|
||||||
authParameter = authParam,
|
authParameter = authParam,
|
||||||
credential = credential,
|
credential = credential,
|
||||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
extraParams = mapOf("since" to cursor.toString(), "board" to board) +
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(AuthManager.activeProfileId.value),
|
||||||
)
|
)
|
||||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.CatalogTimeoutInterceptor
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
|||||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||||
},
|
},
|
||||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params, timeoutMs = CatalogTimeoutInterceptor.TIMEOUT_MS)
|
||||||
},
|
},
|
||||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
|||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
|||||||
emitEvent(finalEvent)
|
emitEvent(finalEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// Disconnect/profile switch cancels replay; it is not a
|
||||||
|
// remote failure and must not request a stale transcript.
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onReplayFailure(sid, e)
|
onReplayFailure(sid, e)
|
||||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -110,6 +111,11 @@ internal class RpcChannel(
|
|||||||
suppressErrorEvent: Boolean = false,
|
suppressErrorEvent: Boolean = false,
|
||||||
): CompletableDeferred<Any?> {
|
): CompletableDeferred<Any?> {
|
||||||
val deferred = CompletableDeferred<Any?>()
|
val deferred = CompletableDeferred<Any?>()
|
||||||
|
// AITURK: a method the paired IDE already answered "unknown method" for is not sent again.
|
||||||
|
if (AiturkCompat.isKnownMissing(method)) {
|
||||||
|
deferred.completeExceptionally(AiturkCompat.missingMethodError())
|
||||||
|
return deferred
|
||||||
|
}
|
||||||
val id =
|
val id =
|
||||||
sendRequest(method, params) { reqId ->
|
sendRequest(method, params) { reqId ->
|
||||||
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||||
@@ -191,7 +197,7 @@ internal class RpcChannel(
|
|||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(
|
call.deferred.completeExceptionally(
|
||||||
HermesWsClient.HermesRpcException(
|
HermesWsClient.HermesRpcException(
|
||||||
message = error.message,
|
message = AiturkCompat.rpcExceptionMessage(error.code, error.message),
|
||||||
code = error.code,
|
code = error.code,
|
||||||
data = error.data,
|
data = error.data,
|
||||||
),
|
),
|
||||||
@@ -332,7 +338,12 @@ internal class RpcChannel(
|
|||||||
|
|
||||||
is WsEvent.RpcError -> {
|
is WsEvent.RpcError -> {
|
||||||
onError(event.id)
|
onError(event.id)
|
||||||
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
|
// AITURK: an awaited call the paired IDE does not know fails through its
|
||||||
|
// deferred only; the raw "unknown method" never reaches the chat banner.
|
||||||
|
val pending = pendingCalls[event.id]
|
||||||
|
AiturkCompat.noteRpcError(pending?.method, event.error.code)
|
||||||
|
val quiet = pending != null && AiturkCompat.quietRpcError(event.error.code)
|
||||||
|
if (quiet || pending?.suppressErrorEvent == true) {
|
||||||
// Opt-in suppression: the caller already handles the
|
// Opt-in suppression: the caller already handles the
|
||||||
// failure through the CompletableDeferred, so the event
|
// failure through the CompletableDeferred, so the event
|
||||||
// copy would only surface a duplicate UI banner for
|
// copy would only surface a duplicate UI banner for
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ sealed class WsEvent {
|
|||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
val status: String?,
|
val status: String?,
|
||||||
val data: Map<String, Any?>?,
|
val data: Map<String, Any?>?,
|
||||||
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
data class SessionUpdated(
|
data class SessionUpdated(
|
||||||
|
|||||||
@@ -659,6 +659,8 @@ internal class WsTransport(
|
|||||||
outboundDrainJob?.cancel()
|
outboundDrainJob?.cancel()
|
||||||
outboundDrainJob = null
|
outboundDrainJob = null
|
||||||
Log.i(TAG, "WebSocket opened")
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||||
|
.onSocketOpened()
|
||||||
connected.set(true)
|
connected.set(true)
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
reconnectPolicy.reset()
|
reconnectPolicy.reset()
|
||||||
@@ -730,6 +732,8 @@ internal class WsTransport(
|
|||||||
// Do NOT log [reason] — it may carry server-side context. The
|
// Do NOT log [reason] — it may carry server-side context. The
|
||||||
// reason is still inspected internally to detect auth failures.
|
// reason is still inspected internally to detect auth failures.
|
||||||
Log.i(TAG, "WebSocket closed: $code")
|
Log.i(TAG, "WebSocket closed: $code")
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||||
|
.onSocketEnded(null)
|
||||||
connected.set(false)
|
connected.set(false)
|
||||||
ActiveSessionHolder.clear()
|
ActiveSessionHolder.clear()
|
||||||
connectionHealth.stop()
|
connectionHealth.stop()
|
||||||
@@ -764,6 +768,10 @@ internal class WsTransport(
|
|||||||
connectionHealth.stop()
|
connectionHealth.stop()
|
||||||
rpcChannel.clearCapabilityRequests()
|
rpcChannel.clearCapabilityRequests()
|
||||||
val code = response?.code ?: 0
|
val code = response?.code ?: 0
|
||||||
|
// AITURK: lets the chat banner say "AITURK IDE is not open on the
|
||||||
|
// computer" when the tunnel reports that no host is attached.
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||||
|
.onSocketEnded(t)
|
||||||
if (code == 401 || code == 4401 || code == 4403 ||
|
if (code == 401 || code == 4401 || code == 4403 ||
|
||||||
t.message?.contains("401") == true ||
|
t.message?.contains("401") == true ||
|
||||||
t.message?.contains("4401") == true ||
|
t.message?.contains("4401") == true ||
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
|
|||||||
@SerialName("hidden") val hidden: Boolean? = null,
|
@SerialName("hidden") val hidden: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `source` this client declares for every session it mints or resumes.
|
||||||
|
*
|
||||||
|
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
|
||||||
|
* `session.resume`. The gateway resolves an omitted `source` from the host environment
|
||||||
|
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
|
||||||
|
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
|
||||||
|
* create and resume call sites cannot drift apart again.
|
||||||
|
*/
|
||||||
|
const val DESKTOP_SESSION_SOURCE = "desktop"
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionResumeParams(
|
data class SessionResumeParams(
|
||||||
@SerialName("session_id") val sessionId: String,
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("source") val source: String? = null,
|
||||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||||
@SerialName("profile") val profile: String? = null,
|
@SerialName("profile") val profile: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -726,6 +726,11 @@ object NotificationHelper {
|
|||||||
) {
|
) {
|
||||||
ChatNotificationService.setAppForeground(foreground)
|
ChatNotificationService.setAppForeground(foreground)
|
||||||
HermesWsClient.setAppForeground(foreground)
|
HermesWsClient.setAppForeground(foreground)
|
||||||
|
// AITURK: reopen the socket that was closed while idle in the background.
|
||||||
|
if (foreground) {
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
.onAppForeground()
|
||||||
|
}
|
||||||
if (!foreground) {
|
if (!foreground) {
|
||||||
BackgroundConnectionController.default.reconcileState()
|
BackgroundConnectionController.default.reconcileState()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
|
|||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||||
@@ -61,7 +62,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
NotificationCompat
|
NotificationCompat
|
||||||
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
|
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle("Hermes")
|
.setContentTitle("AITURK")
|
||||||
.setContentText("Replied")
|
.setContentText("Replied")
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
|||||||
val params =
|
val params =
|
||||||
SessionResumeParams(
|
SessionResumeParams(
|
||||||
sessionId = storedSessionId,
|
sessionId = storedSessionId,
|
||||||
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
omitMessages = true,
|
omitMessages = true,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.m57.hermescontrol.ui.aiturkpair
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Computer
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||||
|
import com.m57.hermescontrol.FilesScreen
|
||||||
|
import com.m57.hermescontrol.KeysScreen
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.SettingsVault
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
import com.m57.hermescontrol.ui.common.EmptyState
|
||||||
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screens a paired AITURK IDE cannot serve, hidden from the drawer and the
|
||||||
|
* settings list while that pairing is the active connection:
|
||||||
|
*
|
||||||
|
* - Account connectors, Credential vault: the IDE's agent has no
|
||||||
|
* `connectors.*` / `vault.*` methods;
|
||||||
|
* - Keys, Files: the IDE's phone proxy refuses `/api/env` and `/api/files`
|
||||||
|
* on purpose (secrets and the computer's files stay on the computer).
|
||||||
|
*
|
||||||
|
* A plain Hermes dashboard connection shows everything, as upstream does.
|
||||||
|
*/
|
||||||
|
object AiturkFeatureGate {
|
||||||
|
val HIDDEN_WHEN_LINKED: Set<NavKey> = setOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)
|
||||||
|
|
||||||
|
fun shows(
|
||||||
|
key: NavKey,
|
||||||
|
linked: Boolean = AiturkCompat.isLinked(),
|
||||||
|
): Boolean = !(linked && key in HIDDEN_WHEN_LINKED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown instead of a hidden screen that is still reached (restored back stack, shortcut). */
|
||||||
|
@Composable
|
||||||
|
fun AiturkUnavailableScreen(
|
||||||
|
@StringRes titleRes: Int,
|
||||||
|
onOpenDrawer: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
HermesScaffold(
|
||||||
|
title = { Text(stringResource(titleRes)) },
|
||||||
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
|
) { padding ->
|
||||||
|
EmptyState(
|
||||||
|
title = stringResource(R.string.aiturk_feature_unavailable),
|
||||||
|
subtitle = stringResource(R.string.aiturk_feature_unavailable_hint),
|
||||||
|
icon = Icons.Filled.Computer,
|
||||||
|
modifier = Modifier.padding(padding).testTag("aiturk_unavailable"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.m57.hermescontrol.ui.aiturkpair
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.m57.hermescontrol.AiturkPairScreen
|
||||||
|
import com.m57.hermescontrol.NavigationController
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands an `aiturk://pair?...` VIEW intent to the pairing screen. The link is
|
||||||
|
* only parked here; the screen asks the user to confirm before connecting,
|
||||||
|
* because any app or web page can fire this intent.
|
||||||
|
*/
|
||||||
|
object AiturkPairDeepLink {
|
||||||
|
private val _pending = MutableStateFlow<String?>(null)
|
||||||
|
val pending: StateFlow<String?> = _pending.asStateFlow()
|
||||||
|
|
||||||
|
/** @return true when [intent] was an AITURK pairing link. */
|
||||||
|
fun offer(intent: Intent?): Boolean {
|
||||||
|
if (intent?.action != Intent.ACTION_VIEW) return false
|
||||||
|
val raw = intent.dataString
|
||||||
|
if (!AiturkPairing.looksLikePairingLink(raw)) return false
|
||||||
|
// Consume it so a recreated activity does not replay the same link.
|
||||||
|
intent.data = null
|
||||||
|
_pending.value = raw
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun take(): String? = _pending.value.also { _pending.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the pairing screen whenever a deep link is waiting. */
|
||||||
|
@Composable
|
||||||
|
fun AiturkPairDeepLinkEffect() {
|
||||||
|
val pending by AiturkPairDeepLink.pending.collectAsState()
|
||||||
|
LaunchedEffect(pending) {
|
||||||
|
if (pending != null) NavigationController.navigateTo(AiturkPairScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package com.m57.hermescontrol.ui.aiturkpair
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Computer
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.OffsetMapping
|
||||||
|
import androidx.compose.ui.text.input.TransformedText
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||||
|
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
||||||
|
|
||||||
|
/** Any RFC1918 address: pairing targets are always private IPv4, so the answer is the same. */
|
||||||
|
private const val LAN_PROBE_URL = "https://192.168.0.1/"
|
||||||
|
private const val CODE_GROUP = 4
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AiturkPairScreen(
|
||||||
|
onConnected: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AiturkPairViewModel = viewModel { AiturkPairViewModel() },
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val scanPrompt = stringResource(R.string.aiturk_pair_scan_prompt)
|
||||||
|
val scanLauncher =
|
||||||
|
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||||
|
viewModel.onQrScanned(result.contents, lanPermissionGranted(context))
|
||||||
|
}
|
||||||
|
val launchScanner = {
|
||||||
|
scanLauncher.launch(
|
||||||
|
ScanOptions()
|
||||||
|
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
.setCaptureActivity(AiturkQrCaptureActivity::class.java)
|
||||||
|
.setPrompt(scanPrompt)
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setBarcodeImageEnabled(false)
|
||||||
|
.setOrientationLocked(true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val cameraPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) launchScanner() else viewModel.onCameraPermissionDenied()
|
||||||
|
}
|
||||||
|
val lanPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
viewModel.onLanPermissionResult(granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.lanPermissionNeeded) {
|
||||||
|
if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
}
|
||||||
|
// The connection is already saved; the dialog only shows the security code
|
||||||
|
// (the same 8 characters AITURK IDE shows under the QR code) before leaving.
|
||||||
|
if (state.connectionSuccess) {
|
||||||
|
val securityCode = state.securityCode
|
||||||
|
if (securityCode == null) {
|
||||||
|
LaunchedEffect(Unit) { onConnected() }
|
||||||
|
} else {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onConnected,
|
||||||
|
title = { Text(stringResource(R.string.aiturk_pair_security_title)) },
|
||||||
|
text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConnected) {
|
||||||
|
Text(stringResource(R.string.aiturk_pair_security_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deepLink by AiturkPairDeepLink.pending.collectAsState()
|
||||||
|
LaunchedEffect(deepLink) {
|
||||||
|
if (deepLink != null) AiturkPairDeepLink.take()?.let(viewModel::onDeepLink)
|
||||||
|
}
|
||||||
|
// The ViewModel is Activity-scoped: drop one-shot flags when the screen leaves.
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { viewModel.clearTransientState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
state.confirmAddress?.let { address ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::dismissDeepLink,
|
||||||
|
title = { Text(stringResource(R.string.aiturk_pair_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.aiturk_pair_confirm_message, address)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.confirmDeepLink(lanPermissionGranted(context)) }) {
|
||||||
|
Text(stringResource(R.string.aiturk_pair_connect))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::dismissDeepLink) {
|
||||||
|
Text(stringResource(R.string.aiturk_pair_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.fillMaxSize().imePadding(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Computer,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.aiturk_pair_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.aiturk_pair_desc),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (hasPermission(context, Manifest.permission.CAMERA)) {
|
||||||
|
launchScanner()
|
||||||
|
} else {
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !state.busy,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.QrCodeScanner, contentDescription = null)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.aiturk_pair_scan_qr), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.aiturk_pair_or),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.code,
|
||||||
|
onValueChange = viewModel::onCodeChange,
|
||||||
|
label = { Text(stringResource(R.string.aiturk_pair_code_label)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.aiturk_pair_code_placeholder)) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !state.busy,
|
||||||
|
isError = state.errorRes != null,
|
||||||
|
visualTransformation = PairingCodeTransformation,
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
),
|
||||||
|
keyboardOptions =
|
||||||
|
KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.Characters,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { viewModel.submitCode(lanPermissionGranted(context)) }),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { viewModel.submitCode(lanPermissionGranted(context)) },
|
||||||
|
enabled = !state.busy && state.code.length == AiturkPairing.CODE_LENGTH,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.aiturk_pair_connect), style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.busy) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
if (state.step == AiturkPairStep.RESOLVING) {
|
||||||
|
R.string.aiturk_pair_progress_resolve
|
||||||
|
} else {
|
||||||
|
R.string.aiturk_pair_progress_connect
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorRes?.let { res ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(res),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
// Diagnostic code for support: fixed words only, never a token, code or address.
|
||||||
|
if (state.errorCode.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.aiturk_pair_error_code, state.errorCode),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||||
|
Text(stringResource(R.string.aiturk_pair_back))
|
||||||
|
}
|
||||||
|
|
||||||
|
// So the user can tell which build is installed when support asks.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.aiturk_app_version, AiturkCompat.versionLabel()),
|
||||||
|
modifier = Modifier.testTag("aiturk_app_version"),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermission(
|
||||||
|
context: Context,
|
||||||
|
permission: String,
|
||||||
|
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Android 17+ gates LAN hosts behind ACCESS_LOCAL_NETWORK; older releases need nothing. */
|
||||||
|
private fun lanPermissionGranted(context: Context): Boolean =
|
||||||
|
!requiresLocalNetworkPermission(Build.VERSION.SDK_INT, LAN_PROBE_URL) ||
|
||||||
|
hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||||
|
|
||||||
|
/** Shows `ABCDEFGH` as `ABCD-EFGH` without storing the dash. */
|
||||||
|
private object PairingCodeTransformation : VisualTransformation {
|
||||||
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
|
val raw = text.text
|
||||||
|
val shown = if (raw.length > CODE_GROUP) raw.substring(0, CODE_GROUP) + "-" + raw.substring(CODE_GROUP) else raw
|
||||||
|
val mapping =
|
||||||
|
object : OffsetMapping {
|
||||||
|
override fun originalToTransformed(offset: Int): Int = if (offset > CODE_GROUP) offset + 1 else offset
|
||||||
|
|
||||||
|
override fun transformedToOriginal(offset: Int): Int = if (offset > CODE_GROUP) offset - 1 else offset
|
||||||
|
}
|
||||||
|
return TransformedText(AnnotatedString(shown), mapping)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package com.m57.hermescontrol.ui.aiturkpair
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkDiag
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairError
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class AiturkPairStep { IDLE, RESOLVING, CONNECTING }
|
||||||
|
|
||||||
|
data class AiturkPairUiState(
|
||||||
|
val code: String = "",
|
||||||
|
val step: AiturkPairStep = AiturkPairStep.IDLE,
|
||||||
|
@param:StringRes val errorRes: Int? = null,
|
||||||
|
/** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */
|
||||||
|
val errorCode: String = "",
|
||||||
|
/** Address of a deep-link pairing waiting for the user's confirmation. */
|
||||||
|
val confirmAddress: String? = null,
|
||||||
|
val lanPermissionNeeded: Boolean = false,
|
||||||
|
val connectionSuccess: Boolean = false,
|
||||||
|
/** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */
|
||||||
|
val securityCode: String? = null,
|
||||||
|
) {
|
||||||
|
val busy: Boolean get() = step != AiturkPairStep.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiturkPairViewModel(
|
||||||
|
private val client: AiturkPairingClient = AiturkPairingClient(),
|
||||||
|
private val register: suspend (AiturkPairedDevice) -> Unit = AiturkConnectionRegistrar::register,
|
||||||
|
private val deviceName: String = defaultDeviceName(),
|
||||||
|
) : ViewModel() {
|
||||||
|
private sealed interface Request {
|
||||||
|
data class Code(
|
||||||
|
val code: String,
|
||||||
|
) : Request
|
||||||
|
|
||||||
|
data class Payload(
|
||||||
|
val payload: AiturkPairingPayload,
|
||||||
|
) : Request
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(AiturkPairUiState())
|
||||||
|
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var pending: AiturkPairingPayload? = null
|
||||||
|
private var confirmPayload: AiturkPairingPayload? = null
|
||||||
|
|
||||||
|
fun onCodeChange(raw: String) {
|
||||||
|
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitCode(lanPermissionGranted: Boolean) {
|
||||||
|
val code = AiturkPairing.normalizeCode(_uiState.value.code)
|
||||||
|
if (code == null) {
|
||||||
|
showError(R.string.aiturk_pair_error_code_invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start(Request.Code(code), lanPermissionGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [raw] is null when the user backed out of the scanner. */
|
||||||
|
fun onQrScanned(
|
||||||
|
raw: String?,
|
||||||
|
lanPermissionGranted: Boolean,
|
||||||
|
) {
|
||||||
|
if (raw == null) return
|
||||||
|
when (val result = AiturkPairing.parse(raw)) {
|
||||||
|
is AiturkPayloadResult.Ok -> start(Request.Payload(result.payload), lanPermissionGranted)
|
||||||
|
is AiturkPayloadResult.Invalid -> showError(payloadErrorRes(result.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link from outside the app is never acted on without an explicit confirmation. */
|
||||||
|
fun onDeepLink(raw: String) {
|
||||||
|
if (_uiState.value.busy) return
|
||||||
|
when (val result = AiturkPairing.parse(raw)) {
|
||||||
|
is AiturkPayloadResult.Ok -> {
|
||||||
|
confirmPayload = result.payload
|
||||||
|
// A relay-only link (the default) has no LAN address to show.
|
||||||
|
val address =
|
||||||
|
result.payload.offer.endpoints
|
||||||
|
.firstOrNull()
|
||||||
|
?.hostPort ?: RELAY_DISPLAY_NAME
|
||||||
|
_uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
is AiturkPayloadResult.Invalid -> {
|
||||||
|
showError(payloadErrorRes(result.error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun confirmDeepLink(lanPermissionGranted: Boolean) {
|
||||||
|
val payload = confirmPayload ?: return
|
||||||
|
dismissDeepLink()
|
||||||
|
start(Request.Payload(payload), lanPermissionGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissDeepLink() {
|
||||||
|
confirmPayload = null
|
||||||
|
_uiState.update { it.copy(confirmAddress = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCameraPermissionDenied() = showError(R.string.aiturk_pair_error_camera_denied)
|
||||||
|
|
||||||
|
fun onLanPermissionResult(granted: Boolean) {
|
||||||
|
_uiState.update { it.copy(lanPermissionNeeded = false) }
|
||||||
|
val payload = pending
|
||||||
|
pending = null
|
||||||
|
// A denied permission no longer stops the pairing: the tunnel needs no
|
||||||
|
// local-network access. Only the LAN candidates are skipped, and if
|
||||||
|
// nothing else works the result says so (`lan:permission`).
|
||||||
|
if (payload != null) run(payload, lanAllowed = granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
|
||||||
|
fun clearTransientState() {
|
||||||
|
pending = null
|
||||||
|
confirmPayload = null
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
errorRes = null,
|
||||||
|
errorCode = "",
|
||||||
|
confirmAddress = null,
|
||||||
|
lanPermissionNeeded = false,
|
||||||
|
connectionSuccess = false,
|
||||||
|
securityCode = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start(
|
||||||
|
request: Request,
|
||||||
|
lanPermissionGranted: Boolean,
|
||||||
|
) {
|
||||||
|
if (_uiState.value.busy) return
|
||||||
|
when (request) {
|
||||||
|
is Request.Payload -> {
|
||||||
|
connect(request.payload, lanPermissionGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Request.Code -> {
|
||||||
|
_uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val resolved =
|
||||||
|
try {
|
||||||
|
client.resolve(request.code)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(step = AiturkPairStep.IDLE) }
|
||||||
|
when (resolved) {
|
||||||
|
is AiturkPairResult.Success -> {
|
||||||
|
connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
is AiturkPairResult.Failure -> {
|
||||||
|
showFailure(resolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local-network permission is asked for only when a LAN candidate may
|
||||||
|
* actually be tried. A pairing that carries only a relay id (the default)
|
||||||
|
* goes through the tunnel and never asks.
|
||||||
|
*/
|
||||||
|
private fun connect(
|
||||||
|
payload: AiturkPairingPayload,
|
||||||
|
lanPermissionGranted: Boolean,
|
||||||
|
) {
|
||||||
|
if (_uiState.value.busy) return
|
||||||
|
if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) {
|
||||||
|
pending = payload
|
||||||
|
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run(payload, lanAllowed = lanPermissionGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
payload: AiturkPairingPayload,
|
||||||
|
lanAllowed: Boolean,
|
||||||
|
) {
|
||||||
|
if (_uiState.value.busy) return
|
||||||
|
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val outcome =
|
||||||
|
try {
|
||||||
|
client.pair(payload.offer, payload.code, deviceName, lanAllowed)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||||
|
}
|
||||||
|
when (outcome) {
|
||||||
|
is AiturkPairResult.Success -> {
|
||||||
|
val saved = runCatching { register(outcome.value) }.isSuccess
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
step = AiturkPairStep.IDLE,
|
||||||
|
code = "",
|
||||||
|
connectionSuccess = saved,
|
||||||
|
securityCode = if (saved) securityCode(outcome.value.fingerprint) else null,
|
||||||
|
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
|
||||||
|
errorCode = if (saved) "" else "app:save_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is AiturkPairResult.Failure -> {
|
||||||
|
showFailure(outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFailure(failure: AiturkPairResult.Failure) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
step = AiturkPairStep.IDLE,
|
||||||
|
errorRes = pairErrorRes(failure.error),
|
||||||
|
errorCode = AiturkDiag.sanitize(failure.code),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(
|
||||||
|
@StringRes res: Int,
|
||||||
|
) {
|
||||||
|
_uiState.update { it.copy(errorRes = res, errorCode = "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SECURITY_CODE_LENGTH = 8
|
||||||
|
private const val RELAY_DISPLAY_NAME = "turkservis.online"
|
||||||
|
|
||||||
|
/** What both screens show so the user can compare them. */
|
||||||
|
fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH)
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun payloadErrorRes(error: AiturkPayloadError): Int =
|
||||||
|
when (error) {
|
||||||
|
AiturkPayloadError.NOT_AITURK -> R.string.aiturk_pair_error_qr_not_aiturk
|
||||||
|
|
||||||
|
AiturkPayloadError.UNSUPPORTED_VERSION -> R.string.aiturk_pair_error_update_app
|
||||||
|
|
||||||
|
AiturkPayloadError.MALFORMED,
|
||||||
|
AiturkPayloadError.BAD_FINGERPRINT,
|
||||||
|
AiturkPayloadError.BAD_ADDRESS,
|
||||||
|
AiturkPayloadError.BAD_ADDRESS_COUNT,
|
||||||
|
AiturkPayloadError.BAD_RELAY,
|
||||||
|
AiturkPayloadError.BAD_CODE,
|
||||||
|
-> R.string.aiturk_pair_error_qr_invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
fun pairErrorRes(error: AiturkPairError): Int =
|
||||||
|
when (error) {
|
||||||
|
AiturkPairError.CODE_NOT_FOUND -> R.string.aiturk_pair_error_code_not_found
|
||||||
|
|
||||||
|
AiturkPairError.RATE_LIMITED -> R.string.aiturk_pair_error_rate_limited
|
||||||
|
|
||||||
|
AiturkPairError.GATEWAY_UNAVAILABLE -> R.string.aiturk_pair_error_gateway
|
||||||
|
|
||||||
|
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
|
||||||
|
|
||||||
|
AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline
|
||||||
|
|
||||||
|
AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout
|
||||||
|
|
||||||
|
AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy
|
||||||
|
|
||||||
|
AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown
|
||||||
|
|
||||||
|
AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin
|
||||||
|
|
||||||
|
AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet
|
||||||
|
|
||||||
|
AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay
|
||||||
|
|
||||||
|
AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission
|
||||||
|
|
||||||
|
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
|
||||||
|
|
||||||
|
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
|
||||||
|
|
||||||
|
AiturkPairError.PAIRING_LOCKED -> R.string.aiturk_pair_error_pairing_locked
|
||||||
|
|
||||||
|
AiturkPairError.INVALID_REQUEST,
|
||||||
|
AiturkPairError.UNEXPECTED_RESPONSE,
|
||||||
|
-> R.string.aiturk_pair_error_unexpected
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultDeviceName(): String {
|
||||||
|
val maker = android.os.Build.MANUFACTURER.orEmpty()
|
||||||
|
val model = android.os.Build.MODEL.orEmpty()
|
||||||
|
val name = if (model.startsWith(maker, ignoreCase = true)) model else "$maker $model"
|
||||||
|
return AiturkPairingClient.sanitizeDeviceName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.m57.hermescontrol.ui.aiturkpair
|
||||||
|
|
||||||
|
import com.journeyapps.barcodescanner.CaptureActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ZXing capture screen under our own manifest entry, so it follows the
|
||||||
|
* device orientation instead of the library default (landscape only).
|
||||||
|
*/
|
||||||
|
class AiturkQrCaptureActivity : CaptureActivity()
|
||||||
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
|||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||||
@@ -564,7 +565,7 @@ class GroupChatViewModel(
|
|||||||
method = RpcMethods.SESSION_CREATE,
|
method = RpcMethods.SESSION_CREATE,
|
||||||
params =
|
params =
|
||||||
SessionCreateParams(
|
SessionCreateParams(
|
||||||
source = "desktop",
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
profile = bot.name,
|
profile = bot.name,
|
||||||
title = title,
|
title = title,
|
||||||
hidden = true,
|
hidden = true,
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ class ChannelsViewModel :
|
|||||||
val result =
|
val result =
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.startTelegramOnboarding(
|
ApiClient.hermesApi.startTelegramOnboarding(
|
||||||
TelegramOnboardingStartRequest(botName = "Hermes Agent"),
|
TelegramOnboardingStartRequest(botName = "AITURK"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
|
||||||
|
internal data class ChatAttachmentTarget(
|
||||||
|
val sessionId: String,
|
||||||
|
val generation: Long,
|
||||||
|
val baseUrl: String,
|
||||||
|
val connectionProfileId: String?,
|
||||||
|
val agentProfileId: String?,
|
||||||
|
)
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
||||||
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
|||||||
toolCount = 0
|
toolCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
message.role == MessageRole.TOOL -> {
|
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||||
toolCount += 1
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[message.id] = toolCount
|
milestones[message.id] = toolCount
|
||||||
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
|||||||
toolCount = 0
|
toolCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
message.role == MessageRole.TOOL -> {
|
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||||
toolCount += 1
|
toolCount += 1
|
||||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||||
milestones[index] = toolCount
|
milestones[index] = toolCount
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
|
||||||
|
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
|
||||||
|
* never run during a live session where an old attachment may still be uploading.
|
||||||
|
*/
|
||||||
|
internal fun cleanStalePastedImages(
|
||||||
|
cacheDir: File,
|
||||||
|
nowMs: Long = System.currentTimeMillis(),
|
||||||
|
) {
|
||||||
|
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
|
||||||
|
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||||
|
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
|
||||||
|
for (file in files) {
|
||||||
|
runCatching {
|
||||||
|
// Do not traverse subdirectories or touch any other cache namespace.
|
||||||
|
if (file.isFile && file.lastModified() < cutoff) file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
|
||||||
|
|
||||||
|
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
|
||||||
|
class PastedImage(
|
||||||
|
val attachment: Attachment,
|
||||||
|
val file: File,
|
||||||
|
) {
|
||||||
|
/** Call if the import cannot be queued (for example, after a session switch). */
|
||||||
|
fun discard() {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
|
||||||
|
class ChatPastedImageStore(
|
||||||
|
private val context: Context,
|
||||||
|
) {
|
||||||
|
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
|
||||||
|
suspend fun importImage(
|
||||||
|
uri: Uri,
|
||||||
|
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||||
|
): PastedImage? {
|
||||||
|
var pending: PastedImage? = null
|
||||||
|
var delivered = false
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (uri.scheme != "content") return@withContext null
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val mimeType =
|
||||||
|
resolver
|
||||||
|
.getType(uri)
|
||||||
|
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
|
||||||
|
?: return@withContext null
|
||||||
|
val extension =
|
||||||
|
MimeTypeMap
|
||||||
|
.getSingleton()
|
||||||
|
.getExtensionFromMimeType(mimeType)
|
||||||
|
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
|
||||||
|
?: "img"
|
||||||
|
val input = resolver.openInputStream(uri) ?: return@withContext null
|
||||||
|
val activeContext = currentCoroutineContext()
|
||||||
|
val file =
|
||||||
|
stagePastedImage(
|
||||||
|
input = input,
|
||||||
|
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
|
||||||
|
extension = extension,
|
||||||
|
maxBytes = maxBytes,
|
||||||
|
checkActive = { activeContext.ensureActive() },
|
||||||
|
) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
activeContext.ensureActive()
|
||||||
|
val privateUri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file,
|
||||||
|
)
|
||||||
|
PastedImage(
|
||||||
|
attachment =
|
||||||
|
Attachment(
|
||||||
|
uri = privateUri.toString(),
|
||||||
|
name = file.name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
size = file.length(),
|
||||||
|
),
|
||||||
|
file = file,
|
||||||
|
).also { pending = it }
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
file.delete()
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered = true
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
// withContext may discard its completed IO result if the parent was cancelled
|
||||||
|
// during dispatch back; in that case ownership never reached the caller.
|
||||||
|
if (!delivered) pending?.discard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
|
||||||
|
internal fun stagePastedImage(
|
||||||
|
input: InputStream,
|
||||||
|
directory: File,
|
||||||
|
extension: String,
|
||||||
|
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||||
|
checkActive: () -> Unit = {},
|
||||||
|
newId: () -> String = { UUID.randomUUID().toString() },
|
||||||
|
): File? {
|
||||||
|
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||||
|
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
|
||||||
|
var owned: File? = null
|
||||||
|
var keep = false
|
||||||
|
try {
|
||||||
|
return input
|
||||||
|
.use source@{ source ->
|
||||||
|
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
|
||||||
|
throw IOException("Cannot create pasted image cache directory")
|
||||||
|
}
|
||||||
|
val file = File(directory, "${UUID.fromString(newId())}.$extension")
|
||||||
|
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
|
||||||
|
owned = file
|
||||||
|
var total = 0L
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
FileOutputStream(file).use { output ->
|
||||||
|
while (true) {
|
||||||
|
checkActive()
|
||||||
|
// Probe one byte beyond the limit without trusting provider-reported size.
|
||||||
|
val remaining = effectiveMaxBytes - total
|
||||||
|
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
|
||||||
|
val count = source.read(buffer, 0, requested)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count > remaining) throw PastedImageTooLargeException()
|
||||||
|
if (count == 0) {
|
||||||
|
val byte = source.read()
|
||||||
|
if (byte < 0) break
|
||||||
|
if (remaining == 0L) throw PastedImageTooLargeException()
|
||||||
|
output.write(byte)
|
||||||
|
total++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
checkActive()
|
||||||
|
}
|
||||||
|
if (total == 0L) null else file
|
||||||
|
}.also { keep = it != null }
|
||||||
|
} finally {
|
||||||
|
if (!keep) owned?.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.input.clearText
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
@@ -78,6 +79,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.HistoryScreen
|
import com.m57.hermescontrol.HistoryScreen
|
||||||
@@ -93,6 +95,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
|||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
||||||
@@ -118,9 +121,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
|||||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
|
||||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||||
@@ -338,20 +344,18 @@ fun ChatScreen(
|
|||||||
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
val inputState = rememberTextFieldState()
|
||||||
mutableStateOf(TextFieldValue(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(state.pendingPrefillText) {
|
LaunchedEffect(state.pendingPrefillText) {
|
||||||
val prefill = state.pendingPrefillText
|
val prefill = state.pendingPrefillText
|
||||||
if (prefill != null) {
|
if (prefill != null) {
|
||||||
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
|
inputState.replaceComposerDraft(prefill)
|
||||||
viewModel.consumePendingPrefill()
|
viewModel.consumePendingPrefill()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(state.composerTextToRestore) {
|
LaunchedEffect(state.composerTextToRestore) {
|
||||||
state.composerTextToRestore?.let { text ->
|
state.composerTextToRestore?.let { text ->
|
||||||
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
inputState.restoreRejectedComposerDraft(text)
|
||||||
viewModel.consumeComposerTextRestore()
|
viewModel.consumeComposerTextRestore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +368,10 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val imagePaste =
|
||||||
|
rememberChatImagePaste(viewModel) { message ->
|
||||||
|
scrollScope.launch { snackbarHostState.showSnackbar(message) }
|
||||||
|
}
|
||||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||||
@@ -411,8 +419,7 @@ fun ChatScreen(
|
|||||||
|
|
||||||
val mediaLaunchers =
|
val mediaLaunchers =
|
||||||
rememberChatMediaLaunchers(
|
rememberChatMediaLaunchers(
|
||||||
inputFieldValue = inputFieldValue,
|
inputState = inputState,
|
||||||
onInputFieldValueChange = { inputFieldValue = it },
|
|
||||||
onAddAttachment = { uri, name, mimeType, size ->
|
onAddAttachment = { uri, name, mimeType, size ->
|
||||||
viewModel.addAttachment(uri, name, mimeType, size)
|
viewModel.addAttachment(uri, name, mimeType, size)
|
||||||
},
|
},
|
||||||
@@ -596,20 +603,23 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.testTag("chat_menu_search"),
|
modifier = Modifier.testTag("chat_menu_search"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenuItem(
|
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
|
||||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
|
||||||
leadingIcon = {
|
DropdownMenuItem(
|
||||||
Icon(
|
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||||
imageVector = Icons.Filled.Extension,
|
leadingIcon = {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
)
|
imageVector = Icons.Filled.Extension,
|
||||||
},
|
contentDescription = null,
|
||||||
onClick = {
|
)
|
||||||
showSessionMenu = false
|
},
|
||||||
connectorsViewModel.show()
|
onClick = {
|
||||||
},
|
showSessionMenu = false
|
||||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
connectorsViewModel.show()
|
||||||
)
|
},
|
||||||
|
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||||
|
)
|
||||||
|
}
|
||||||
ChatBackgroundConnectionMenuItem(
|
ChatBackgroundConnectionMenuItem(
|
||||||
checked = settingsState.keepConnectedInBackground,
|
checked = settingsState.keepConnectedInBackground,
|
||||||
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
||||||
@@ -785,7 +795,7 @@ fun ChatScreen(
|
|||||||
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||||
},
|
},
|
||||||
onShare = { details ->
|
onShare = { details ->
|
||||||
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
|
val report = "AITURK Mobil ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||||
val intent =
|
val intent =
|
||||||
Intent(Intent.ACTION_SEND).apply {
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
@@ -859,15 +869,18 @@ fun ChatScreen(
|
|||||||
mainTurnBusy = state.isMainTurnBusy,
|
mainTurnBusy = state.isMainTurnBusy,
|
||||||
onSendAgain = viewModel::sendQueuedNow,
|
onSendAgain = viewModel::sendQueuedNow,
|
||||||
onDiscard = viewModel::discardPendingSend,
|
onDiscard = viewModel::discardPendingSend,
|
||||||
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onImageClick = { viewingImage = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = inputFieldValue,
|
inputState = inputState,
|
||||||
onInputChange = { inputFieldValue = it },
|
receiveContentListener = imagePaste,
|
||||||
|
isReceivingContent = imagePaste.isReceiving,
|
||||||
onSend = {
|
onSend = {
|
||||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
|
||||||
inputFieldValue = TextFieldValue("")
|
inputState.clearText()
|
||||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||||
scrollController.jumpToBottom(animated = true)
|
scrollController.jumpToBottom(animated = true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
||||||
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
||||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
||||||
@@ -100,7 +101,7 @@ class ChatSearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.TOOL -> {
|
MessageRole.TOOL -> {
|
||||||
scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageRole.SYSTEM -> {
|
MessageRole.SYSTEM -> {
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
|
||||||
|
* page containing it proves delivery even when the merge could not alias the local bubble.
|
||||||
|
*/
|
||||||
|
internal fun pendingSendIdsConfirmedByRowIds(
|
||||||
|
pageRowIds: Set<Long>,
|
||||||
|
pending: List<PendingSend>,
|
||||||
|
): Set<String> =
|
||||||
|
pending
|
||||||
|
.asSequence()
|
||||||
|
.filter {
|
||||||
|
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||||
|
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
|
||||||
|
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
|
||||||
|
|
||||||
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
||||||
class ChatSendStore(
|
class ChatSendStore(
|
||||||
private val prefs: SharedPreferences? = null,
|
private val prefs: SharedPreferences? = null,
|
||||||
@@ -158,9 +177,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
|
|||||||
val recoveryIds =
|
val recoveryIds =
|
||||||
pending
|
pending
|
||||||
.filter {
|
.filter {
|
||||||
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
|
it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
|
||||||
it.state == PendingSendState.REJECTED
|
|
||||||
}.mapTo(mutableSetOf()) { it.id }
|
}.mapTo(mutableSetOf()) { it.id }
|
||||||
if (recoveryIds.isEmpty()) return messages
|
if (recoveryIds.isEmpty()) return messages
|
||||||
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */
|
||||||
|
internal val PendingSend.needsRecovery: Boolean
|
||||||
|
get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED
|
||||||
|
|||||||
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
|
|||||||
rawContent
|
rawContent
|
||||||
}
|
}
|
||||||
var attachments: List<Attachment>? = null
|
var attachments: List<Attachment>? = null
|
||||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
if (role == MessageRole.USER) {
|
||||||
|
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
|
||||||
|
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||||
val items = HostMediaExtractor.extract(rawContent)
|
val items = HostMediaExtractor.extract(rawContent)
|
||||||
if (items.isNotEmpty()) {
|
if (items.isNotEmpty()) {
|
||||||
finalContent = HostMediaExtractor.strip(rawContent)
|
finalContent = HostMediaExtractor.strip(rawContent)
|
||||||
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
|
|||||||
completionId = completionId,
|
completionId = completionId,
|
||||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||||
reactions = msg.reactions,
|
reactions = msg.reactions,
|
||||||
),
|
).normalizedGatewayNotice(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
|
|||||||
match.id to
|
match.id to
|
||||||
rich.copy(
|
rich.copy(
|
||||||
id = match.id,
|
id = match.id,
|
||||||
|
attachments =
|
||||||
|
if (rich.role == MessageRole.USER) {
|
||||||
|
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
|
||||||
|
} else {
|
||||||
|
rich.attachments
|
||||||
|
},
|
||||||
content = preservedContent ?: rich.content,
|
content = preservedContent ?: rich.content,
|
||||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||||
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
|
|||||||
when {
|
when {
|
||||||
match?.role == MessageRole.USER -> {
|
match?.role == MessageRole.USER -> {
|
||||||
match.copy(
|
match.copy(
|
||||||
|
// #1432: cached user rows lack attachment metadata; hydrate from REST,
|
||||||
|
// while preserving richer optimistic/local attachments when present.
|
||||||
|
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
|
||||||
|
content =
|
||||||
|
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
|
||||||
|
message.content
|
||||||
|
} else {
|
||||||
|
match.content
|
||||||
|
},
|
||||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||||
reactions = message.reactions.ifEmpty { match.reactions },
|
reactions = message.reactions.ifEmpty { match.reactions },
|
||||||
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
|
|||||||
.mapNotNull { index ->
|
.mapNotNull { index ->
|
||||||
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||||
}.toMap()
|
}.toMap()
|
||||||
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
return dedupeCachedMessages(
|
||||||
.reconcileReasoningRows()
|
transcript.inTranscriptOrder(
|
||||||
|
previous = current,
|
||||||
|
resolvedOrders = resolvedOrders,
|
||||||
|
observedSuccessorAnchors = current,
|
||||||
|
),
|
||||||
|
confirmedOnly = true,
|
||||||
|
).reconcileReasoningRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
||||||
private fun List<ChatMessage>.inTranscriptOrder(
|
private fun List<ChatMessage>.inTranscriptOrder(
|
||||||
previous: List<ChatMessage>,
|
previous: List<ChatMessage>,
|
||||||
resolvedOrders: Map<String, Long>,
|
resolvedOrders: Map<String, Long>,
|
||||||
|
observedSuccessorAnchors: List<ChatMessage>? = null,
|
||||||
): List<ChatMessage> {
|
): List<ChatMessage> {
|
||||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||||
// Session-start markers use -1 and must remain before unresolved cached history.
|
// Session-start markers use -1 and must remain before unresolved cached history.
|
||||||
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
var pendingLocalOrder: Long? = null
|
var pendingLocalOrder: Long? = null
|
||||||
val localAnchors = mutableMapOf<String, Long>()
|
val localAnchors = mutableMapOf<String, Long>()
|
||||||
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
||||||
|
// #1451: an observed live block must stay before its later confirmed USER successor,
|
||||||
|
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
|
||||||
|
// observed current lists (never cache concatenations) and only from later USER prompts.
|
||||||
|
val nextCanonicalOrder = mutableMapOf<String, Long>()
|
||||||
|
val anchorSource = observedSuccessorAnchors ?: emptyList()
|
||||||
|
var followingUserCanonical: Long? = null
|
||||||
|
for (i in anchorSource.indices.reversed()) {
|
||||||
|
val message = anchorSource[i]
|
||||||
|
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||||
|
if (order != null && message.role == MessageRole.USER) {
|
||||||
|
followingUserCanonical = order
|
||||||
|
} else if (followingUserCanonical != null) {
|
||||||
|
nextCanonicalOrder[message.id] = followingUserCanonical
|
||||||
|
}
|
||||||
|
}
|
||||||
previous.forEach { message ->
|
previous.forEach { message ->
|
||||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||||
if (order != null) {
|
if (order != null) {
|
||||||
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
} else if (message.isPermanentlyLocal()) {
|
} else if (message.isPermanentlyLocal()) {
|
||||||
localAnchors[message.id] =
|
localAnchors[message.id] =
|
||||||
if (hasPendingPredecessor) {
|
if (hasPendingPredecessor) {
|
||||||
Long.MAX_VALUE
|
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
|
||||||
} else {
|
} else {
|
||||||
precedingCanonical?.takeIf { it >= 0L }
|
precedingCanonical?.takeIf { it >= 0L }
|
||||||
?: if (message.role == MessageRole.USER) {
|
?: if (message.role == MessageRole.USER) {
|
||||||
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
|||||||
} else {
|
} else {
|
||||||
hasPendingPredecessor = true
|
hasPendingPredecessor = true
|
||||||
pendingLocalOrder = message.localOrder
|
pendingLocalOrder = message.localOrder
|
||||||
|
nextCanonicalOrder[message.id]?.let { successor ->
|
||||||
|
localAnchors[message.id] = successor - 1L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
@@ -41,6 +42,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
|
|||||||
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||||
|
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||||
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
||||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||||
@@ -165,7 +167,7 @@ data class ChatUiState(
|
|||||||
val messages: List<ChatMessage> = emptyList(),
|
val messages: List<ChatMessage> = emptyList(),
|
||||||
val currentSessionId: String? = null,
|
val currentSessionId: String? = null,
|
||||||
val sessions: List<SessionUi> = emptyList(),
|
val sessions: List<SessionUi> = emptyList(),
|
||||||
val chatTitle: String = "Hermes",
|
val chatTitle: String = "AITURK",
|
||||||
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||||
val isAgentTyping: Boolean = false,
|
val isAgentTyping: Boolean = false,
|
||||||
val isMainTurnBusy: Boolean = false,
|
val isMainTurnBusy: Boolean = false,
|
||||||
@@ -543,6 +545,7 @@ class ChatViewModel(
|
|||||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||||
|
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
|
||||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||||
private val pendingSendReservationLock = Any()
|
private val pendingSendReservationLock = Any()
|
||||||
@@ -918,7 +921,7 @@ class ChatViewModel(
|
|||||||
.collect { _ ->
|
.collect { _ ->
|
||||||
pendingGoneSessionNotice = false
|
pendingGoneSessionNotice = false
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Same wipe when the CONNECTION profile changes (different server):
|
// Same wipe when the CONNECTION profile changes (different server):
|
||||||
@@ -930,7 +933,7 @@ class ChatViewModel(
|
|||||||
.collect { _ ->
|
.collect { _ ->
|
||||||
pendingGoneSessionNotice = false
|
pendingGoneSessionNotice = false
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Slash-command usage ranking (issue #865): mirror the local usage
|
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||||
@@ -1005,7 +1008,7 @@ class ChatViewModel(
|
|||||||
// even when the route looks already active).
|
// even when the route looks already active).
|
||||||
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
|
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
|
||||||
cancelResumeRetry()
|
cancelResumeRetry()
|
||||||
addSystemMessage("Connected to Hermes")
|
addSystemMessage("Connected to AITURK")
|
||||||
loadSessions()
|
loadSessions()
|
||||||
fetchCommandCatalog()
|
fetchCommandCatalog()
|
||||||
modelSwitchDelegate.preloadModelOptions()
|
modelSwitchDelegate.preloadModelOptions()
|
||||||
@@ -1510,6 +1513,10 @@ class ChatViewModel(
|
|||||||
viewModelScope.launch { fetchContextUsage() }
|
viewModelScope.launch { fetchContextUsage() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ReducerEffect.DeleteLocalMessage -> {
|
||||||
|
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
|
||||||
|
}
|
||||||
|
|
||||||
is ReducerEffect.AttachHostMedia -> {
|
is ReducerEffect.AttachHostMedia -> {
|
||||||
// Issue #724: turn host-path MEDIA: directives into real
|
// Issue #724: turn host-path MEDIA: directives into real
|
||||||
// attachments (images inline, every other file tappable)
|
// attachments (images inline, every other file tappable)
|
||||||
@@ -1702,6 +1709,17 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
accepted -> {
|
accepted -> {
|
||||||
|
// #1427: a gateway-queued prompt belongs to a future turn, not the
|
||||||
|
// completion currently on screen. Keep it live until that turn is verified.
|
||||||
|
val receipt = sendStore.all().firstOrNull { it.id == messageId }
|
||||||
|
acceptedTurnEpochById[messageId] =
|
||||||
|
if (status == "queued" ||
|
||||||
|
(!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt))
|
||||||
|
) {
|
||||||
|
mainTurnEpoch + 1
|
||||||
|
} else {
|
||||||
|
mainTurnEpoch
|
||||||
|
}
|
||||||
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
||||||
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
||||||
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
||||||
@@ -1786,12 +1804,14 @@ class ChatViewModel(
|
|||||||
isSessionReady = true,
|
isSessionReady = true,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
|
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
|
||||||
chatTitle = "Hermes",
|
chatTitle = "AITURK",
|
||||||
usedContextTokens = null,
|
usedContextTokens = null,
|
||||||
fullContextTokens = null,
|
fullContextTokens = null,
|
||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
sessionUsage = null,
|
sessionUsage = null,
|
||||||
|
isCompressing = false,
|
||||||
|
compressionStatus = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
@@ -1853,7 +1873,7 @@ class ChatViewModel(
|
|||||||
val generation =
|
val generation =
|
||||||
resetSessionState(
|
resetSessionState(
|
||||||
sessionId = storageId,
|
sessionId = storageId,
|
||||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "Hermes",
|
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "AITURK",
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
)
|
)
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
@@ -2298,7 +2318,7 @@ class ChatViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error ($method): $errorMsg",
|
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
|
||||||
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2631,12 +2651,14 @@ class ChatViewModel(
|
|||||||
state: PendingSendState,
|
state: PendingSendState,
|
||||||
) {
|
) {
|
||||||
sendStore.update(messageId) { it.copy(state = state) }
|
sendStore.update(messageId) { it.copy(state = state) }
|
||||||
|
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
||||||
if (state != PendingSendState.SENDING) drainPendingQueue()
|
if (state != PendingSendState.SENDING) drainPendingQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removePendingSend(messageId: String) {
|
private fun removePendingSend(messageId: String) {
|
||||||
|
acceptedTurnEpochById.remove(messageId)
|
||||||
sendStore.remove(messageId)
|
sendStore.remove(messageId)
|
||||||
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
@@ -3662,6 +3684,34 @@ class ChatViewModel(
|
|||||||
|
|
||||||
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
||||||
|
|
||||||
|
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
|
||||||
|
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
|
||||||
|
val state = _uiState.value
|
||||||
|
val sessionId = state.currentSessionId ?: return null
|
||||||
|
// Connection status is combined into public uiState, not written to _uiState.
|
||||||
|
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
|
||||||
|
!state.isSessionReady || _timelineState.value.isHistorical
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ChatAttachmentTarget(
|
||||||
|
sessionId = sessionId,
|
||||||
|
generation = sessionGeneration,
|
||||||
|
baseUrl = AuthManager.getBaseUrl(),
|
||||||
|
connectionProfileId = AuthManager.getSelectedProfileId(),
|
||||||
|
agentProfileId = AuthManager.activeProfileId.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun addPastedAttachments(
|
||||||
|
target: ChatAttachmentTarget,
|
||||||
|
attachments: List<Attachment>,
|
||||||
|
): Boolean {
|
||||||
|
if (captureAttachmentTarget() != target) return false
|
||||||
|
attachmentsDelegate.addAttachments(attachments)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||||
|
|
||||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||||
@@ -4113,44 +4163,46 @@ class ChatViewModel(
|
|||||||
val arg = parts.getOrElse(1) { "" }
|
val arg = parts.getOrElse(1) { "" }
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
try {
|
try {
|
||||||
// Primary path: command.dispatch handles quick/plugin/bundle/
|
// Desktop parity: slash.exec owns generic commands. Handle errors here,
|
||||||
// skill commands + a few hardcoded ones. It returns a hard 4018
|
// not again through the shared RpcError banner (/usage regression).
|
||||||
// "not a ... command" for everything that lives only in the TUI
|
|
||||||
// slash worker (the 29 commands that 4018'd on mobile — issue
|
|
||||||
// #576). For those we fall back to slash.exec, which runs the
|
|
||||||
// full COMMAND_REGISTRY through the worker.
|
|
||||||
val result =
|
val result =
|
||||||
wsClient.call(
|
wsClient.call(
|
||||||
RpcMethods.COMMAND_DISPATCH,
|
RpcMethods.SLASH_EXEC,
|
||||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
|
||||||
|
suppressErrorEvent = true,
|
||||||
)
|
)
|
||||||
handleDispatchResult(result.toAny())
|
val map = result.toAny() as? Map<*, *>
|
||||||
} catch (e: HermesWsClient.HermesRpcException) {
|
if (map?.get("type") is String) {
|
||||||
val msg = e.message.orEmpty()
|
handleDispatchResult(map)
|
||||||
// Registry miss on command.dispatch: the backend emits exactly
|
|
||||||
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
|
|
||||||
// server.py L12408). Match that precise phrase so unrelated
|
|
||||||
// errors can't accidentally trigger the slash.exec fallback.
|
|
||||||
if (msg.contains("not a quick/plugin/bundle/skill command")) {
|
|
||||||
// Registry miss on command.dispatch -> retry via slash.exec,
|
|
||||||
// which routes the full CLI command set through the worker.
|
|
||||||
try {
|
|
||||||
val result =
|
|
||||||
wsClient.call(
|
|
||||||
RpcMethods.SLASH_EXEC,
|
|
||||||
SlashExecParams(
|
|
||||||
sessionId = sessionId,
|
|
||||||
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
|
|
||||||
if (!output.isNullOrBlank()) addAssistantMessage(output)
|
|
||||||
} catch (e2: HermesWsClient.HermesRpcException) {
|
|
||||||
addAssistantMessage("/$name: ${e2.message}")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Legit error from command.dispatch (busy, no history, etc.)
|
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
|
||||||
addAssistantMessage("/$name: ${e.message}")
|
val warning = (map?.get("warning") as? String).orEmpty()
|
||||||
|
addAssistantMessage(if (warning.isBlank()) output else "warning: $warning\n$output")
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
val result =
|
||||||
|
wsClient.call(
|
||||||
|
RpcMethods.COMMAND_DISPATCH,
|
||||||
|
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||||
|
suppressErrorEvent = true,
|
||||||
|
)
|
||||||
|
val map = result.toAny() as? Map<*, *>
|
||||||
|
if (map?.get("type") is String) {
|
||||||
|
handleDispatchResult(map)
|
||||||
|
} else {
|
||||||
|
addAssistantMessage("/$name: invalid response: command.dispatch")
|
||||||
|
}
|
||||||
|
} catch (fallback: CancellationException) {
|
||||||
|
throw fallback
|
||||||
|
} catch (fallback: Exception) {
|
||||||
|
val registryMiss =
|
||||||
|
Regex("not a quick/plugin/(?:bundle/)?skill command", RegexOption.IGNORE_CASE)
|
||||||
|
.containsMatchIn(fallback.message.orEmpty())
|
||||||
|
val failure = if (registryMiss) e else fallback
|
||||||
|
addAssistantMessage("/$name: ${failure.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4516,11 +4568,11 @@ class ChatViewModel(
|
|||||||
fun createNewSession(setLoading: Boolean = true) {
|
fun createNewSession(setLoading: Boolean = true) {
|
||||||
// A fresh create has no persisted row until the first prompt.
|
// A fresh create has no persisted row until the first prompt.
|
||||||
sessionHasServerPresence = false
|
sessionHasServerPresence = false
|
||||||
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading)
|
val generation = resetSessionState(sessionId = null, title = "AITURK", isLoading = setLoading)
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
wsClient.send(
|
wsClient.send(
|
||||||
RpcMethods.SESSION_CREATE,
|
RpcMethods.SESSION_CREATE,
|
||||||
SessionCreateParams(source = "desktop"),
|
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
|
||||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4904,7 +4956,7 @@ class ChatViewModel(
|
|||||||
val title =
|
val title =
|
||||||
_uiState.value.sessions
|
_uiState.value.sessions
|
||||||
.find { it.id == sessionId }
|
.find { it.id == sessionId }
|
||||||
?.title ?: "Hermes"
|
?.title ?: "AITURK"
|
||||||
val generation = resetSessionState(sessionId, title, isLoading = true)
|
val generation = resetSessionState(sessionId, title, isLoading = true)
|
||||||
AuthManager.setLastOpenedSessionId(sessionId)
|
AuthManager.setLastOpenedSessionId(sessionId)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -5056,7 +5108,22 @@ class ChatViewModel(
|
|||||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||||
val valid = { isCurrent() && cacheCursor == before }
|
val valid = { isCurrent() && cacheCursor == before }
|
||||||
if (!valid()) return false
|
if (!valid()) return false
|
||||||
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
|
mergeHistoryPage(valid, cached = true) {
|
||||||
|
// #1432: Room retains host references, not connection-bound download URLs.
|
||||||
|
page.messages.map { message ->
|
||||||
|
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
|
||||||
|
message.copy(
|
||||||
|
attachments =
|
||||||
|
userImageAttachments(
|
||||||
|
message.content,
|
||||||
|
::gatewayMediaUrl,
|
||||||
|
).takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: return false
|
||||||
if (!valid()) return false
|
if (!valid()) return false
|
||||||
cacheCursor = page.cursor
|
cacheCursor = page.cursor
|
||||||
cacheHasOlder = page.hasOlder
|
cacheHasOlder = page.hasOlder
|
||||||
@@ -5202,7 +5269,11 @@ class ChatViewModel(
|
|||||||
val scope = sendScope()
|
val scope = sendScope()
|
||||||
val scopedPending =
|
val scopedPending =
|
||||||
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
||||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
|
val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
|
||||||
|
(
|
||||||
|
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
|
||||||
|
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
|
||||||
|
).forEach(::removePendingSend)
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
drainPendingQueue()
|
drainPendingQueue()
|
||||||
}
|
}
|
||||||
@@ -5310,6 +5381,9 @@ class ChatViewModel(
|
|||||||
contextBreakdown = null,
|
contextBreakdown = null,
|
||||||
compressionCount = null,
|
compressionCount = null,
|
||||||
sessionUsage = null,
|
sessionUsage = null,
|
||||||
|
// #1433: compaction state belongs to the session being left.
|
||||||
|
isCompressing = false,
|
||||||
|
compressionStatus = null,
|
||||||
pendingAttachments = emptyList(),
|
pendingAttachments = emptyList(),
|
||||||
composerTextToRestore = null,
|
composerTextToRestore = null,
|
||||||
reactionKind = null,
|
reactionKind = null,
|
||||||
@@ -5338,6 +5412,7 @@ class ChatViewModel(
|
|||||||
val params =
|
val params =
|
||||||
SessionResumeParams(
|
SessionResumeParams(
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
source = DESKTOP_SESSION_SOURCE,
|
||||||
omitMessages = true,
|
omitMessages = true,
|
||||||
profile = profile?.takeIf { it.isNotBlank() },
|
profile = profile?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
@@ -5648,6 +5723,17 @@ class ChatViewModel(
|
|||||||
val requestSequence = activeHydrationRequestSequence
|
val requestSequence = activeHydrationRequestSequence
|
||||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||||
val useLatest = latestPaging
|
val useLatest = latestPaging
|
||||||
|
// #1427: acceptance alone is normal live UX. Only a completed turn followed by
|
||||||
|
// successful history verification can move its still-unmatched receipts into recovery.
|
||||||
|
// Capture the rows before suspending so a later prompt/retry cannot be quarantined.
|
||||||
|
val completionAt = lastMainCompletionAt
|
||||||
|
val completedTurnEpoch = mainTurnEpoch
|
||||||
|
val unverifiedAccepted =
|
||||||
|
sendStore.all().filter {
|
||||||
|
it.scope == sendScope() && it.sessionId == sessionId &&
|
||||||
|
it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt &&
|
||||||
|
acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true
|
||||||
|
}
|
||||||
val nextOffset =
|
val nextOffset =
|
||||||
if (useLatest) {
|
if (useLatest) {
|
||||||
0
|
0
|
||||||
@@ -5682,6 +5768,14 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
persistHistoryPage(page, sessionId)
|
persistHistoryPage(page, sessionId)
|
||||||
|
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
||||||
|
unverifiedAccepted.forEach { receipt ->
|
||||||
|
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
|
||||||
|
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
|
||||||
|
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
||||||
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
||||||
// request may overlap, but stable IDs remove echoes without skipping any history.
|
// request may overlap, but stable IDs remove echoes without skipping any history.
|
||||||
@@ -5945,6 +6039,8 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
// AITURK: an unsaved (empty) chat has no session row yet; skip the 404 poll.
|
||||||
|
if (AiturkCompat.skipSessionDetail(_uiState.value.messages.isEmpty())) return@launch
|
||||||
val usedResult =
|
val usedResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.model.deltaFrom
|
import com.m57.hermescontrol.data.model.deltaFrom
|
||||||
import com.m57.hermescontrol.data.model.mergeWith
|
import com.m57.hermescontrol.data.model.mergeWith
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
@@ -136,6 +137,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||||
|
is WsEvent.StatusUpdate -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||||
@@ -457,6 +459,7 @@ object ChatWsEventReducer {
|
|||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||||
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||||
|
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
|
||||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||||
@@ -995,7 +998,7 @@ object ChatWsEventReducer {
|
|||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error: ${event.error}",
|
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
@@ -1052,36 +1055,107 @@ object ChatWsEventReducer {
|
|||||||
event: WsEvent.StatusUpdate,
|
event: WsEvent.StatusUpdate,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val kind = event.data?.get("kind") as? String
|
val kind = event.data?.get("kind") as? String
|
||||||
val text = event.data?.get("text") as? String
|
val text = (event.data?.get("text") as? String)?.trim()
|
||||||
return when (kind) {
|
return when (kind) {
|
||||||
|
// In-progress compaction is live state: the tail indicator shows only while it runs.
|
||||||
"compressing", "compacting" -> {
|
"compressing", "compacting" -> {
|
||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
isCompressing = true,
|
isCompressing = true,
|
||||||
compressionStatus = text ?: "⏳ Compressing context...",
|
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
|
||||||
"compacted" -> {
|
"compacted" -> {
|
||||||
|
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
"ready" -> {
|
||||||
ReducerResult(
|
ReducerResult(
|
||||||
state =
|
state = state.copy(isCompressing = false, compressionStatus = null),
|
||||||
state.copy(
|
|
||||||
isCompressing = false,
|
|
||||||
compressionStatus = text,
|
|
||||||
),
|
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Durable notices the desktop/TUI also paint into the transcript.
|
||||||
|
"goal", "loop", "warn", "fallback" -> {
|
||||||
|
appendGatewayNotice(state, streamingState, text)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
ReducerResult(state = state, streamingState = streamingState)
|
ReducerResult(state = state, streamingState = streamingState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
|
||||||
|
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
|
||||||
|
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
|
||||||
|
* skip it rather than stacking a duplicate.
|
||||||
|
*/
|
||||||
|
private fun appendGatewayNotice(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
text: String?,
|
||||||
|
extraEffects: List<ReducerEffect> = emptyList(),
|
||||||
|
): ReducerResult {
|
||||||
|
if (text.isNullOrBlank()) {
|
||||||
|
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||||
|
}
|
||||||
|
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
|
||||||
|
val duplicate =
|
||||||
|
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
|
||||||
|
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||||
|
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||||
|
val sid = state.currentSessionId
|
||||||
|
return ReducerResult(
|
||||||
|
state = state.copy(messages = state.messages + notice),
|
||||||
|
streamingState = streamingState,
|
||||||
|
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
|
||||||
|
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
|
||||||
|
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
|
||||||
|
*/
|
||||||
|
private fun onInterruptNotice(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.MessageComplete,
|
||||||
|
): ReducerResult {
|
||||||
|
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
|
val text = event.text.trim()
|
||||||
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
|
var messages = usageState.messages
|
||||||
|
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
|
||||||
|
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
|
||||||
|
if (looped.isNotEmpty()) {
|
||||||
|
messages = messages.filterNot { it.id in looped }
|
||||||
|
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Other exits keep any real partial the user already saw.
|
||||||
|
streamingState.streamingMessage
|
||||||
|
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
|
||||||
|
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
|
||||||
|
?.let { partial ->
|
||||||
|
messages = messages.upsertById(partial)
|
||||||
|
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
effects += ReducerEffect.RefreshSessions
|
||||||
|
effects += ReducerEffect.RefreshContextUsage
|
||||||
|
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
|
||||||
|
return appendGatewayNotice(settled, StreamingState(), text, effects)
|
||||||
|
}
|
||||||
|
|
||||||
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
||||||
private fun onMessageReaction(
|
private fun onMessageReaction(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
@@ -1293,6 +1367,11 @@ sealed class ReducerEffect {
|
|||||||
|
|
||||||
data object RefreshContextUsage : ReducerEffect()
|
data object RefreshContextUsage : ReducerEffect()
|
||||||
|
|
||||||
|
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
|
||||||
|
data class DeleteLocalMessage(
|
||||||
|
val messageId: String,
|
||||||
|
) : ReducerEffect()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
||||||
* just-finalized assistant message into real [Attachment]s (images inline,
|
* just-finalized assistant message into real [Attachment]s (images inline,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
|
||||||
|
*
|
||||||
|
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
|
||||||
|
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
|
||||||
|
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
|
||||||
|
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
|
||||||
|
*/
|
||||||
|
internal object GatewayNotice {
|
||||||
|
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
|
||||||
|
|
||||||
|
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
|
||||||
|
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
|
||||||
|
|
||||||
|
fun isInterruptSentinel(text: String): Boolean {
|
||||||
|
val trimmed = text.trim()
|
||||||
|
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
|
||||||
|
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
|
||||||
|
*/
|
||||||
|
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
|
||||||
|
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
|
||||||
|
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ object MediaExportHelper {
|
|||||||
if (length != null && length > 0) {
|
if (length != null && length > 0) {
|
||||||
put(MediaStore.MediaColumns.SIZE, length)
|
put(MediaStore.MediaColumns.SIZE, length)
|
||||||
}
|
}
|
||||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
|
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/AITURK")
|
||||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ object MediaImageStore {
|
|||||||
val safe = sanitizeName(displayName)
|
val safe = sanitizeName(displayName)
|
||||||
val (collection, relativePath) =
|
val (collection, relativePath) =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/Hermes"
|
MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/AITURK"
|
||||||
} else {
|
} else {
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
|||||||
openFileUriStream(uriString, fallbackMime, consumer)
|
openFileUriStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
uriString.startsWith("/") -> {
|
File(uriString).isAbsolute -> {
|
||||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
|||||||
fallbackMime: String,
|
fallbackMime: String,
|
||||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||||
): T {
|
): T {
|
||||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
val file =
|
||||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
runCatching { File(java.net.URI(uriString)) }.getOrElse {
|
||||||
|
val parsed = Uri.parse(uriString).path ?: throw IOException("Invalid file URI: $uriString")
|
||||||
|
File(parsed)
|
||||||
|
}
|
||||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||||
return file.inputStream().use { inputStream ->
|
return file.inputStream().use { inputStream ->
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ sealed class SlashResult {
|
|||||||
/** Create a new session (client-side immediate). */
|
/** Create a new session (client-side immediate). */
|
||||||
data object NewSession : SlashResult()
|
data object NewSession : SlashResult()
|
||||||
|
|
||||||
/** Forward to command.dispatch via WebSocket. */
|
/** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
|
||||||
data object RpcDispatch : SlashResult()
|
data object RpcDispatch : SlashResult()
|
||||||
|
|
||||||
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
|
|||||||
import com.m57.hermescontrol.theme.LightOnSurface
|
import com.m57.hermescontrol.theme.LightOnSurface
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||||
|
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -81,6 +84,7 @@ fun UserBubble(
|
|||||||
messageStatsEnabled: Boolean = false,
|
messageStatsEnabled: Boolean = false,
|
||||||
showUserMessageTokens: Boolean = true,
|
showUserMessageTokens: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
pendingSendState: PendingSendState? = null,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
val maxBubbleWidth = screenWidth * 0.80f
|
val maxBubbleWidth = screenWidth * 0.80f
|
||||||
@@ -99,18 +103,36 @@ fun UserBubble(
|
|||||||
var copied by rememberCopyFeedback()
|
var copied by rememberCopyFeedback()
|
||||||
|
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
|
val deliveryState =
|
||||||
|
when {
|
||||||
|
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
|
||||||
|
|
||||||
|
pendingSendState == PendingSendState.ACCEPTED ||
|
||||||
|
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
|
||||||
|
// Keep the original content for Copy; only the layout input is bounded.
|
||||||
|
val displayContent =
|
||||||
|
remember(message.content, message.attachments) {
|
||||||
|
// #1432: the path is only hidden when its image renders as an attachment below.
|
||||||
|
val visible =
|
||||||
|
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
|
||||||
|
ToolJson.clampForDisplay(visible)
|
||||||
|
}
|
||||||
val highlightedText =
|
val highlightedText =
|
||||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
|
||||||
if (searchQuery.isNotBlank()) {
|
if (searchQuery.isNotBlank()) {
|
||||||
buildHighlightedString(
|
buildHighlightedString(
|
||||||
message.content,
|
displayContent,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isCurrentMatch,
|
isCurrentMatch,
|
||||||
statusColors,
|
statusColors,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AnnotatedString(message.content)
|
AnnotatedString(displayContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@@ -152,7 +174,7 @@ fun UserBubble(
|
|||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
|
||||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||||
@@ -218,6 +240,11 @@ fun UserBubble(
|
|||||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
|
DeliveryChecks(
|
||||||
|
state = deliveryState,
|
||||||
|
tint = userBubbleTextColor,
|
||||||
|
messageId = message.id,
|
||||||
|
)
|
||||||
if (messageStatsEnabled && showUserMessageTokens &&
|
if (messageStatsEnabled && showUserMessageTokens &&
|
||||||
message.tokenCount != null && message.tokenCount > 0
|
message.tokenCount != null && message.tokenCount > 0
|
||||||
) {
|
) {
|
||||||
@@ -250,3 +277,24 @@ fun UserBubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
|
||||||
|
private enum class DeliveryState { SENT, DELIVERED }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeliveryChecks(
|
||||||
|
state: DeliveryState?,
|
||||||
|
tint: Color,
|
||||||
|
messageId: String,
|
||||||
|
) {
|
||||||
|
if (state == null) return
|
||||||
|
val delivered = state == DeliveryState.DELIVERED
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
|
||||||
|
contentDescription =
|
||||||
|
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
|
||||||
|
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
|
||||||
|
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses user-authored or server-persisted `@image:` directives from message content into
|
||||||
|
* gateway-backed [Attachment]s.
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
|
||||||
|
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
|
||||||
|
* - Ignores empty or relative paths (must start with '/').
|
||||||
|
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
|
||||||
|
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
|
||||||
|
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
|
||||||
|
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
|
||||||
|
*/
|
||||||
|
internal fun userImageAttachments(
|
||||||
|
content: String,
|
||||||
|
mediaUrl: (String) -> String?,
|
||||||
|
): List<Attachment> {
|
||||||
|
if (!content.contains("@image:")) return emptyList()
|
||||||
|
|
||||||
|
val seenPaths = mutableSetOf<String>()
|
||||||
|
val attachments = mutableListOf<Attachment>()
|
||||||
|
|
||||||
|
for (rawLine in content.lines()) {
|
||||||
|
val line = rawLine.trim()
|
||||||
|
if (!line.startsWith("@image:")) continue
|
||||||
|
val path = line.removePrefix("@image:").trim()
|
||||||
|
if (!path.startsWith("/")) continue
|
||||||
|
if (!seenPaths.add(path)) continue
|
||||||
|
|
||||||
|
val url = mediaUrl(path) ?: continue
|
||||||
|
attachments.add(
|
||||||
|
Attachment(
|
||||||
|
uri = url,
|
||||||
|
name = mediaNameFromPath(path),
|
||||||
|
mimeType = mediaMimeForPath(path),
|
||||||
|
size = 0,
|
||||||
|
gatewayUrl = url,
|
||||||
|
source = AttachmentSource.GATEWAY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
|
||||||
|
internal fun hideImageRefLines(content: String): String {
|
||||||
|
if (!content.contains("@image:")) return content
|
||||||
|
return content
|
||||||
|
.lines()
|
||||||
|
.filterNot { line ->
|
||||||
|
line.trim().let {
|
||||||
|
it.startsWith("@image:") &&
|
||||||
|
it.removePrefix("@image:").trim().startsWith("/")
|
||||||
|
}
|
||||||
|
}.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.content.ReceiveContentListener
|
||||||
|
import androidx.compose.foundation.content.contentReceiver
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.input.InputTransformation
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDirection
|
import androidx.compose.ui.text.style.TextDirection
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
|
|||||||
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun ChatInputBar(
|
fun ChatInputBar(
|
||||||
inputFieldValue: TextFieldValue,
|
inputState: TextFieldState,
|
||||||
onInputChange: (TextFieldValue) -> Unit,
|
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onMicTap: () -> Unit,
|
onMicTap: () -> Unit,
|
||||||
isListening: Boolean,
|
isListening: Boolean,
|
||||||
@@ -127,15 +135,18 @@ fun ChatInputBar(
|
|||||||
isRecordingVoice: Boolean = false,
|
isRecordingVoice: Boolean = false,
|
||||||
amplitudeProvider: () -> Float = { 0f },
|
amplitudeProvider: () -> Float = { 0f },
|
||||||
onStopGeneration: () -> Unit = {},
|
onStopGeneration: () -> Unit = {},
|
||||||
|
receiveContentListener: ReceiveContentListener? = null,
|
||||||
|
isReceivingContent: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
val inputText = inputState.text.toString()
|
||||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||||
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||||
val canSend =
|
val canSend =
|
||||||
pendingReasoningLevel == null &&
|
!isReceivingContent && pendingReasoningLevel == null &&
|
||||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
|
||||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||||
|
|
||||||
// Attachment tray state
|
// Attachment tray state
|
||||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||||
@@ -232,6 +243,16 @@ fun ChatInputBar(
|
|||||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||||
|
if (isReceivingContent) {
|
||||||
|
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("chat_image_paste_progress")
|
||||||
|
.semantics { contentDescription = loadingLabel },
|
||||||
|
)
|
||||||
|
}
|
||||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||||
// TUI-only commands that don't function on mobile (issue #574).
|
// TUI-only commands that don't function on mobile (issue #574).
|
||||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||||
@@ -245,13 +266,13 @@ fun ChatInputBar(
|
|||||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
visible = inputText.startsWith("/") && !inputText.contains(" "),
|
||||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||||
) {
|
) {
|
||||||
val filteredCommands =
|
val filteredCommands =
|
||||||
ChatInputPolicy.sortSlashSuggestions(
|
ChatInputPolicy.sortSlashSuggestions(
|
||||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
|
||||||
slashUsageCounts,
|
slashUsageCounts,
|
||||||
)
|
)
|
||||||
if (filteredCommands.isNotEmpty()) {
|
if (filteredCommands.isNotEmpty()) {
|
||||||
@@ -280,7 +301,7 @@ fun ChatInputBar(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
onClick = { inputState.replaceComposerDraft(cmd) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +362,7 @@ fun ChatInputBar(
|
|||||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
|
||||||
stringResource(R.string.chat_input_placeholder_queue)
|
stringResource(R.string.chat_input_placeholder_queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,9 +377,9 @@ fun ChatInputBar(
|
|||||||
|
|
||||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||||
val inputLayoutDirection =
|
val inputLayoutDirection =
|
||||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
remember(inputText, ambientLayoutDirection) {
|
||||||
BidiUtils.resolveLayoutDirection(
|
BidiUtils.resolveLayoutDirection(
|
||||||
inputFieldValue.text,
|
inputText,
|
||||||
fallback = ambientLayoutDirection,
|
fallback = ambientLayoutDirection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -381,8 +402,7 @@ fun ChatInputBar(
|
|||||||
.heightIn(min = 42.dp),
|
.heightIn(min = 42.dp),
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = inputFieldValue,
|
state = inputState,
|
||||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -392,18 +412,28 @@ fun ChatInputBar(
|
|||||||
.focusRequester(inputFocusRequester)
|
.focusRequester(inputFocusRequester)
|
||||||
.onFocusChanged { focusState ->
|
.onFocusChanged { focusState ->
|
||||||
inputFocused = focusState.isFocused
|
inputFocused = focusState.isFocused
|
||||||
}.testTag("chat_input"),
|
}.testTag("chat_input")
|
||||||
|
.then(
|
||||||
|
if (receiveContentListener != null) {
|
||||||
|
Modifier.contentReceiver(receiveContentListener)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
|
inputTransformation =
|
||||||
|
InputTransformation {
|
||||||
|
if (isRecordingVoice) revertAllChanges()
|
||||||
|
},
|
||||||
textStyle =
|
textStyle =
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = palette.text,
|
color = palette.text,
|
||||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||||
),
|
),
|
||||||
singleLine = false,
|
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
|
||||||
maxLines = 8,
|
|
||||||
cursorBrush = SolidColor(palette.text),
|
cursorBrush = SolidColor(palette.text),
|
||||||
decorationBox = { innerTextField ->
|
decorator = { innerTextField ->
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentAlignment =
|
contentAlignment =
|
||||||
@@ -416,7 +446,7 @@ fun ChatInputBar(
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalLayoutDirection provides inputLayoutDirection,
|
LocalLayoutDirection provides inputLayoutDirection,
|
||||||
) {
|
) {
|
||||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
if (inputText.isEmpty() && !isRecordingVoice) {
|
||||||
Text(
|
Text(
|
||||||
text = placeholderText,
|
text = placeholderText,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||||
|
|
||||||
|
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
|
||||||
|
internal fun TextFieldState.replaceComposerDraft(text: String) {
|
||||||
|
val replacement = ChatInputPolicy.commandFieldValue(text)
|
||||||
|
edit {
|
||||||
|
replace(0, length, replacement.text)
|
||||||
|
selection = replacement.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
|
||||||
|
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
|
||||||
|
val current = TextFieldValue(text.toString(), selection)
|
||||||
|
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
|
||||||
|
edit {
|
||||||
|
replace(0, length, restored.text)
|
||||||
|
selection = restored.selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
|
||||||
|
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
|
||||||
|
if (spokenText.isBlank()) return
|
||||||
|
val current = text.toString()
|
||||||
|
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -70,13 +72,20 @@ fun ChatConnectionBanner(
|
|||||||
color = LocalHermesStatusColors.current.error,
|
color = LocalHermesStatusColors.current.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// AITURK: over the relay, say why the reconnect keeps failing.
|
||||||
|
val aiturkHostOffline by com.m57.hermescontrol.data.aiturk.AiturkRelayStatus.hostOffline
|
||||||
|
.collectAsState()
|
||||||
|
val hostOffline = aiturkHostOffline && connectionStatus == ConnectionStatus.RECONNECTING
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
maxLines = 1,
|
maxLines = if (hostOffline) 3 else 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
text =
|
text =
|
||||||
when (connectionStatus) {
|
when (connectionStatus) {
|
||||||
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting)
|
ConnectionStatus.RECONNECTING ->
|
||||||
|
stringResource(
|
||||||
|
if (hostOffline) R.string.aiturk_status_host_offline else R.string.chat_status_reconnecting,
|
||||||
|
)
|
||||||
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
|
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
|
||||||
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
|
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
|
||||||
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
|
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.content.MediaType
|
||||||
|
import androidx.compose.foundation.content.ReceiveContentListener
|
||||||
|
import androidx.compose.foundation.content.TransferableContent
|
||||||
|
import androidx.compose.foundation.content.consume
|
||||||
|
import androidx.compose.foundation.content.hasMediaType
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImage
|
||||||
|
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
internal const val MAX_PASTED_IMAGE_COUNT = 8
|
||||||
|
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
|
||||||
|
|
||||||
|
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
|
||||||
|
|
||||||
|
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal class ChatImagePasteController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val importImage: suspend (Uri, Long) -> PastedImage?,
|
||||||
|
private val captureTarget: () -> ChatAttachmentTarget?,
|
||||||
|
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
|
||||||
|
private val onError: (Int) -> Unit,
|
||||||
|
private val isImageUri: (Uri) -> Boolean = { false },
|
||||||
|
) : ReceiveContentListener {
|
||||||
|
var isReceiving by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var closed = false
|
||||||
|
private var job: Job? = null
|
||||||
|
private val retainedPayloads = mutableSetOf<Any>()
|
||||||
|
|
||||||
|
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
|
||||||
|
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
|
||||||
|
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
|
||||||
|
if (transferableContent.source != TransferableContent.Source.Clipboard &&
|
||||||
|
transferableContent.source != TransferableContent.Source.Keyboard
|
||||||
|
) {
|
||||||
|
return transferableContent
|
||||||
|
}
|
||||||
|
val clip = transferableContent.clipEntry.clipData
|
||||||
|
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
|
||||||
|
for (index in 0 until clip.itemCount) {
|
||||||
|
val uri = clip.getItemAt(index).uri
|
||||||
|
if (uri?.scheme == "content" && isImageUri(uri)) {
|
||||||
|
uris.add(uri)
|
||||||
|
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!receiveImages(uris, transferableContent)) return transferableContent
|
||||||
|
// Only accepted image candidates are consumed. Text and non-image URIs
|
||||||
|
// remain available to the ordinary text field/other receivers.
|
||||||
|
val accepted = uris.toSet()
|
||||||
|
// An item can carry both image URI and text. Keep that item for the text
|
||||||
|
// field after importing its image; consuming it would drop its text.
|
||||||
|
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
|
||||||
|
internal fun receiveImages(
|
||||||
|
uris: List<Uri>,
|
||||||
|
payload: Any,
|
||||||
|
): Boolean {
|
||||||
|
if (closed || !scope.isActive || uris.isEmpty()) return false
|
||||||
|
if (isReceiving) {
|
||||||
|
onError(R.string.chat_image_paste_busy)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
|
||||||
|
onError(R.string.chat_image_paste_batch_limit)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val target = captureTarget()
|
||||||
|
if (target == null) {
|
||||||
|
onError(R.string.chat_session_not_ready)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
isReceiving = true
|
||||||
|
retainedPayloads.add(payload)
|
||||||
|
job =
|
||||||
|
scope.launch {
|
||||||
|
val images = mutableListOf<PastedImage>()
|
||||||
|
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
|
||||||
|
var committed = false
|
||||||
|
try {
|
||||||
|
for (uri in uris) {
|
||||||
|
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
|
||||||
|
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
|
||||||
|
images += image
|
||||||
|
remainingBytes -= image.file.length()
|
||||||
|
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
|
||||||
|
}
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
if (!closed && captureTarget() == target) {
|
||||||
|
committed = commit(target, images.map { it.attachment })
|
||||||
|
}
|
||||||
|
} catch (cancelled: CancellationException) {
|
||||||
|
throw cancelled
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
if (!closed && captureTarget() == target) {
|
||||||
|
onError(
|
||||||
|
if (failure is PastedImageBatchLimitException ||
|
||||||
|
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
|
||||||
|
) {
|
||||||
|
R.string.chat_image_paste_batch_limit
|
||||||
|
} else if (failure is PastedImageTooLargeException) {
|
||||||
|
R.string.chat_image_paste_too_large
|
||||||
|
} else {
|
||||||
|
R.string.chat_image_paste_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!committed) {
|
||||||
|
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
|
||||||
|
}
|
||||||
|
retainedPayloads.remove(payload)
|
||||||
|
isReceiving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Covers a scope cancelled before the launched coroutine ever starts.
|
||||||
|
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
closed = true
|
||||||
|
job?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
internal fun rememberChatImagePaste(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
onShowMessage: (String) -> Unit,
|
||||||
|
): ChatImagePasteController {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val resources by rememberUpdatedState(LocalResources.current)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val showMessage by rememberUpdatedState(onShowMessage)
|
||||||
|
val controller =
|
||||||
|
remember(viewModel, context, scope) {
|
||||||
|
val store = ChatPastedImageStore(context.applicationContext)
|
||||||
|
ChatImagePasteController(
|
||||||
|
scope = scope,
|
||||||
|
importImage = store::importImage,
|
||||||
|
isImageUri = { uri ->
|
||||||
|
try {
|
||||||
|
context.contentResolver.getType(uri)?.let { type ->
|
||||||
|
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
|
||||||
|
} == true
|
||||||
|
} catch (_: RuntimeException) {
|
||||||
|
// Provider failures must never consume an unclassified URI.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
captureTarget = viewModel::captureAttachmentTarget,
|
||||||
|
commit = viewModel::addPastedAttachments,
|
||||||
|
onError = { showMessage(resources.getString(it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DisposableEffect(controller) { onDispose { controller.close() } }
|
||||||
|
return controller
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
|
||||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||||
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberChatMediaLaunchers(
|
fun rememberChatMediaLaunchers(
|
||||||
inputFieldValue: TextFieldValue,
|
inputState: TextFieldState,
|
||||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
|
||||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||||
onAddAttachments: (List<Attachment>) -> Unit,
|
onAddAttachments: (List<Attachment>) -> Unit,
|
||||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||||
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
|
|||||||
var isListening by remember { mutableStateOf(false) }
|
var isListening by remember { mutableStateOf(false) }
|
||||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
val currentInputState by rememberUpdatedState(inputState)
|
||||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
|
||||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||||
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
|
|||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
if (spokenText.isNotBlank()) {
|
if (spokenText.isNotBlank()) {
|
||||||
val currentText = currentInputFieldValue.text
|
currentInputState.appendSpeechComposerDraft(spokenText)
|
||||||
val merged =
|
|
||||||
if (currentText.isBlank()) {
|
|
||||||
spokenText
|
|
||||||
} else {
|
|
||||||
"$currentText $spokenText"
|
|
||||||
}
|
|
||||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
|
import com.m57.hermescontrol.ui.chat.needsRecovery
|
||||||
|
|
||||||
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
|
|||||||
mainTurnBusy: Boolean,
|
mainTurnBusy: Boolean,
|
||||||
onSendAgain: (String) -> Unit,
|
onSendAgain: (String) -> Unit,
|
||||||
onDiscard: (String) -> Unit,
|
onDiscard: (String) -> Unit,
|
||||||
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (sends.isEmpty()) return
|
val recoverySends = sends.filter { it.needsRecovery }
|
||||||
|
if (recoverySends.isEmpty()) return
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
var retryId by remember { mutableStateOf<String?>(null) }
|
var retryId by remember { mutableStateOf<String?>(null) }
|
||||||
var discardId by remember { mutableStateOf<String?>(null) }
|
var discardId by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
||||||
) {
|
) {
|
||||||
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
|
Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
|
||||||
}
|
}
|
||||||
if (open) {
|
if (open) {
|
||||||
ModalBottomSheet(onDismissRequest = { open = false }) {
|
ModalBottomSheet(onDismissRequest = { open = false }) {
|
||||||
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
|
|||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
||||||
items(sends, key = { it.id }) { send ->
|
items(recoverySends, key = { it.id }) { send ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (send.attachments.isNotEmpty()) {
|
send.attachments.forEach { attachment ->
|
||||||
Text(stringResource(R.string.chat_pending_attachments))
|
if (attachment.isImage || attachment.isAudio || attachment.isVideo) {
|
||||||
|
Text(attachment.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
InlineAttachment(
|
||||||
|
attachment = attachment,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
onOpen = onOpenAttachment,
|
||||||
|
onSave = {},
|
||||||
|
savingPath = null,
|
||||||
|
openingPath = null,
|
||||||
|
canSave = false,
|
||||||
|
onImageClick = onImageClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sends.firstOrNull { it.id == retryId }?.let { send ->
|
recoverySends.firstOrNull { it.id == retryId }?.let { send ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { retryId = null },
|
onDismissRequest = { retryId = null },
|
||||||
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
||||||
@@ -151,7 +170,7 @@ fun PendingSendRecovery(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sends.firstOrNull { it.id == discardId }?.let { send ->
|
recoverySends.firstOrNull { it.id == discardId }?.let { send ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { discardId = null },
|
onDismissRequest = { discardId = null },
|
||||||
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.SearchTarget
|
import com.m57.hermescontrol.ui.chat.SearchTarget
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||||
@@ -57,6 +58,23 @@ private object FullBleedContentType {
|
|||||||
const val SYSTEM_EVENT: String = "system_event"
|
const val SYSTEM_EVENT: String = "system_event"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Only show the placeholder when there are no messages or renderable live tail items. */
|
||||||
|
internal fun shouldShowChatEmptyState(
|
||||||
|
transcript: TranscriptUiState,
|
||||||
|
hasReplyError: Boolean,
|
||||||
|
): Boolean =
|
||||||
|
transcript.messages.isEmpty() &&
|
||||||
|
transcript.streamingState.streamingMessage == null &&
|
||||||
|
!transcript.isLoading &&
|
||||||
|
!transcript.isAgentTyping &&
|
||||||
|
!hasReplyError &&
|
||||||
|
transcript.clarifyRequest == null &&
|
||||||
|
transcript.vaultUnlockPrompt == null &&
|
||||||
|
transcript.vaultSaveLoginPrompt == null &&
|
||||||
|
transcript.vaultCodePrompt == null &&
|
||||||
|
!transcript.isCompressing &&
|
||||||
|
transcript.compressionStatus == null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||||
* surface since the bubble renderer was removed. User messages keep their
|
* surface since the bubble renderer was removed. User messages keep their
|
||||||
@@ -104,7 +122,7 @@ fun FullBleedChatList(
|
|||||||
val isCompressing = transcript.isCompressing
|
val isCompressing = transcript.isCompressing
|
||||||
val compressionStatus = transcript.compressionStatus
|
val compressionStatus = transcript.compressionStatus
|
||||||
val speakingMessageId = transcript.speakingMessageId
|
val speakingMessageId = transcript.speakingMessageId
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -311,6 +329,7 @@ fun FullBleedChatList(
|
|||||||
onImageClick = actions.onImageClick,
|
onImageClick = actions.onImageClick,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
pendingSendState = transcript.pendingSendStates[userMessage.id],
|
||||||
)
|
)
|
||||||
if (userMessage.reactions.isNotEmpty()) {
|
if (userMessage.reactions.isNotEmpty()) {
|
||||||
MessageReactionChips(
|
MessageReactionChips(
|
||||||
@@ -561,6 +580,7 @@ private fun renderUserBubble(
|
|||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
messageStatsEnabled: Boolean,
|
messageStatsEnabled: Boolean,
|
||||||
showUserMessageTokens: Boolean,
|
showUserMessageTokens: Boolean,
|
||||||
|
pendingSendState: PendingSendState?,
|
||||||
) {
|
) {
|
||||||
UserBubble(
|
UserBubble(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -574,5 +594,6 @@ private fun renderUserBubble(
|
|||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
pendingSendState = pendingSendState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
|||||||
displayKind == null &&
|
displayKind == null &&
|
||||||
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||||
|
|
||||||
|
private const val HIDDEN_TOOL_NAME = "react_to_message"
|
||||||
|
|
||||||
|
/** Tool rows that never get a transcript bubble. */
|
||||||
|
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
|
||||||
|
|
||||||
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
|||||||
agentEntries += AgentEntry.Prose(message)
|
agentEntries += AgentEntry.Prose(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message.isHiddenTool() -> {
|
||||||
|
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
|
||||||
|
}
|
||||||
|
|
||||||
MessageRole.TOOL == message.role -> {
|
MessageRole.TOOL == message.role -> {
|
||||||
agentEntries += AgentEntry.ToolRow(message)
|
agentEntries += AgentEntry.ToolRow(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
@@ -37,6 +38,7 @@ data class TranscriptUiState(
|
|||||||
val isCompressing: Boolean,
|
val isCompressing: Boolean,
|
||||||
val compressionStatus: String?,
|
val compressionStatus: String?,
|
||||||
val speakingMessageId: String?,
|
val speakingMessageId: String?,
|
||||||
|
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
|
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
|
||||||
@@ -76,6 +78,8 @@ data class TranscriptUiState(
|
|||||||
isCompressing = !historical && chat.isCompressing,
|
isCompressing = !historical && chat.isCompressing,
|
||||||
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
||||||
speakingMessageId = speakingMessageId,
|
speakingMessageId = speakingMessageId,
|
||||||
|
pendingSendStates =
|
||||||
|
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.cron
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Code
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
@@ -32,12 +35,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TimePicker
|
||||||
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -46,9 +53,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -388,6 +397,12 @@ fun CronJobEditorDialog(
|
|||||||
|
|
||||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||||
|
|
||||||
|
LaunchedEffect(state.isNew) {
|
||||||
|
if (state.isNew && state.schedule.isBlank()) {
|
||||||
|
onFieldChange("schedule", "0 9 * * *")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Dialog(
|
Dialog(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
if (hasChanges && !state.isNew) {
|
if (hasChanges && !state.isNew) {
|
||||||
@@ -416,6 +431,8 @@ fun CronJobEditorDialog(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.cron_edit_title)
|
stringResource(R.string.cron_edit_title)
|
||||||
},
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon =
|
navigationIcon =
|
||||||
@@ -430,13 +447,13 @@ fun CronJobEditorDialog(
|
|||||||
),
|
),
|
||||||
actions = {
|
actions = {
|
||||||
if (!state.isLoading) {
|
if (!state.isLoading) {
|
||||||
IconButton(
|
TextButton(
|
||||||
onClick = onSave,
|
onClick = onSave,
|
||||||
enabled = !state.isSaving,
|
enabled = !state.isSaving,
|
||||||
) {
|
) {
|
||||||
if (state.isSaving) {
|
if (state.isSaving) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.fillMaxSize(0.6f),
|
modifier = Modifier.size(24.dp),
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -504,13 +521,9 @@ fun CronJobEditorDialog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Schedule (required)
|
// Schedule (required)
|
||||||
OutlinedTextField(
|
SchedulePickerSection(
|
||||||
value = state.schedule,
|
schedule = state.schedule,
|
||||||
onValueChange = { onFieldChange("schedule", it) },
|
onScheduleChange = { onFieldChange("schedule", it) },
|
||||||
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
|
||||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prompt
|
// Prompt
|
||||||
@@ -818,6 +831,7 @@ private fun BlueprintStartFromDropdown(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BlueprintSlotField(
|
private fun BlueprintSlotField(
|
||||||
field: CronBlueprintField,
|
field: CronBlueprintField,
|
||||||
@@ -837,13 +851,61 @@ private fun BlueprintSlotField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
field.type == "enum" || field.type == "weekdays" -> {
|
field.type == "enum" || field.type == "weekdays" -> {
|
||||||
ExposedDropdownField(
|
ExposedDropdownField(
|
||||||
label = field.label,
|
label = field.label,
|
||||||
options = field.options,
|
options = field.options,
|
||||||
selectedValue = value,
|
selectedValue = value,
|
||||||
onOptionSelected = onValueChange,
|
onOptionSelected = onValueChange,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
field.type == "time" -> {
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
|
||||||
|
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
|
||||||
|
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
|
||||||
|
|
||||||
|
Text(field.label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
|
||||||
|
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
if (showTimePicker) {
|
||||||
|
val timePickerState =
|
||||||
|
rememberTimePickerState(
|
||||||
|
initialHour = curHour,
|
||||||
|
initialMinute = curMin,
|
||||||
|
is24Hour = true,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
|
||||||
|
showTimePicker = false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showTimePicker = false }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
TimePicker(state = timePickerState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -936,3 +998,528 @@ private fun DeliverField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SchedulePickerSection(
|
||||||
|
schedule: String,
|
||||||
|
onScheduleChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val parsed = remember(schedule) { parseSchedule(schedule) }
|
||||||
|
|
||||||
|
var mode by remember(schedule) { mutableStateOf(parsed.mode) }
|
||||||
|
var hour by remember { mutableStateOf(parsed.hour) }
|
||||||
|
var minute by remember { mutableStateOf(parsed.minute) }
|
||||||
|
var intervalCount by remember(schedule) { mutableStateOf(parsed.intervalCount) }
|
||||||
|
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
|
||||||
|
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
|
||||||
|
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
|
||||||
|
var month by remember(schedule) { mutableStateOf(parsed.month) }
|
||||||
|
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
|
||||||
|
var repeatExpanded by remember { mutableStateOf(false) }
|
||||||
|
var showTimePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun emitSchedule(
|
||||||
|
newMode: ScheduleMode,
|
||||||
|
newHour: Int = hour,
|
||||||
|
newMinute: Int = minute,
|
||||||
|
newIntervalCount: Int = intervalCount,
|
||||||
|
newIntervalUnit: String = intervalUnit,
|
||||||
|
newDays: Set<Int> = selectedDays,
|
||||||
|
newDom: Int = dayOfMonth,
|
||||||
|
newMonth: Int = month,
|
||||||
|
) {
|
||||||
|
onScheduleChange(ParsedSchedule(
|
||||||
|
mode = newMode, hour = newHour, minute = newMinute,
|
||||||
|
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
|
||||||
|
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
|
||||||
|
).toSchedule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_edit_field_schedule),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
isAdvanced = !isAdvanced
|
||||||
|
if (isAdvanced) {
|
||||||
|
mode = ScheduleMode.ADVANCED
|
||||||
|
} else {
|
||||||
|
val fallback = parseSchedule(schedule)
|
||||||
|
mode = fallback.mode
|
||||||
|
hour = fallback.hour
|
||||||
|
minute = fallback.minute
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isAdvanced) Icons.Filled.Schedule else Icons.Filled.Code,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
if (isAdvanced) {
|
||||||
|
stringResource(R.string.cron_schedule_visual_toggle)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.cron_schedule_advanced_toggle)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdvanced) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = schedule,
|
||||||
|
onValueChange = onScheduleChange,
|
||||||
|
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val repeatOptions = listOf(
|
||||||
|
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
|
||||||
|
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
|
||||||
|
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
|
||||||
|
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
|
||||||
|
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
|
||||||
|
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
|
||||||
|
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
|
||||||
|
)
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = repeatExpanded,
|
||||||
|
onExpandedChange = { repeatExpanded = it },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
|
||||||
|
?: R.string.cron_schedule_advanced_toggle),
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(R.string.cron_repeat_label)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
|
||||||
|
repeatOptions.forEach { (option, label) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(label)) },
|
||||||
|
onClick = {
|
||||||
|
mode = option
|
||||||
|
repeatExpanded = false
|
||||||
|
emitSchedule(option)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (mode) {
|
||||||
|
ScheduleMode.DAILY -> {
|
||||||
|
TimeDisplayCard(
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
onClick = { showTimePicker = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleMode.WEEKLY -> {
|
||||||
|
TimeDisplayCard(
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
onClick = { showTimePicker = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
selectedDays = setOf(1, 2, 3, 4, 5)
|
||||||
|
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.cron_schedule_weekdays_workdays), style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
selectedDays = setOf(6, 0)
|
||||||
|
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.cron_schedule_weekdays_weekend), style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
selectedDays = setOf(1, 2, 3, 4, 5, 6, 0)
|
||||||
|
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.cron_schedule_weekdays_all), style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val daysConfig =
|
||||||
|
listOf(
|
||||||
|
1 to R.string.cron_dow_mon,
|
||||||
|
2 to R.string.cron_dow_tue,
|
||||||
|
3 to R.string.cron_dow_wed,
|
||||||
|
4 to R.string.cron_dow_thu,
|
||||||
|
5 to R.string.cron_dow_fri,
|
||||||
|
6 to R.string.cron_dow_sat,
|
||||||
|
0 to R.string.cron_dow_sun,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
daysConfig.forEach { (d, labelRes) ->
|
||||||
|
val isSelected = selectedDays.contains(d)
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
val nextDays =
|
||||||
|
if (isSelected) {
|
||||||
|
if (selectedDays.size > 1) selectedDays - d else selectedDays
|
||||||
|
} else {
|
||||||
|
selectedDays + d
|
||||||
|
}
|
||||||
|
selectedDays = nextDays
|
||||||
|
emitSchedule(ScheduleMode.WEEKLY, newDays = nextDays)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleMode.INTERVAL -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_count),
|
||||||
|
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
|
||||||
|
selectedValue = intervalCount.toString(),
|
||||||
|
onOptionSelected = {
|
||||||
|
intervalCount = it.toInt()
|
||||||
|
emitSchedule(mode, newIntervalCount = intervalCount)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val units = listOf("m", "h", "d")
|
||||||
|
val labels = listOf(stringResource(R.string.cron_unit_minute),
|
||||||
|
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_unit),
|
||||||
|
options = labels,
|
||||||
|
selectedValue = labels[units.indexOf(intervalUnit)],
|
||||||
|
onOptionSelected = {
|
||||||
|
intervalUnit = units[labels.indexOf(it)]
|
||||||
|
emitSchedule(mode, newIntervalUnit = intervalUnit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_schedule_interval_presets),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
val presets =
|
||||||
|
listOf(
|
||||||
|
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
|
||||||
|
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
|
||||||
|
Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)),
|
||||||
|
Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
|
||||||
|
Triple(2, "h", "2 " + stringResource(R.string.cron_unit_hour)),
|
||||||
|
Triple(6, "h", "6 " + stringResource(R.string.cron_unit_hour)),
|
||||||
|
Triple(12, "h", "12 " + stringResource(R.string.cron_unit_hour)),
|
||||||
|
Triple(1, "d", "1 " + stringResource(R.string.cron_unit_day)),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
presets.forEach { (cnt, unt, title) ->
|
||||||
|
val isSelected = intervalCount == cnt && intervalUnit == unt
|
||||||
|
FilterChip(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = {
|
||||||
|
intervalCount = cnt
|
||||||
|
intervalUnit = unt
|
||||||
|
emitSchedule(ScheduleMode.INTERVAL, newIntervalCount = cnt, newIntervalUnit = unt)
|
||||||
|
},
|
||||||
|
label = { Text(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
|
||||||
|
if (mode == ScheduleMode.YEARLY) {
|
||||||
|
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_month),
|
||||||
|
options = months,
|
||||||
|
selectedValue = months[month - 1],
|
||||||
|
onOptionSelected = {
|
||||||
|
month = months.indexOf(it) + 1
|
||||||
|
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
|
||||||
|
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TimeDisplayCard(
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
onClick = { showTimePicker = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_schedule_day_of_month_label, dayOfMonth),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (dayOfMonth > 1) {
|
||||||
|
dayOfMonth--
|
||||||
|
emitSchedule(mode, newDom = dayOfMonth)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = dayOfMonth > 1,
|
||||||
|
) {
|
||||||
|
Text("-", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = dayOfMonth.toString(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
|
||||||
|
if (dayOfMonth < maxDay) {
|
||||||
|
dayOfMonth++
|
||||||
|
emitSchedule(mode, newDom = dayOfMonth)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
|
||||||
|
) {
|
||||||
|
Text("+", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScheduleMode.HOURLY -> {
|
||||||
|
ExposedDropdownField(
|
||||||
|
label = stringResource(R.string.cron_repeat_minute),
|
||||||
|
options = (0..59).map { it.toString().padStart(2, '0') },
|
||||||
|
selectedValue = minute.toString().padStart(2, '0'),
|
||||||
|
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
|
||||||
|
}
|
||||||
|
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
|
||||||
|
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
|
||||||
|
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live Summary Card
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.cron_schedule_timezone_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = scheduleSummary(parsed, schedule),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTimePicker) {
|
||||||
|
val timePickerState =
|
||||||
|
rememberTimePickerState(
|
||||||
|
initialHour = hour,
|
||||||
|
initialMinute = minute,
|
||||||
|
is24Hour = true,
|
||||||
|
)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showTimePicker = false },
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
hour = timePickerState.hour
|
||||||
|
minute = timePickerState.minute
|
||||||
|
showTimePicker = false
|
||||||
|
emitSchedule(mode, newHour = timePickerState.hour, newMinute = timePickerState.minute)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showTimePicker = false }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
TimePicker(state = timePickerState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeDisplayCard(
|
||||||
|
hour: Int,
|
||||||
|
minute: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_schedule_select_time),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cron_action_edit),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
|
||||||
|
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
|
||||||
|
return when (parsed.mode) {
|
||||||
|
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
|
||||||
|
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
|
||||||
|
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
|
||||||
|
ScheduleMode.WEEKLY -> {
|
||||||
|
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
|
||||||
|
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
|
||||||
|
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
|
||||||
|
stringResource(R.string.cron_summary_weekly, names, time)
|
||||||
|
}
|
||||||
|
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
|
||||||
|
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
|
||||||
|
ScheduleMode.INTERVAL -> {
|
||||||
|
val unit = stringResource(when (parsed.intervalUnit) {
|
||||||
|
"m" -> R.string.cron_unit_minute
|
||||||
|
"h" -> R.string.cron_unit_hour
|
||||||
|
else -> R.string.cron_unit_day
|
||||||
|
})
|
||||||
|
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
|
||||||
|
}
|
||||||
|
ScheduleMode.ADVANCED -> raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ class CronJobsViewModel :
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
|
// The server may have started the job even if its reply is lost.
|
||||||
|
safeApiCall(retries = 0) { ApiClient.hermesApi.triggerCronJob(id) }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
|||||||
val result =
|
val result =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.instantiateBlueprint(
|
ApiClient.hermesApi.instantiateBlueprint(
|
||||||
InstantiateBlueprintRequest(
|
InstantiateBlueprintRequest(
|
||||||
blueprint = editor.selectedBlueprintKey,
|
blueprint = editor.selectedBlueprintKey,
|
||||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
|||||||
*/
|
*/
|
||||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||||
val createdResult =
|
val createdResult =
|
||||||
safeApiCall {
|
safeApiCall(retries = 0) {
|
||||||
ApiClient.hermesApi.createCronJob(
|
ApiClient.hermesApi.createCronJob(
|
||||||
CreateCronJobRequest(
|
CreateCronJobRequest(
|
||||||
name = editor.name,
|
name = editor.name,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package com.m57.hermescontrol.ui.cron
|
||||||
|
|
||||||
|
internal enum class ScheduleMode {
|
||||||
|
HOURLY,
|
||||||
|
MINUTELY,
|
||||||
|
YEARLY,
|
||||||
|
DAILY,
|
||||||
|
WEEKLY,
|
||||||
|
INTERVAL,
|
||||||
|
MONTHLY,
|
||||||
|
ADVANCED,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ParsedSchedule(
|
||||||
|
val mode: ScheduleMode,
|
||||||
|
val hour: Int = 9,
|
||||||
|
val minute: Int = 0,
|
||||||
|
val intervalCount: Int = 1,
|
||||||
|
val intervalUnit: String = "h",
|
||||||
|
val selectedDays: Set<Int> = setOf(1),
|
||||||
|
val dayOfMonth: Int = 1,
|
||||||
|
val month: Int = 1,
|
||||||
|
val raw: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun parseSchedule(schedule: String): ParsedSchedule {
|
||||||
|
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
||||||
|
if (clean.isBlank()) {
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean)
|
||||||
|
if (everyMatch != null) {
|
||||||
|
val count = everyMatch.groupValues[1].toIntOrNull()
|
||||||
|
?: return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
if (count <= 0) return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
val unit = everyMatch.groupValues[2].lowercase()
|
||||||
|
return ParsedSchedule(
|
||||||
|
mode = ScheduleMode.INTERVAL,
|
||||||
|
intervalCount = count,
|
||||||
|
intervalUnit = unit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean)
|
||||||
|
if (minStepMatch != null) {
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
|
||||||
|
if (hourStepMatch != null) {
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
val parts = clean.split(" ")
|
||||||
|
if (clean == "* * * * *") return ParsedSchedule(mode = ScheduleMode.MINUTELY)
|
||||||
|
if (parts.size == 5 && parts.drop(1).all { it == "*" }) {
|
||||||
|
val minute = parts[0].toIntOrNull()
|
||||||
|
if (minute != null && minute in 0..59) return ParsedSchedule(mode = ScheduleMode.HOURLY, minute = minute)
|
||||||
|
}
|
||||||
|
if (parts.size == 5) {
|
||||||
|
val minute = parts[0].toIntOrNull()
|
||||||
|
val hour = parts[1].toIntOrNull()
|
||||||
|
val dom = parts[2]
|
||||||
|
val month = parts[3]
|
||||||
|
val dow = parts[4]
|
||||||
|
|
||||||
|
if (minute != null && hour != null && minute in 0..59 && hour in 0..23) {
|
||||||
|
val monthNumber = month.toIntOrNull()
|
||||||
|
val dayNumber = dom.toIntOrNull()
|
||||||
|
if (monthNumber != null && monthNumber in 1..12 && dayNumber != null &&
|
||||||
|
dayNumber in 1..java.time.Month.of(monthNumber).maxLength() && dow == "*") {
|
||||||
|
return ParsedSchedule(ScheduleMode.YEARLY, hour = hour, minute = minute,
|
||||||
|
dayOfMonth = dayNumber, month = monthNumber)
|
||||||
|
}
|
||||||
|
if (dom == "*" && month == "*" && dow == "*") {
|
||||||
|
return ParsedSchedule(
|
||||||
|
mode = ScheduleMode.DAILY,
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (dom == "*" && month == "*" && dow != "*") {
|
||||||
|
val days = parseDowList(dow)
|
||||||
|
if (days.isNotEmpty()) {
|
||||||
|
return ParsedSchedule(
|
||||||
|
mode = ScheduleMode.WEEKLY,
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
selectedDays = days.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dom != "*" && month == "*" && dow == "*") {
|
||||||
|
val domInt = dom.toIntOrNull()
|
||||||
|
if (domInt != null && domInt in 1..31) {
|
||||||
|
return ParsedSchedule(
|
||||||
|
mode = ScheduleMode.MONTHLY,
|
||||||
|
hour = hour,
|
||||||
|
minute = minute,
|
||||||
|
dayOfMonth = domInt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun ParsedSchedule.toSchedule(): String {
|
||||||
|
require(hour in 0..23 && minute in 0..59)
|
||||||
|
require(dayOfMonth in 1..31 && month in 1..12)
|
||||||
|
return when (mode) {
|
||||||
|
ScheduleMode.DAILY -> "$minute $hour * * *"
|
||||||
|
ScheduleMode.HOURLY -> "$minute * * * *"
|
||||||
|
ScheduleMode.MINUTELY -> "* * * * *"
|
||||||
|
ScheduleMode.WEEKLY -> {
|
||||||
|
require(selectedDays.isNotEmpty() && selectedDays.all { it in 0..6 })
|
||||||
|
"$minute $hour * * ${selectedDays.sorted().joinToString(",")}"
|
||||||
|
}
|
||||||
|
ScheduleMode.MONTHLY -> "$minute $hour $dayOfMonth * *"
|
||||||
|
ScheduleMode.YEARLY -> {
|
||||||
|
require(dayOfMonth <= java.time.Month.of(month).maxLength())
|
||||||
|
"$minute $hour $dayOfMonth $month *"
|
||||||
|
}
|
||||||
|
ScheduleMode.INTERVAL -> {
|
||||||
|
require(intervalCount > 0 && intervalUnit in listOf("m", "h", "d"))
|
||||||
|
"every $intervalCount$intervalUnit"
|
||||||
|
}
|
||||||
|
ScheduleMode.ADVANCED -> raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDowList(dow: String): List<Int> {
|
||||||
|
if (dow.contains("-")) {
|
||||||
|
val bounds = dow.split("-")
|
||||||
|
if (bounds.size == 2) {
|
||||||
|
val start = bounds[0].toIntOrNull() ?: return emptyList()
|
||||||
|
val end = bounds[1].toIntOrNull() ?: return emptyList()
|
||||||
|
if (start in 0..7 && end in 0..7 && start <= end) {
|
||||||
|
return (start..end).map { if (it == 7) 0 else it }.distinct().sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val values = dow.split(",").map { it.trim().toIntOrNull() ?: return emptyList() }
|
||||||
|
if (values.any { it !in 0..7 }) return emptyList()
|
||||||
|
return values
|
||||||
|
.map { if (it == 7) 0 else it }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Bolt
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -31,6 +32,7 @@ import com.m57.hermescontrol.R
|
|||||||
fun LandingScreen(
|
fun LandingScreen(
|
||||||
onAuthLogin: () -> Unit,
|
onAuthLogin: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onAiturkPair: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -88,6 +90,16 @@ fun LandingScreen(
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
|
onClick = onAiturkPair,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.landing_action_aiturk_pair),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
onClick = onAuthLogin,
|
onClick = onAuthLogin,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.m57.hermescontrol.SettingsConnection
|
|||||||
import com.m57.hermescontrol.SettingsLanguage
|
import com.m57.hermescontrol.SettingsLanguage
|
||||||
import com.m57.hermescontrol.SettingsVault
|
import com.m57.hermescontrol.SettingsVault
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
import com.m57.hermescontrol.theme.ThemePreference
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
||||||
@@ -74,7 +75,7 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
SettingsCategoryCard(
|
SettingsCategoryCard(
|
||||||
items =
|
items =
|
||||||
listOf(
|
listOfNotNull(
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.AutoMirrored.Filled.ListAlt,
|
icon = Icons.AutoMirrored.Filled.ListAlt,
|
||||||
label = stringResource(R.string.settings_sec_connection),
|
label = stringResource(R.string.settings_sec_connection),
|
||||||
@@ -94,7 +95,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.screen_account_connectors),
|
label = stringResource(R.string.screen_account_connectors),
|
||||||
summary = stringResource(R.string.account_connectors_summary),
|
summary = stringResource(R.string.account_connectors_summary),
|
||||||
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(AccountConnectorsScreen) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Palette,
|
icon = Icons.Filled.Palette,
|
||||||
label = stringResource(R.string.settings_sec_appearance),
|
label = stringResource(R.string.settings_sec_appearance),
|
||||||
@@ -136,7 +137,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.settings_sec_vault),
|
label = stringResource(R.string.settings_sec_vault),
|
||||||
summary = stringResource(R.string.settings_summary_vault),
|
summary = stringResource(R.string.settings_summary_vault),
|
||||||
onClick = { NavigationController.navigateTo(SettingsVault) },
|
onClick = { NavigationController.navigateTo(SettingsVault) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(SettingsVault) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Info,
|
icon = Icons.Filled.Info,
|
||||||
label = stringResource(R.string.settings_sec_about),
|
label = stringResource(R.string.settings_sec_about),
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||||
|
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||||
import com.m57.hermescontrol.ui.settings.InfoRow
|
import com.m57.hermescontrol.ui.settings.InfoRow
|
||||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||||
|
|
||||||
|
private const val AITURK_DOWNLOAD_URL = "https://turkservis.online/indir"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AboutSection(
|
internal fun AboutSection(
|
||||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||||
@@ -55,21 +58,31 @@ internal fun AboutSection(
|
|||||||
label = stringResource(R.string.settings_about_version),
|
label = stringResource(R.string.settings_about_version),
|
||||||
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||||
)
|
)
|
||||||
UpdateRow(
|
// AITURK: the upstream GitHub self-updater is switched off
|
||||||
state = updateState,
|
// (UpdateNoticeManager.enabled = false); new builds ship from the
|
||||||
onCheckUpdate = onCheckUpdate,
|
// AITURK download page instead.
|
||||||
onStartUpdate = onStartUpdate,
|
if (UpdateNoticeManager.enabled) {
|
||||||
onOpenInstallSettings = onOpenInstallSettings,
|
UpdateRow(
|
||||||
)
|
state = updateState,
|
||||||
ReleaseCandidateRow(
|
onCheckUpdate = onCheckUpdate,
|
||||||
checked = checkReleaseCandidateUpdates,
|
onStartUpdate = onStartUpdate,
|
||||||
// Mid-download / mid-install the channel must not flip under the
|
onOpenInstallSettings = onOpenInstallSettings,
|
||||||
// in-flight APK.
|
)
|
||||||
enabled =
|
ReleaseCandidateRow(
|
||||||
updateState !is AppUpdateState.Downloading &&
|
checked = checkReleaseCandidateUpdates,
|
||||||
updateState !is AppUpdateState.Installing,
|
// Mid-download / mid-install the channel must not flip under the
|
||||||
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
// in-flight APK.
|
||||||
)
|
enabled =
|
||||||
|
updateState !is AppUpdateState.Downloading &&
|
||||||
|
updateState !is AppUpdateState.Installing,
|
||||||
|
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
InfoRow(
|
||||||
|
label = stringResource(R.string.settings_about_download),
|
||||||
|
value = AITURK_DOWNLOAD_URL,
|
||||||
|
)
|
||||||
|
}
|
||||||
InfoRow(
|
InfoRow(
|
||||||
label = stringResource(R.string.settings_about_build),
|
label = stringResource(R.string.settings_about_build),
|
||||||
value =
|
value =
|
||||||
@@ -87,6 +100,14 @@ internal fun AboutSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_about_attribution),
|
||||||
|
style =
|
||||||
|
MaterialTheme.typography.bodySmall.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "https://github.com/Hy4ri/hermes-mobile",
|
text = "https://github.com/Hy4ri/hermes-mobile",
|
||||||
style =
|
style =
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
|
|||||||
listOf(
|
listOf(
|
||||||
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
||||||
"en" to stringResource(R.string.language_english),
|
"en" to stringResource(R.string.language_english),
|
||||||
|
"tr" to stringResource(R.string.language_turkish),
|
||||||
"ar" to stringResource(R.string.language_arabic),
|
"ar" to stringResource(R.string.language_arabic),
|
||||||
"zh" to stringResource(R.string.language_chinese),
|
"zh" to stringResource(R.string.language_chinese),
|
||||||
"ko" to stringResource(R.string.language_korean),
|
"ko" to stringResource(R.string.language_korean),
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
|
|||||||
|
|
||||||
fun cronToHumanReadable(schedule: String): String {
|
fun cronToHumanReadable(schedule: String): String {
|
||||||
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
||||||
|
if (clean.startsWith("every ", ignoreCase = true)) {
|
||||||
|
val rest = clean.substring(6).trim()
|
||||||
|
val match = Regex("""^(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(rest)
|
||||||
|
if (match != null) {
|
||||||
|
val num = match.groupValues[1].toIntOrNull() ?: 1
|
||||||
|
val unit = match.groupValues[2].lowercase()
|
||||||
|
val unitStr = when (unit) {
|
||||||
|
"m" -> if (num == 1) "minute" else "minutes"
|
||||||
|
"h" -> if (num == 1) "hour" else "hours"
|
||||||
|
"d" -> if (num == 1) "day" else "days"
|
||||||
|
else -> unit
|
||||||
|
}
|
||||||
|
return "Every " + num + " " + unitStr
|
||||||
|
}
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
val parts = clean.split(" ")
|
val parts = clean.split(" ")
|
||||||
if (parts.size != 5) {
|
if (parts.size != 5) {
|
||||||
return schedule
|
return schedule
|
||||||
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val dows = parseField(dowPart, 0, 7)
|
val dows = parseField(dowPart, 0, 7)
|
||||||
if (dows.size == 1) {
|
if (dows.isNotEmpty()) {
|
||||||
val dowName = dowNames[dows[0]]
|
val names = dows.map { dowNames[it] }.distinct()
|
||||||
return "Every $dowName at $timeStr"
|
return if (names.size == 1) {
|
||||||
|
"Every " + names[0] + " at " + timeStr
|
||||||
|
} else {
|
||||||
|
"Every " + names.joinToString(", ") + " at " + timeStr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- Solid brand background -->
|
<!-- Solid AITURK brand background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#1B1B2F"
|
android:fillColor="#081525"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -3,23 +3,18 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<!-- Left pillar of H -->
|
<!-- AITURK "A" mark (source: AITURK IDE aiturk_icon, 256 viewport), scaled
|
||||||
<path
|
into the 66dp adaptive-icon safe zone and centred. -->
|
||||||
android:fillColor="#FFFFFF"
|
<group
|
||||||
android:pathData="M30,22 L46,22 Q48,22 48,24 L48,84 Q48,86 46,86 L30,86 Q28,86 28,84 L28,24 Q28,22 30,22 Z" />
|
android:scaleX="0.238"
|
||||||
|
android:scaleY="0.238"
|
||||||
<!-- Right pillar of H — extends upward into a wing/swoop -->
|
android:translateX="23.536"
|
||||||
<path
|
android:translateY="24.488">
|
||||||
android:fillColor="#FFFFFF"
|
<path
|
||||||
android:pathData="M66,30 L80,30 Q82,30 82,32 L82,84 Q82,86 80,86 L66,86 Q64,86 64,84 L64,32 Q64,30 66,30 Z" />
|
android:fillColor="#24D4C6"
|
||||||
|
android:pathData="M128,30L230,218H185L164,178H92L71,218H26L128,30ZM128,109L106,151H150L128,109Z" />
|
||||||
<!-- Crossbar of H -->
|
<path
|
||||||
<path
|
android:fillColor="#FFFFFF"
|
||||||
android:fillColor="#FFFFFF"
|
android:pathData="M128,58L148,96H108L128,58Z" />
|
||||||
android:pathData="M38,48 L72,48 Q74,48 74,50 L74,58 Q74,60 72,60 L38,60 Q36,60 36,58 L36,50 Q36,48 38,48 Z" />
|
</group>
|
||||||
|
|
||||||
<!-- Wing swoop — extends from right pillar top, curving upward and outward -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M72,30 Q84,14 96,10 Q100,8 98,14 Q94,26 82,30 Z" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Mobile</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
<string name="chat_send_desc">إرسال</string>
|
<string name="chat_send_desc">إرسال</string>
|
||||||
<string name="chat_mic_listening">جارٍ الاستماع…</string>
|
<string name="chat_mic_listening">جارٍ الاستماع…</string>
|
||||||
<string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string>
|
<string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<string name="billing_plan">الخطة</string>
|
<string name="billing_plan">الخطة</string>
|
||||||
<string name="billing_topup">شحن الرصيد</string>
|
<string name="billing_topup">شحن الرصيد</string>
|
||||||
<string name="press_back_again_to_exit">اضغط رجوع مرة أخرى للخروج</string>
|
<string name="press_back_again_to_exit">اضغط رجوع مرة أخرى للخروج</string>
|
||||||
<string name="nav_drawer_title">Hermes</string>
|
<string name="nav_drawer_title">AITURK</string>
|
||||||
<string name="nav_drawer_subtitle">التحكم في وكيل الذكاء الاصطناعي</string>
|
<string name="nav_drawer_subtitle">التحكم في وكيل الذكاء الاصطناعي</string>
|
||||||
<string name="nav_drawer_section_converse">محادثة</string>
|
<string name="nav_drawer_section_converse">محادثة</string>
|
||||||
<string name="nav_drawer_section_automate">أتمتة</string>
|
<string name="nav_drawer_section_automate">أتمتة</string>
|
||||||
@@ -127,27 +127,27 @@
|
|||||||
<string name="screen_analytics">التحليلات</string>
|
<string name="screen_analytics">التحليلات</string>
|
||||||
<string name="screen_billing">الفواتير والاشتراكات</string>
|
<string name="screen_billing">الفواتير والاشتراكات</string>
|
||||||
<string name="screen_files">الملفات</string>
|
<string name="screen_files">الملفات</string>
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">AITURK</string>
|
||||||
<string name="notif_waiting_replies">بانتظار ردود Hermes</string>
|
<string name="notif_waiting_replies">بانتظار ردود AITURK</string>
|
||||||
<string name="notif_connected_in_background">متصل في الخلفية</string>
|
<string name="notif_connected_in_background">متصل في الخلفية</string>
|
||||||
<string name="notif_action_turn_off">إيقاف</string>
|
<string name="notif_action_turn_off">إيقاف</string>
|
||||||
<string name="notif_connecting">جارٍ الاتصال…</string>
|
<string name="notif_connecting">جارٍ الاتصال…</string>
|
||||||
<string name="notif_waiting_network">في انتظار الشبكة</string>
|
<string name="notif_waiting_network">في انتظار الشبكة</string>
|
||||||
<string name="notif_reconnecting">جارٍ إعادة الاتصال</string>
|
<string name="notif_reconnecting">جارٍ إعادة الاتصال</string>
|
||||||
<string name="notif_new_message">رسالة جديدة</string>
|
<string name="notif_new_message">رسالة جديدة</string>
|
||||||
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string>
|
<string name="notif_clarification_needed">يحتاج AITURK إلى توضيح</string>
|
||||||
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
|
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
|
||||||
<string name="notif_channel_service_name">خدمة Hermes في الخلفية</string>
|
<string name="notif_channel_service_name">خدمة AITURK في الخلفية</string>
|
||||||
<string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string>
|
<string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string>
|
||||||
<string name="notif_channel_chat_name">ردود محادثة Hermes</string>
|
<string name="notif_channel_chat_name">ردود محادثة AITURK</string>
|
||||||
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من Hermes</string>
|
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من AITURK</string>
|
||||||
<string name="action_reply">رد</string>
|
<string name="action_reply">رد</string>
|
||||||
<string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string>
|
<string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string>
|
||||||
<string name="chat_action_search">بحث</string>
|
<string name="chat_action_search">بحث</string>
|
||||||
<string name="chat_action_close_search">إغلاق البحث</string>
|
<string name="chat_action_close_search">إغلاق البحث</string>
|
||||||
<string name="chat_action_reconnect">إعادة الاتصال</string>
|
<string name="chat_action_reconnect">إعادة الاتصال</string>
|
||||||
<string name="chat_action_relogin">إعادة تسجيل الدخول</string>
|
<string name="chat_action_relogin">إعادة تسجيل الدخول</string>
|
||||||
<string name="chat_relogin_title">إعادة تسجيل الدخول إلى Hermes</string>
|
<string name="chat_relogin_title">إعادة تسجيل الدخول إلى AITURK</string>
|
||||||
<string name="chat_relogin_username">اسم المستخدم</string>
|
<string name="chat_relogin_username">اسم المستخدم</string>
|
||||||
<string name="chat_relogin_password">كلمة المرور</string>
|
<string name="chat_relogin_password">كلمة المرور</string>
|
||||||
<string name="chat_relogin_submit">تسجيل الدخول</string>
|
<string name="chat_relogin_submit">تسجيل الدخول</string>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string>
|
<string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string>
|
||||||
<string name="chat_empty_title">جاهز للدردشة</string>
|
<string name="chat_empty_title">جاهز للدردشة</string>
|
||||||
<string name="chat_empty_subtitle">أرسل رسالة لبدء محادثة</string>
|
<string name="chat_empty_subtitle">أرسل رسالة لبدء محادثة</string>
|
||||||
<string name="chat_status_connecting">جارٍ الاتصال بـ Hermes\u2026</string>
|
<string name="chat_status_connecting">جارٍ الاتصال بـ AITURK\u2026</string>
|
||||||
<string name="chat_resume_error_title">تعذر الاتصال بهذه الجلسة</string>
|
<string name="chat_resume_error_title">تعذر الاتصال بهذه الجلسة</string>
|
||||||
<string name="chat_status_reconnecting">جارٍ إعادة الاتصال\u2026</string>
|
<string name="chat_status_reconnecting">جارٍ إعادة الاتصال\u2026</string>
|
||||||
<string name="chat_status_disconnected">تم قطع الاتصال</string>
|
<string name="chat_status_disconnected">تم قطع الاتصال</string>
|
||||||
@@ -227,9 +227,7 @@
|
|||||||
<string name="settings_action_test_connection">اختبار الاتصال</string>
|
<string name="settings_action_test_connection">اختبار الاتصال</string>
|
||||||
<string name="settings_sec_about">حول التطبيق</string>
|
<string name="settings_sec_about">حول التطبيق</string>
|
||||||
<string name="settings_sec_language">اللغة</string>
|
<string name="settings_sec_language">اللغة</string>
|
||||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||||
<string name="settings_support_title">ادعم Hermes Mobile</string>
|
|
||||||
<string name="settings_support_message">إذا وفّر لك Hermes Mobile الوقت، يمكنك دعم تطويره.</string>
|
|
||||||
<string name="settings_support_action">ادعمني على Ko-fi</string>
|
<string name="settings_support_action">ادعمني على Ko-fi</string>
|
||||||
<string name="settings_about_version">الإصدار</string>
|
<string name="settings_about_version">الإصدار</string>
|
||||||
<string name="settings_about_build">رقم البنية</string>
|
<string name="settings_about_build">رقم البنية</string>
|
||||||
@@ -289,7 +287,7 @@
|
|||||||
<string name="connect_selected_profile">الملف التعريفي: %1$s</string>
|
<string name="connect_selected_profile">الملف التعريفي: %1$s</string>
|
||||||
<string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</string>
|
<string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</string>
|
||||||
<string name="connect_health_title">حالة الخادم الخلفي</string>
|
<string name="connect_health_title">حالة الخادم الخلفي</string>
|
||||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||||
<string name="connect_health_auth_gated">محمي (OAuth)</string>
|
<string name="connect_health_auth_gated">محمي (OAuth)</string>
|
||||||
<string name="connect_health_auth_token">Token</string>
|
<string name="connect_health_auth_token">Token</string>
|
||||||
<string name="connect_error_token_required">رمز Token مطلوب</string>
|
<string name="connect_error_token_required">رمز Token مطلوب</string>
|
||||||
@@ -297,7 +295,7 @@
|
|||||||
<string name="connect_error_403">تم رفض الوصول (403 Forbidden)</string>
|
<string name="connect_error_403">تم رفض الوصول (403 Forbidden)</string>
|
||||||
<string name="connect_error_http_code">استجاب الخادم برمز HTTP %1$d</string>
|
<string name="connect_error_http_code">استجاب الخادم برمز HTTP %1$d</string>
|
||||||
<string name="connect_error_timeout">انتهت مهلة الاتصال</string>
|
<string name="connect_error_timeout">انتهت مهلة الاتصال</string>
|
||||||
<string name="connect_error_refused">تم رفض الاتصال – هل خادم Hermes قيد التشغيل؟</string>
|
<string name="connect_error_refused">تم رفض الاتصال – هل خادم AITURK قيد التشغيل؟</string>
|
||||||
<string name="connect_error_resolve">تعذر حل اسم المضيف</string>
|
<string name="connect_error_resolve">تعذر حل اسم المضيف</string>
|
||||||
<string name="connect_error_connection_failed">فشل الاتصال: %1$s</string>
|
<string name="connect_error_connection_failed">فشل الاتصال: %1$s</string>
|
||||||
<string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string>
|
<string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string>
|
||||||
@@ -500,7 +498,7 @@
|
|||||||
<string name="skills_hub_source_offline">غير متصل</string>
|
<string name="skills_hub_source_offline">غير متصل</string>
|
||||||
<string name="skills_hub_source_via_index">عبر الفهرس</string>
|
<string name="skills_hub_source_via_index">عبر الفهرس</string>
|
||||||
<string name="skills_hub_featured_title">مهارات مميزة</string>
|
<string name="skills_hub_featured_title">مهارات مميزة</string>
|
||||||
<string name="skills_hub_featured_subtitle">من فهرس Hermes — ابحث أعلاه للمزيد</string>
|
<string name="skills_hub_featured_subtitle">من فهرس AITURK — ابحث أعلاه للمزيد</string>
|
||||||
<string name="skills_hub_landing_hint">ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة</string>
|
<string name="skills_hub_landing_hint">ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة</string>
|
||||||
<string name="skills_hub_scan">فحص</string>
|
<string name="skills_hub_scan">فحص</string>
|
||||||
<string name="skills_hub_scan_verdict_label">النتيجة: %1$s</string>
|
<string name="skills_hub_scan_verdict_label">النتيجة: %1$s</string>
|
||||||
@@ -529,7 +527,7 @@
|
|||||||
<string name="content_desc_update_skills_hub">تحديث المهارات من المركز</string>
|
<string name="content_desc_update_skills_hub">تحديث المهارات من المركز</string>
|
||||||
<string name="content_desc_save_changes">حفظ التغييرات</string>
|
<string name="content_desc_save_changes">حفظ التغييرات</string>
|
||||||
<string name="logs_empty_title">لا توجد سجلات متاحة</string>
|
<string name="logs_empty_title">لا توجد سجلات متاحة</string>
|
||||||
<string name="logs_empty_desc">ستظهر مخرجات السجل من Hermes هنا.</string>
|
<string name="logs_empty_desc">ستظهر مخرجات السجل من AITURK هنا.</string>
|
||||||
<string name="logs_action_pause">إيقاف مؤقت</string>
|
<string name="logs_action_pause">إيقاف مؤقت</string>
|
||||||
<string name="logs_action_resume">استئناف</string>
|
<string name="logs_action_resume">استئناف</string>
|
||||||
<string name="logs_action_export">مشاركة</string>
|
<string name="logs_action_export">مشاركة</string>
|
||||||
@@ -552,7 +550,7 @@
|
|||||||
<string name="system_label_arch">المعمارية</string>
|
<string name="system_label_arch">المعمارية</string>
|
||||||
<string name="system_label_hostname">اسم المضيف</string>
|
<string name="system_label_hostname">اسم المضيف</string>
|
||||||
<string name="system_label_python">Python</string>
|
<string name="system_label_python">Python</string>
|
||||||
<string name="system_label_hermes_version">Hermes</string>
|
<string name="system_label_hermes_version">AITURK</string>
|
||||||
<string name="system_label_cpu">وحدة المعالجة المركزية (CPU)</string>
|
<string name="system_label_cpu">وحدة المعالجة المركزية (CPU)</string>
|
||||||
<string name="system_label_memory">الذاكرة (RAM)</string>
|
<string name="system_label_memory">الذاكرة (RAM)</string>
|
||||||
<string name="system_label_disk">القرص</string>
|
<string name="system_label_disk">القرص</string>
|
||||||
@@ -566,7 +564,7 @@
|
|||||||
<string name="system_version_behind">متأخر بمقدار %1$d</string>
|
<string name="system_version_behind">متأخر بمقدار %1$d</string>
|
||||||
<string name="system_action_check_updates">التحقق من وجود تحديثات</string>
|
<string name="system_action_check_updates">التحقق من وجود تحديثات</string>
|
||||||
<string name="system_action_update_now">تحديث الآن</string>
|
<string name="system_action_update_now">تحديث الآن</string>
|
||||||
<string name="system_update_confirm_title">هل تريد تحديث Hermes؟</string>
|
<string name="system_update_confirm_title">هل تريد تحديث AITURK؟</string>
|
||||||
<string name="system_update_confirm_desc">سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة.</string>
|
<string name="system_update_confirm_desc">سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة.</string>
|
||||||
<string name="system_update_progress_title">تحديث البرنامج</string>
|
<string name="system_update_progress_title">تحديث البرنامج</string>
|
||||||
<string name="system_sec_portal">بوابة Nous Portal</string>
|
<string name="system_sec_portal">بوابة Nous Portal</string>
|
||||||
@@ -642,10 +640,10 @@
|
|||||||
<string name="system_sec_restore">استعادة من ملف تم رفعه</string>
|
<string name="system_sec_restore">استعادة من ملف تم رفعه</string>
|
||||||
<string name="system_op_restore_upload">رفع واستعادة</string>
|
<string name="system_op_restore_upload">رفع واستعادة</string>
|
||||||
<string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string>
|
<string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string>
|
||||||
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ Hermes؟</string>
|
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ AITURK؟</string>
|
||||||
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات Hermes الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
|
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات AITURK الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||||
<string name="system_sec_debug_share">مشاركة بيانات تصحيح الأخطاء</string>
|
<string name="system_sec_debug_share">مشاركة بيانات تصحيح الأخطاء</string>
|
||||||
<string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق Hermes. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string>
|
<string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق AITURK. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string>
|
||||||
<string name="system_debug_share_generate">إنشاء رابط المشاركة</string>
|
<string name="system_debug_share_generate">إنشاء رابط المشاركة</string>
|
||||||
<string name="system_debug_share_uploading">جارٍ الرفع…</string>
|
<string name="system_debug_share_uploading">جارٍ الرفع…</string>
|
||||||
<string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string>
|
<string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string>
|
||||||
@@ -704,7 +702,7 @@
|
|||||||
<item quantity="other">%1$d نموذج</item>
|
<item quantity="other">%1$d نموذج</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="history_empty_title">لا يوجد سجل</string>
|
<string name="history_empty_title">لا يوجد سجل</string>
|
||||||
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
|
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من AITURK هنا.</string>
|
||||||
<string name="history_untitled">بلا عنوان</string>
|
<string name="history_untitled">بلا عنوان</string>
|
||||||
<string name="history_message_count">%1$d رسالة</string>
|
<string name="history_message_count">%1$d رسالة</string>
|
||||||
<plurals name="sessions_card_message_count">
|
<plurals name="sessions_card_message_count">
|
||||||
@@ -849,7 +847,7 @@
|
|||||||
<string name="config_action_save">حفظ</string>
|
<string name="config_action_save">حفظ</string>
|
||||||
<string name="config_action_save_yaml">حفظ YAML</string>
|
<string name="config_action_save_yaml">حفظ YAML</string>
|
||||||
<string name="profiles_empty_title">لا توجد ملفات تعريف متاحة</string>
|
<string name="profiles_empty_title">لا توجد ملفات تعريف متاحة</string>
|
||||||
<string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من Hermes هنا.</string>
|
<string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من AITURK هنا.</string>
|
||||||
<string name="profiles_label_model">النموذج: %1$s</string>
|
<string name="profiles_label_model">النموذج: %1$s</string>
|
||||||
<string name="profiles_label_provider">المزود: %1$s</string>
|
<string name="profiles_label_provider">المزود: %1$s</string>
|
||||||
<string name="profiles_label_skills">%1$d مهارات</string>
|
<string name="profiles_label_skills">%1$d مهارات</string>
|
||||||
@@ -895,7 +893,7 @@
|
|||||||
<string name="profiles_delete_warning">سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا.</string>
|
<string name="profiles_delete_warning">سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا.</string>
|
||||||
<string name="profiles_delete_path">المسار: %1$s</string>
|
<string name="profiles_delete_path">المسار: %1$s</string>
|
||||||
<string name="profiles_delete_gateway_warning">يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها.</string>
|
<string name="profiles_delete_gateway_warning">يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها.</string>
|
||||||
<string name="profiles_setup_command_hint">شغّل هذا الأمر في Hermes CLI لتهيئة هذا الملف الشخصي.</string>
|
<string name="profiles_setup_command_hint">شغّل هذا الأمر في AITURK CLI لتهيئة هذا الملف الشخصي.</string>
|
||||||
<string name="profiles_builder_step_indicator">الخطوة %1$d من 5</string>
|
<string name="profiles_builder_step_indicator">الخطوة %1$d من 5</string>
|
||||||
<string name="profiles_builder_step_identity">الهوية</string>
|
<string name="profiles_builder_step_identity">الهوية</string>
|
||||||
<string name="profiles_builder_step_model">تهيئة النموذج</string>
|
<string name="profiles_builder_step_model">تهيئة النموذج</string>
|
||||||
@@ -913,7 +911,7 @@
|
|||||||
<string name="profiles_builder_skills_default_desc">تضمين أدوات النظام الشائعة تلقائيًا</string>
|
<string name="profiles_builder_skills_default_desc">تضمين أدوات النظام الشائعة تلقائيًا</string>
|
||||||
<string name="profiles_builder_skills_local">تحديد المهارات المحلية</string>
|
<string name="profiles_builder_skills_local">تحديد المهارات المحلية</string>
|
||||||
<string name="profiles_builder_skills_search_hub">البحث في الـ Hub</string>
|
<string name="profiles_builder_skills_search_hub">البحث في الـ Hub</string>
|
||||||
<string name="profiles_builder_skills_hub_title">إضافة مهارات من Hermes Hub</string>
|
<string name="profiles_builder_skills_hub_title">إضافة مهارات من AITURK Hub</string>
|
||||||
<string name="profiles_builder_skills_added">المهارات المضافة:</string>
|
<string name="profiles_builder_skills_added">المهارات المضافة:</string>
|
||||||
<string name="profiles_builder_skills_search_results">نتائج البحث:</string>
|
<string name="profiles_builder_skills_search_results">نتائج البحث:</string>
|
||||||
<string name="profiles_builder_mcp_title">خوادم MCP المهيأة</string>
|
<string name="profiles_builder_mcp_title">خوادم MCP المهيأة</string>
|
||||||
@@ -1049,7 +1047,7 @@
|
|||||||
<string name="pressure_banner_memory_oom">أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة</string>
|
<string name="pressure_banner_memory_oom">أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة</string>
|
||||||
<string name="pressure_banner_memory_elevated">ذاكرة الوكيل منخفضة</string>
|
<string name="pressure_banner_memory_elevated">ذاكرة الوكيل منخفضة</string>
|
||||||
<string name="cron_empty_title">لا توجد مهام cron</string>
|
<string name="cron_empty_title">لا توجد مهام cron</string>
|
||||||
<string name="cron_empty_desc">ستظهر المهام المجدولة من Hermes هنا.</string>
|
<string name="cron_empty_desc">ستظهر المهام المجدولة من AITURK هنا.</string>
|
||||||
<string name="cron_status_active">نشط</string>
|
<string name="cron_status_active">نشط</string>
|
||||||
<string name="cron_status_paused">متوقف مؤقتاً</string>
|
<string name="cron_status_paused">متوقف مؤقتاً</string>
|
||||||
<string name="cron_action_pause">إيقاف مؤقت</string>
|
<string name="cron_action_pause">إيقاف مؤقت</string>
|
||||||
@@ -1124,7 +1122,7 @@
|
|||||||
<string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string>
|
<string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string>
|
||||||
<string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</string>
|
<string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||||
<string name="keys_empty_title">لا توجد مفاتيح بيئة مُبلّغ عنها</string>
|
<string name="keys_empty_title">لا توجد مفاتيح بيئة مُبلّغ عنها</string>
|
||||||
<string name="keys_empty_desc">ستظهر مفاتيح البيئة من Hermes هنا.</string>
|
<string name="keys_empty_desc">ستظهر مفاتيح البيئة من AITURK هنا.</string>
|
||||||
<string name="keys_label_not_configured">غير مهيأ</string>
|
<string name="keys_label_not_configured">غير مهيأ</string>
|
||||||
<string name="keys_action_toggle_visibility">تبديل مستوى الرؤية</string>
|
<string name="keys_action_toggle_visibility">تبديل مستوى الرؤية</string>
|
||||||
<string name="keys_search_placeholder">بحث في المفاتيح…</string>
|
<string name="keys_search_placeholder">بحث في المفاتيح…</string>
|
||||||
@@ -1165,7 +1163,7 @@
|
|||||||
<string name="files_dialog_delete_title">هل تريد الحذف؟</string>
|
<string name="files_dialog_delete_title">هل تريد الحذف؟</string>
|
||||||
<string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</string>
|
<string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</string>
|
||||||
<string name="channels_empty_title">لا توجد قنوات مهيأة</string>
|
<string name="channels_empty_title">لا توجد قنوات مهيأة</string>
|
||||||
<string name="channels_empty_desc">أضف منصات المراسلة في Hermes لعرضها هنا.</string>
|
<string name="channels_empty_desc">أضف منصات المراسلة في AITURK لعرضها هنا.</string>
|
||||||
<string name="channels_action_configure">تهيئة</string>
|
<string name="channels_action_configure">تهيئة</string>
|
||||||
<string name="channels_sec_settings">إعدادات التهيئة</string>
|
<string name="channels_sec_settings">إعدادات التهيئة</string>
|
||||||
<string name="channels_action_save_settings">حفظ الإعدادات</string>
|
<string name="channels_action_save_settings">حفظ الإعدادات</string>
|
||||||
@@ -1188,7 +1186,7 @@
|
|||||||
<string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string>
|
<string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string>
|
||||||
<string name="settings_logout">تسجيل الخروج</string>
|
<string name="settings_logout">تسجيل الخروج</string>
|
||||||
<string name="auth_login_title">تسجيل الدخول والمصادقة</string>
|
<string name="auth_login_title">تسجيل الدخول والمصادقة</string>
|
||||||
<string name="auth_login_desc">الاتصال بلوحة تحكم Hermes</string>
|
<string name="auth_login_desc">الاتصال بلوحة تحكم AITURK</string>
|
||||||
<string name="auth_login_base_url_label">رابط الخادم (URL)</string>
|
<string name="auth_login_base_url_label">رابط الخادم (URL)</string>
|
||||||
<string name="auth_login_token_label">رمز الجلسة (Session Token)</string>
|
<string name="auth_login_token_label">رمز الجلسة (Session Token)</string>
|
||||||
<string name="auth_login_username_label">اسم المستخدم</string>
|
<string name="auth_login_username_label">اسم المستخدم</string>
|
||||||
@@ -1201,11 +1199,11 @@
|
|||||||
<string name="auth_login_error_password_required">كلمة المرور مطلوبة</string>
|
<string name="auth_login_error_password_required">كلمة المرور مطلوبة</string>
|
||||||
<string name="auth_login_error_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</string>
|
<string name="auth_login_error_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</string>
|
||||||
<string name="auth_login_error_oauth_unsupported">تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#)</string>
|
<string name="auth_login_error_oauth_unsupported">تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#)</string>
|
||||||
<string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في Hermes Mobile.</string>
|
<string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في AITURK Mobil.</string>
|
||||||
<string name="auth_login_existing_profiles_title">استخدام ملف تعريف مسجل دخوله مسبقاً:</string>
|
<string name="auth_login_existing_profiles_title">استخدام ملف تعريف مسجل دخوله مسبقاً:</string>
|
||||||
<string name="auth_login_use_profile">الدخول باسم %1$s</string>
|
<string name="auth_login_use_profile">الدخول باسم %1$s</string>
|
||||||
<string name="landing_title">Hermes Mobile</string>
|
<string name="landing_title">AITURK Mobil</string>
|
||||||
<string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string>
|
<string name="landing_subtitle">لوحة التحكم لـ AITURK</string>
|
||||||
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
|
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
|
||||||
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
|
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
|
||||||
<string name="providers_empty_title">لا يوجد موفرو OAuth</string>
|
<string name="providers_empty_title">لا يوجد موفرو OAuth</string>
|
||||||
@@ -1226,7 +1224,7 @@
|
|||||||
<string name="action_dismiss">تجاهل</string>
|
<string name="action_dismiss">تجاهل</string>
|
||||||
<string name="action_done">تم</string>
|
<string name="action_done">تم</string>
|
||||||
<string name="action_close">إغلاق</string>
|
<string name="action_close">إغلاق</string>
|
||||||
<string name="update_notice_banner_text">يتوفر إصدار جديد من Hermes Mobile %1$s</string>
|
<string name="update_notice_banner_text">يتوفر إصدار جديد من AITURK Mobil %1$s</string>
|
||||||
<string name="update_notice_banner_action">تحديث</string>
|
<string name="update_notice_banner_action">تحديث</string>
|
||||||
<string name="update_dialog_title">التحديث إلى %1$s</string>
|
<string name="update_dialog_title">التحديث إلى %1$s</string>
|
||||||
<string name="update_dialog_changelog_header">ما الجديد</string>
|
<string name="update_dialog_changelog_header">ما الجديد</string>
|
||||||
@@ -1316,7 +1314,7 @@
|
|||||||
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
|
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
|
||||||
<string name="gif_play">تشغيل GIF</string>
|
<string name="gif_play">تشغيل GIF</string>
|
||||||
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
|
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
|
||||||
<string name="logs_clipboard_label">سجلات Hermes</string>
|
<string name="logs_clipboard_label">سجلات AITURK</string>
|
||||||
<string name="logs_search_placeholder">البحث في السجلات...</string>
|
<string name="logs_search_placeholder">البحث في السجلات...</string>
|
||||||
<string name="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string>
|
<string name="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string>
|
||||||
<string name="mcp_search_placeholder">البحث في خوادم MCP...</string>
|
<string name="mcp_search_placeholder">البحث في خوادم MCP...</string>
|
||||||
@@ -1554,7 +1552,7 @@
|
|||||||
<string name="connection_setup_open_browser">فتح في المتصفح</string>
|
<string name="connection_setup_open_browser">فتح في المتصفح</string>
|
||||||
<string name="connection_setup_required">%1$s مطلوب</string>
|
<string name="connection_setup_required">%1$s مطلوب</string>
|
||||||
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
||||||
<string name="connection_setup_pending">بانتظار Hermes…</string>
|
<string name="connection_setup_pending">بانتظار AITURK…</string>
|
||||||
<string name="connection_catalog_title">تثبيت %1$s</string>
|
<string name="connection_catalog_title">تثبيت %1$s</string>
|
||||||
<string name="connection_catalog_kind_plugin">إضافة</string>
|
<string name="connection_catalog_kind_plugin">إضافة</string>
|
||||||
<string name="connection_catalog_kind_skill">مهارة</string>
|
<string name="connection_catalog_kind_skill">مهارة</string>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">헤르메스</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<string name="chat_send_desc">전송</string>
|
<string name="chat_send_desc">전송</string>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<!-- Navigation & Drawer -->
|
<!-- Navigation & Drawer -->
|
||||||
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
|
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
|
||||||
<string name="nav_drawer_title">헤르메스</string>
|
<string name="nav_drawer_title">AITURK</string>
|
||||||
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
|
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
|
||||||
<string name="nav_drawer_section_converse">대화</string>
|
<string name="nav_drawer_section_converse">대화</string>
|
||||||
<string name="nav_drawer_section_automate">자동화</string>
|
<string name="nav_drawer_section_automate">자동화</string>
|
||||||
@@ -124,20 +124,20 @@
|
|||||||
<string name="screen_files">파일</string>
|
<string name="screen_files">파일</string>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">헤르메스</string>
|
<string name="notif_title">AITURK</string>
|
||||||
<string name="notif_waiting_replies">헤르메스 응답 대기 중</string>
|
<string name="notif_waiting_replies">AITURK 응답 대기 중</string>
|
||||||
<string name="notif_connected_in_background">백그라운드에서 연결됨</string>
|
<string name="notif_connected_in_background">백그라운드에서 연결됨</string>
|
||||||
<string name="notif_action_turn_off">끄기</string>
|
<string name="notif_action_turn_off">끄기</string>
|
||||||
<string name="notif_connecting">연결 중…</string>
|
<string name="notif_connecting">연결 중…</string>
|
||||||
<string name="notif_waiting_network">네트워크 대기 중</string>
|
<string name="notif_waiting_network">네트워크 대기 중</string>
|
||||||
<string name="notif_reconnecting">재연결 중</string>
|
<string name="notif_reconnecting">재연결 중</string>
|
||||||
<string name="notif_new_message">새 메시지</string>
|
<string name="notif_new_message">새 메시지</string>
|
||||||
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string>
|
<string name="notif_clarification_needed">AITURK의 확인이 필요합니다</string>
|
||||||
<string name="notif_reply_placeholder">답변 입력…</string>
|
<string name="notif_reply_placeholder">답변 입력…</string>
|
||||||
<string name="notif_channel_service_name">헤르메스 백그라운드 서비스</string>
|
<string name="notif_channel_service_name">AITURK 백그라운드 서비스</string>
|
||||||
<string name="notif_channel_service_desc">백그라운드에서 연결 유지</string>
|
<string name="notif_channel_service_desc">백그라운드에서 연결 유지</string>
|
||||||
<string name="notif_channel_chat_name">헤르메스 채팅 응답</string>
|
<string name="notif_channel_chat_name">AITURK 채팅 응답</string>
|
||||||
<string name="notif_channel_chat_desc">헤르메스의 새 응답 알림</string>
|
<string name="notif_channel_chat_desc">AITURK의 새 응답 알림</string>
|
||||||
<string name="action_reply">답장</string>
|
<string name="action_reply">답장</string>
|
||||||
|
|
||||||
<!-- Chat Screen & Bubble -->
|
<!-- Chat Screen & Bubble -->
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<string name="chat_action_close_search">검색 닫기</string>
|
<string name="chat_action_close_search">검색 닫기</string>
|
||||||
<string name="chat_action_reconnect">재접속</string>
|
<string name="chat_action_reconnect">재접속</string>
|
||||||
<string name="chat_action_relogin">재로그인</string>
|
<string name="chat_action_relogin">재로그인</string>
|
||||||
<string name="chat_relogin_title">헤르메스 재로그인</string>
|
<string name="chat_relogin_title">AITURK 재로그인</string>
|
||||||
<string name="chat_relogin_username">사용자명</string>
|
<string name="chat_relogin_username">사용자명</string>
|
||||||
<string name="chat_relogin_password">비밀번호</string>
|
<string name="chat_relogin_password">비밀번호</string>
|
||||||
<string name="chat_relogin_submit">로그인</string>
|
<string name="chat_relogin_submit">로그인</string>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
<string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string>
|
<string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string>
|
||||||
<string name="chat_empty_title">채팅 준비 완료</string>
|
<string name="chat_empty_title">채팅 준비 완료</string>
|
||||||
<string name="chat_empty_subtitle">메시지를 보내 대화를 시작하세요</string>
|
<string name="chat_empty_subtitle">메시지를 보내 대화를 시작하세요</string>
|
||||||
<string name="chat_status_connecting">헤르메스에 연결 중…</string>
|
<string name="chat_status_connecting">AITURK에 연결 중…</string>
|
||||||
<string name="chat_status_reconnecting">재연결 중…</string>
|
<string name="chat_status_reconnecting">재연결 중…</string>
|
||||||
<string name="chat_status_disconnected">연결 끊김</string>
|
<string name="chat_status_disconnected">연결 끊김</string>
|
||||||
<string name="chat_status_no_network">네트워크 없음</string>
|
<string name="chat_status_no_network">네트워크 없음</string>
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
<string name="settings_sec_connection">연결</string>
|
<string name="settings_sec_connection">연결</string>
|
||||||
<string name="settings_field_token">인증 토큰</string>
|
<string name="settings_field_token">인증 토큰</string>
|
||||||
<string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string>
|
<string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string>
|
||||||
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
|
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 AITURK 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
|
||||||
<string name="settings_sec_behavior">동작</string>
|
<string name="settings_sec_behavior">동작</string>
|
||||||
<string name="settings_item_auto_reconnect">자동 재연결</string>
|
<string name="settings_item_auto_reconnect">자동 재연결</string>
|
||||||
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
||||||
@@ -229,9 +229,7 @@
|
|||||||
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
|
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
|
||||||
<string name="settings_action_test_connection">연결 테스트</string>
|
<string name="settings_action_test_connection">연결 테스트</string>
|
||||||
<string name="settings_sec_about">정보</string>
|
<string name="settings_sec_about">정보</string>
|
||||||
<string name="settings_about_app_name">헤르메스 모바일</string>
|
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||||
<string name="settings_support_title">Hermes Mobile 후원</string>
|
|
||||||
<string name="settings_support_message">Hermes Mobile이 시간을 절약해 준다면 개발을 후원해 주세요.</string>
|
|
||||||
<string name="settings_support_action">Ko-fi에서 후원하기</string>
|
<string name="settings_support_action">Ko-fi에서 후원하기</string>
|
||||||
<string name="settings_about_version">버전</string>
|
<string name="settings_about_version">버전</string>
|
||||||
<string name="settings_about_build">빌드</string>
|
<string name="settings_about_build">빌드</string>
|
||||||
@@ -286,7 +284,7 @@
|
|||||||
<string name="connect_selected_profile">프로필: %1$s</string>
|
<string name="connect_selected_profile">프로필: %1$s</string>
|
||||||
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
||||||
<string name="connect_health_title">백엔드 상태</string>
|
<string name="connect_health_title">백엔드 상태</string>
|
||||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||||
<string name="connect_health_auth_gated">게이트(OAuth)</string>
|
<string name="connect_health_auth_gated">게이트(OAuth)</string>
|
||||||
<string name="connect_health_auth_token">토큰</string>
|
<string name="connect_health_auth_token">토큰</string>
|
||||||
|
|
||||||
@@ -296,13 +294,13 @@
|
|||||||
<string name="connect_error_403">접근 거부 (403 Forbidden)</string>
|
<string name="connect_error_403">접근 거부 (403 Forbidden)</string>
|
||||||
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</string>
|
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</string>
|
||||||
<string name="connect_error_timeout">연결 시간 초과</string>
|
<string name="connect_error_timeout">연결 시간 초과</string>
|
||||||
<string name="connect_error_refused">연결 거부됨 – Hermes가 실행 중인가요?</string>
|
<string name="connect_error_refused">연결 거부됨 – AITURK가 실행 중인가요?</string>
|
||||||
<string name="connect_error_resolve">호스트를 찾을 수 없음</string>
|
<string name="connect_error_resolve">호스트를 찾을 수 없음</string>
|
||||||
<string name="connect_error_connection_failed">연결 실패: %1$s</string>
|
<string name="connect_error_connection_failed">연결 실패: %1$s</string>
|
||||||
|
|
||||||
<string name="connect_save_profile">연결 프로필 저장</string>
|
<string name="connect_save_profile">연결 프로필 저장</string>
|
||||||
<string name="connect_profile_name">프로필 이름</string>
|
<string name="connect_profile_name">프로필 이름</string>
|
||||||
<string name="connect_placeholder_profile_name">예: 집 헤르메스, 사무실 서버</string>
|
<string name="connect_placeholder_profile_name">예: 집 AITURK, 사무실 서버</string>
|
||||||
<string name="connect_action_hide_manual">수동 입력 숨기기</string>
|
<string name="connect_action_hide_manual">수동 입력 숨기기</string>
|
||||||
<string name="connect_action_show_manual">수동으로 입력</string>
|
<string name="connect_action_show_manual">수동으로 입력</string>
|
||||||
<string name="connect_placeholder_token">API 토큰</string>
|
<string name="connect_placeholder_token">API 토큰</string>
|
||||||
@@ -512,7 +510,7 @@
|
|||||||
|
|
||||||
<!-- Logs Screen -->
|
<!-- Logs Screen -->
|
||||||
<string name="logs_empty_title">로그 없음</string>
|
<string name="logs_empty_title">로그 없음</string>
|
||||||
<string name="logs_empty_desc">Hermes의 로그 출력이 여기에 표시됩니다.</string>
|
<string name="logs_empty_desc">AITURK의 로그 출력이 여기에 표시됩니다.</string>
|
||||||
<string name="logs_action_pause">일시정지</string>
|
<string name="logs_action_pause">일시정지</string>
|
||||||
<string name="logs_action_resume">재개</string>
|
<string name="logs_action_resume">재개</string>
|
||||||
<string name="logs_action_export">공유</string>
|
<string name="logs_action_export">공유</string>
|
||||||
@@ -539,7 +537,7 @@
|
|||||||
<string name="system_label_arch">아키텍처</string>
|
<string name="system_label_arch">아키텍처</string>
|
||||||
<string name="system_label_hostname">호스트명</string>
|
<string name="system_label_hostname">호스트명</string>
|
||||||
<string name="system_label_python">파이썬</string>
|
<string name="system_label_python">파이썬</string>
|
||||||
<string name="system_label_hermes_version">헤르메스</string>
|
<string name="system_label_hermes_version">AITURK</string>
|
||||||
<string name="system_label_cpu">CPU</string>
|
<string name="system_label_cpu">CPU</string>
|
||||||
<string name="system_label_memory">메모리</string>
|
<string name="system_label_memory">메모리</string>
|
||||||
<string name="system_label_disk">디스크</string>
|
<string name="system_label_disk">디스크</string>
|
||||||
@@ -553,8 +551,8 @@
|
|||||||
<string name="system_version_behind">%1$d 버전 뒤짐</string>
|
<string name="system_version_behind">%1$d 버전 뒤짐</string>
|
||||||
<string name="system_action_check_updates">업데이트 확인</string>
|
<string name="system_action_check_updates">업데이트 확인</string>
|
||||||
<string name="system_action_update_now">지금 업데이트</string>
|
<string name="system_action_update_now">지금 업데이트</string>
|
||||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
<string name="system_update_confirm_title">AITURK 업데이트?</string>
|
||||||
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
<string name="system_update_confirm_desc">AITURK 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||||
<string name="system_migration_title">게이트웨이 마이그레이션</string>
|
<string name="system_migration_title">게이트웨이 마이그레이션</string>
|
||||||
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
|
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
|
||||||
<string name="system_migration_profiles">프로필</string>
|
<string name="system_migration_profiles">프로필</string>
|
||||||
@@ -703,7 +701,7 @@
|
|||||||
|
|
||||||
<!-- Profiles Screen -->
|
<!-- Profiles Screen -->
|
||||||
<string name="profiles_empty_title">프로필이 없습니다</string>
|
<string name="profiles_empty_title">프로필이 없습니다</string>
|
||||||
<string name="profiles_empty_desc">Hermes의 연결 프로필이 여기에 표시됩니다.</string>
|
<string name="profiles_empty_desc">AITURK의 연결 프로필이 여기에 표시됩니다.</string>
|
||||||
<string name="profiles_label_model">모델: %1$s</string>
|
<string name="profiles_label_model">모델: %1$s</string>
|
||||||
<string name="profiles_label_provider">제공자: %1$s</string>
|
<string name="profiles_label_provider">제공자: %1$s</string>
|
||||||
<string name="profiles_label_skills">%1$d개 스킬</string>
|
<string name="profiles_label_skills">%1$d개 스킬</string>
|
||||||
@@ -745,7 +743,7 @@
|
|||||||
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
|
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
|
||||||
<string name="profiles_delete_path">경로: %1$s</string>
|
<string name="profiles_delete_path">경로: %1$s</string>
|
||||||
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
|
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
|
||||||
<string name="profiles_setup_command_hint">Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
|
<string name="profiles_setup_command_hint">AITURK CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
|
||||||
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
|
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
|
||||||
<string name="profiles_builder_step_identity">아이덴티티</string>
|
<string name="profiles_builder_step_identity">아이덴티티</string>
|
||||||
<string name="profiles_builder_step_model">모델 설정</string>
|
<string name="profiles_builder_step_model">모델 설정</string>
|
||||||
@@ -763,7 +761,7 @@
|
|||||||
<string name="profiles_builder_skills_default_desc">공통 시스템 도구 자동 포함</string>
|
<string name="profiles_builder_skills_default_desc">공통 시스템 도구 자동 포함</string>
|
||||||
<string name="profiles_builder_skills_local">로컬 스킬 선택</string>
|
<string name="profiles_builder_skills_local">로컬 스킬 선택</string>
|
||||||
<string name="profiles_builder_skills_search_hub">허브 검색</string>
|
<string name="profiles_builder_skills_search_hub">허브 검색</string>
|
||||||
<string name="profiles_builder_skills_hub_title">Hermes 허브에서 스킬 추가</string>
|
<string name="profiles_builder_skills_hub_title">AITURK 허브에서 스킬 추가</string>
|
||||||
<string name="profiles_builder_skills_added">추가된 스킬:</string>
|
<string name="profiles_builder_skills_added">추가된 스킬:</string>
|
||||||
<string name="profiles_builder_skills_search_results">검색 결과:</string>
|
<string name="profiles_builder_skills_search_results">검색 결과:</string>
|
||||||
<string name="profiles_builder_mcp_title">설정된 MCP 서버</string>
|
<string name="profiles_builder_mcp_title">설정된 MCP 서버</string>
|
||||||
@@ -963,7 +961,7 @@
|
|||||||
|
|
||||||
<!-- Keys Screen -->
|
<!-- Keys Screen -->
|
||||||
<string name="keys_empty_title">환경 변수 키가 없습니다</string>
|
<string name="keys_empty_title">환경 변수 키가 없습니다</string>
|
||||||
<string name="keys_empty_desc">Hermes의 환경 변수 키가 여기에 표시됩니다.</string>
|
<string name="keys_empty_desc">AITURK의 환경 변수 키가 여기에 표시됩니다.</string>
|
||||||
<string name="keys_label_not_configured">설정 안 됨</string>
|
<string name="keys_label_not_configured">설정 안 됨</string>
|
||||||
<string name="keys_action_toggle_visibility">표시 전환</string>
|
<string name="keys_action_toggle_visibility">표시 전환</string>
|
||||||
<string name="keys_search_placeholder">키 검색…</string>
|
<string name="keys_search_placeholder">키 검색…</string>
|
||||||
@@ -978,7 +976,7 @@
|
|||||||
|
|
||||||
<!-- Channels Screen -->
|
<!-- Channels Screen -->
|
||||||
<string name="channels_empty_title">설정된 채널이 없습니다</string>
|
<string name="channels_empty_title">설정된 채널이 없습니다</string>
|
||||||
<string name="channels_empty_desc">Hermes에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string>
|
<string name="channels_empty_desc">AITURK에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string>
|
||||||
<string name="channels_action_configure">설정</string>
|
<string name="channels_action_configure">설정</string>
|
||||||
<string name="channels_sec_settings">설정</string>
|
<string name="channels_sec_settings">설정</string>
|
||||||
<string name="channels_action_save_settings">설정 저장</string>
|
<string name="channels_action_save_settings">설정 저장</string>
|
||||||
@@ -1007,7 +1005,7 @@
|
|||||||
|
|
||||||
<!-- Auth Login Screen -->
|
<!-- Auth Login Screen -->
|
||||||
<string name="auth_login_title">인증 로그인</string>
|
<string name="auth_login_title">인증 로그인</string>
|
||||||
<string name="auth_login_desc">Hermes 대시보드에 연결</string>
|
<string name="auth_login_desc">AITURK 대시보드에 연결</string>
|
||||||
<string name="auth_login_token_label">세션 토큰</string>
|
<string name="auth_login_token_label">세션 토큰</string>
|
||||||
<string name="auth_login_username_label">사용자명</string>
|
<string name="auth_login_username_label">사용자명</string>
|
||||||
<string name="auth_login_password_label">비밀번호</string>
|
<string name="auth_login_password_label">비밀번호</string>
|
||||||
@@ -1022,8 +1020,8 @@
|
|||||||
<string name="auth_login_use_profile">%1$s(으)로 접속</string>
|
<string name="auth_login_use_profile">%1$s(으)로 접속</string>
|
||||||
|
|
||||||
<!-- Landing Screen -->
|
<!-- Landing Screen -->
|
||||||
<string name="landing_title">헤르메스 모바일</string>
|
<string name="landing_title">AITURK Mobil</string>
|
||||||
<string name="landing_subtitle">Hermes Agent 제어판</string>
|
<string name="landing_subtitle">AITURK 제어판</string>
|
||||||
<string name="landing_action_auth_login">인증 로그인</string>
|
<string name="landing_action_auth_login">인증 로그인</string>
|
||||||
|
|
||||||
<!-- Loading / Startup State (issue #1171) -->
|
<!-- Loading / Startup State (issue #1171) -->
|
||||||
@@ -1210,7 +1208,7 @@
|
|||||||
<string name="connection_setup_open_browser">브라우저에서 열기</string>
|
<string name="connection_setup_open_browser">브라우저에서 열기</string>
|
||||||
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
|
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
|
||||||
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
|
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
|
||||||
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
|
<string name="connection_setup_pending">AITURK를 기다리는 중…</string>
|
||||||
<string name="connection_catalog_title">%1$s 설치</string>
|
<string name="connection_catalog_title">%1$s 설치</string>
|
||||||
<string name="connection_catalog_kind_plugin">플러그인</string>
|
<string name="connection_catalog_kind_plugin">플러그인</string>
|
||||||
<string name="connection_catalog_kind_skill">스킬</string>
|
<string name="connection_catalog_kind_skill">스킬</string>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="chat_image_paste_loading">Yapıştırılan görseller ekleniyor…</string>
|
||||||
|
<string name="chat_image_paste_failed">Görsel yapıştırılamadı. Erişimi kontrol edip yeniden deneyin.</string>
|
||||||
|
<string name="chat_image_paste_too_large">Görsel 10 MB ek sınırını aşıyor.</string>
|
||||||
|
<string name="chat_image_paste_batch_limit">Toplamı 20 MB\'ı geçmeyen en fazla 8 görsel yapıştırılabilir.</string>
|
||||||
|
<string name="chat_image_paste_busy">Yeniden yapıştırmadan önce görsellerin yüklenmesini bekleyin.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="chat_reply_failed_title">AITURK bu yanıtı tamamlayamadı</string>
|
||||||
|
<string name="chat_reply_failed_description">AITURK yanıt verirken bir sorun oluştu. Ne olduğunu anlamak için ayrıntıları ya da günlükleri açın.</string>
|
||||||
|
<string name="chat_reply_failed_details">Ayrıntılar</string>
|
||||||
|
<string name="chat_reply_failed_details_collapsed">Daraltıldı</string>
|
||||||
|
<string name="chat_reply_failed_details_expanded">Genişletildi</string>
|
||||||
|
<string name="chat_reply_failed_copy">Hata ayrıntılarını kopyala</string>
|
||||||
|
<string name="chat_reply_failed_logs">Günlükleri aç</string>
|
||||||
|
<string name="chat_reply_failed_share">Tanılama bilgisini paylaş</string>
|
||||||
|
<string name="chat_reply_failed_share_note">Paylaşmadan önce ayrıntıları gözden geçirin. Tanılama bilgisi bu hatayı ve uygulama sürümünü içerir; konuşmanızı ya da eklerinizi içermez.</string>
|
||||||
|
<string name="chat_reply_failed_dismiss">Hatayı kapat</string>
|
||||||
|
|
||||||
|
<string name="chat_reply_failed_copied">Hata ayrıntıları kopyalandı</string>
|
||||||
|
<string name="chat_reply_failed_share_unavailable">Tanılama bilgisini paylaşacak uygulama yok</string>
|
||||||
|
</resources>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<!-- Simplified Chinese (zh) translation -->
|
<!-- Simplified Chinese (zh) translation -->
|
||||||
<string name="app_name">Hermes Mobile</string>
|
<string name="app_name">AITURK Mobil</string>
|
||||||
<string name="chat_send_desc">发送</string>
|
<string name="chat_send_desc">发送</string>
|
||||||
<string name="chat_mic_listening">正在聆听…</string>
|
<string name="chat_mic_listening">正在聆听…</string>
|
||||||
<string name="stt_permission_denied">语音输入需要麦克风权限</string>
|
<string name="stt_permission_denied">语音输入需要麦克风权限</string>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<string name="billing_plan">套餐</string>
|
<string name="billing_plan">套餐</string>
|
||||||
<string name="billing_topup">充值</string>
|
<string name="billing_topup">充值</string>
|
||||||
<string name="press_back_again_to_exit">再次返回以退出应用</string>
|
<string name="press_back_again_to_exit">再次返回以退出应用</string>
|
||||||
<string name="nav_drawer_title">Hermes</string>
|
<string name="nav_drawer_title">AITURK</string>
|
||||||
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
|
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
|
||||||
<string name="nav_drawer_section_converse">对话</string>
|
<string name="nav_drawer_section_converse">对话</string>
|
||||||
<string name="nav_drawer_section_automate">自动化</string>
|
<string name="nav_drawer_section_automate">自动化</string>
|
||||||
@@ -127,27 +127,27 @@
|
|||||||
<string name="screen_analytics">分析</string>
|
<string name="screen_analytics">分析</string>
|
||||||
<string name="screen_billing">计费</string>
|
<string name="screen_billing">计费</string>
|
||||||
<string name="screen_files">文件</string>
|
<string name="screen_files">文件</string>
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">AITURK</string>
|
||||||
<string name="notif_waiting_replies">等待 Hermes 回复</string>
|
<string name="notif_waiting_replies">等待 AITURK 回复</string>
|
||||||
<string name="notif_connected_in_background">在后台保持连接</string>
|
<string name="notif_connected_in_background">在后台保持连接</string>
|
||||||
<string name="notif_action_turn_off">关闭</string>
|
<string name="notif_action_turn_off">关闭</string>
|
||||||
<string name="notif_connecting">正在连接…</string>
|
<string name="notif_connecting">正在连接…</string>
|
||||||
<string name="notif_waiting_network">等待网络连接</string>
|
<string name="notif_waiting_network">等待网络连接</string>
|
||||||
<string name="notif_reconnecting">正在重新连接</string>
|
<string name="notif_reconnecting">正在重新连接</string>
|
||||||
<string name="notif_new_message">新消息</string>
|
<string name="notif_new_message">新消息</string>
|
||||||
<string name="notif_clarification_needed">Hermes 需要确认</string>
|
<string name="notif_clarification_needed">AITURK 需要确认</string>
|
||||||
<string name="notif_reply_placeholder">输入回复…</string>
|
<string name="notif_reply_placeholder">输入回复…</string>
|
||||||
<string name="notif_channel_service_name">Hermes 后台服务</string>
|
<string name="notif_channel_service_name">AITURK 后台服务</string>
|
||||||
<string name="notif_channel_service_desc">在后台保持连接</string>
|
<string name="notif_channel_service_desc">在后台保持连接</string>
|
||||||
<string name="notif_channel_chat_name">Hermes 对话回复</string>
|
<string name="notif_channel_chat_name">AITURK 对话回复</string>
|
||||||
<string name="notif_channel_chat_desc">Hermes 新回复提醒</string>
|
<string name="notif_channel_chat_desc">AITURK 新回复提醒</string>
|
||||||
<string name="action_reply">回复</string>
|
<string name="action_reply">回复</string>
|
||||||
<string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string>
|
<string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string>
|
||||||
<string name="chat_action_search">搜索</string>
|
<string name="chat_action_search">搜索</string>
|
||||||
<string name="chat_action_close_search">关闭搜索</string>
|
<string name="chat_action_close_search">关闭搜索</string>
|
||||||
<string name="chat_action_reconnect">重新连接</string>
|
<string name="chat_action_reconnect">重新连接</string>
|
||||||
<string name="chat_action_relogin">重新登录</string>
|
<string name="chat_action_relogin">重新登录</string>
|
||||||
<string name="chat_relogin_title">重新登录 Hermes</string>
|
<string name="chat_relogin_title">重新登录 AITURK</string>
|
||||||
<string name="chat_relogin_username">用户名</string>
|
<string name="chat_relogin_username">用户名</string>
|
||||||
<string name="chat_relogin_password">密码</string>
|
<string name="chat_relogin_password">密码</string>
|
||||||
<string name="chat_relogin_submit">登录</string>
|
<string name="chat_relogin_submit">登录</string>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<string name="chat_relogin_error_empty">用户名和密码不能为空</string>
|
<string name="chat_relogin_error_empty">用户名和密码不能为空</string>
|
||||||
<string name="chat_empty_title">准备就绪</string>
|
<string name="chat_empty_title">准备就绪</string>
|
||||||
<string name="chat_empty_subtitle">发送消息开始对话</string>
|
<string name="chat_empty_subtitle">发送消息开始对话</string>
|
||||||
<string name="chat_status_connecting">正在连接 Hermes…</string>
|
<string name="chat_status_connecting">正在连接 AITURK…</string>
|
||||||
<string name="chat_resume_error_title">无法连接此会话</string>
|
<string name="chat_resume_error_title">无法连接此会话</string>
|
||||||
<string name="chat_status_reconnecting">重新连接中…</string>
|
<string name="chat_status_reconnecting">重新连接中…</string>
|
||||||
<string name="chat_status_disconnected">已断开</string>
|
<string name="chat_status_disconnected">已断开</string>
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<string name="settings_sec_connection">连接</string>
|
<string name="settings_sec_connection">连接</string>
|
||||||
<string name="settings_field_token">认证令牌</string>
|
<string name="settings_field_token">认证令牌</string>
|
||||||
<string name="settings_keep_connected_in_background_title">在后台保持连接</string>
|
<string name="settings_keep_connected_in_background_title">在后台保持连接</string>
|
||||||
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。</string>
|
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 AITURK 连接。会显示常驻通知,并可能消耗更多电量。</string>
|
||||||
<string name="settings_sec_behavior">行为</string>
|
<string name="settings_sec_behavior">行为</string>
|
||||||
<string name="settings_item_auto_reconnect">自动重连</string>
|
<string name="settings_item_auto_reconnect">自动重连</string>
|
||||||
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
||||||
@@ -227,9 +227,7 @@
|
|||||||
<string name="settings_action_test_connection">测试连接</string>
|
<string name="settings_action_test_connection">测试连接</string>
|
||||||
<string name="settings_sec_about">关于</string>
|
<string name="settings_sec_about">关于</string>
|
||||||
<string name="settings_sec_language">语言</string>
|
<string name="settings_sec_language">语言</string>
|
||||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||||
<string name="settings_support_title">支持 Hermes Mobile</string>
|
|
||||||
<string name="settings_support_message">如果 Hermes Mobile 为你节省了时间,欢迎支持它的开发。</string>
|
|
||||||
<string name="settings_support_action">在 Ko-fi 上支持我</string>
|
<string name="settings_support_action">在 Ko-fi 上支持我</string>
|
||||||
<string name="settings_about_version">版本</string>
|
<string name="settings_about_version">版本</string>
|
||||||
<string name="settings_about_build">构建</string>
|
<string name="settings_about_build">构建</string>
|
||||||
@@ -289,7 +287,7 @@
|
|||||||
<string name="connect_selected_profile">配置:%1$s</string>
|
<string name="connect_selected_profile">配置:%1$s</string>
|
||||||
<string name="connect_action_select_profile">选择已保存配置</string>
|
<string name="connect_action_select_profile">选择已保存配置</string>
|
||||||
<string name="connect_health_title">后端健康状态</string>
|
<string name="connect_health_title">后端健康状态</string>
|
||||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||||
<string name="connect_health_auth_gated">需登录(OAuth)</string>
|
<string name="connect_health_auth_gated">需登录(OAuth)</string>
|
||||||
<string name="connect_health_auth_token">令牌</string>
|
<string name="connect_health_auth_token">令牌</string>
|
||||||
<string name="connect_error_token_required">需要填写令牌</string>
|
<string name="connect_error_token_required">需要填写令牌</string>
|
||||||
@@ -297,7 +295,7 @@
|
|||||||
<string name="connect_error_403">访问被拒绝(403 禁止访问)</string>
|
<string name="connect_error_403">访问被拒绝(403 禁止访问)</string>
|
||||||
<string name="connect_error_http_code">服务器返回 HTTP %1$d</string>
|
<string name="connect_error_http_code">服务器返回 HTTP %1$d</string>
|
||||||
<string name="connect_error_timeout">连接超时</string>
|
<string name="connect_error_timeout">连接超时</string>
|
||||||
<string name="connect_error_refused">连接被拒绝——Hermes 是否在运行?</string>
|
<string name="connect_error_refused">连接被拒绝——AITURK 是否在运行?</string>
|
||||||
<string name="connect_error_resolve">无法解析主机名</string>
|
<string name="connect_error_resolve">无法解析主机名</string>
|
||||||
<string name="connect_error_connection_failed">连接失败:%1$s</string>
|
<string name="connect_error_connection_failed">连接失败:%1$s</string>
|
||||||
<string name="connect_save_profile">保存连接配置</string>
|
<string name="connect_save_profile">保存连接配置</string>
|
||||||
@@ -499,7 +497,7 @@
|
|||||||
<string name="skills_hub_source_offline">离线</string>
|
<string name="skills_hub_source_offline">离线</string>
|
||||||
<string name="skills_hub_source_via_index">通过索引</string>
|
<string name="skills_hub_source_via_index">通过索引</string>
|
||||||
<string name="skills_hub_featured_title">精选技能</string>
|
<string name="skills_hub_featured_title">精选技能</string>
|
||||||
<string name="skills_hub_featured_subtitle">来自 Hermes 索引——搜索获取更多</string>
|
<string name="skills_hub_featured_subtitle">来自 AITURK 索引——搜索获取更多</string>
|
||||||
<string name="skills_hub_landing_hint">在上方搜索可安装的技能</string>
|
<string name="skills_hub_landing_hint">在上方搜索可安装的技能</string>
|
||||||
<string name="skills_hub_scan">扫描</string>
|
<string name="skills_hub_scan">扫描</string>
|
||||||
<string name="skills_hub_scan_verdict_label">判定:%1$s</string>
|
<string name="skills_hub_scan_verdict_label">判定:%1$s</string>
|
||||||
@@ -520,7 +518,7 @@
|
|||||||
<string name="content_desc_update_skills_hub">从中心更新技能</string>
|
<string name="content_desc_update_skills_hub">从中心更新技能</string>
|
||||||
<string name="content_desc_save_changes">保存更改</string>
|
<string name="content_desc_save_changes">保存更改</string>
|
||||||
<string name="logs_empty_title">暂无日志</string>
|
<string name="logs_empty_title">暂无日志</string>
|
||||||
<string name="logs_empty_desc">Hermes 的日志输出将显示在这里。</string>
|
<string name="logs_empty_desc">AITURK 的日志输出将显示在这里。</string>
|
||||||
<string name="logs_action_pause">暂停</string>
|
<string name="logs_action_pause">暂停</string>
|
||||||
<string name="logs_action_resume">继续</string>
|
<string name="logs_action_resume">继续</string>
|
||||||
<string name="logs_action_export">分享</string>
|
<string name="logs_action_export">分享</string>
|
||||||
@@ -543,7 +541,7 @@
|
|||||||
<string name="system_label_arch">架构</string>
|
<string name="system_label_arch">架构</string>
|
||||||
<string name="system_label_hostname">主机名</string>
|
<string name="system_label_hostname">主机名</string>
|
||||||
<string name="system_label_python">Python</string>
|
<string name="system_label_python">Python</string>
|
||||||
<string name="system_label_hermes_version">Hermes</string>
|
<string name="system_label_hermes_version">AITURK</string>
|
||||||
<string name="system_label_cpu">CPU</string>
|
<string name="system_label_cpu">CPU</string>
|
||||||
<string name="system_label_memory">内存</string>
|
<string name="system_label_memory">内存</string>
|
||||||
<string name="system_label_disk">磁盘</string>
|
<string name="system_label_disk">磁盘</string>
|
||||||
@@ -557,7 +555,7 @@
|
|||||||
<string name="system_version_behind">落后 %1$d 个版本</string>
|
<string name="system_version_behind">落后 %1$d 个版本</string>
|
||||||
<string name="system_action_check_updates">检查更新</string>
|
<string name="system_action_check_updates">检查更新</string>
|
||||||
<string name="system_action_update_now">立即更新</string>
|
<string name="system_action_update_now">立即更新</string>
|
||||||
<string name="system_update_confirm_title">更新 Hermes?</string>
|
<string name="system_update_confirm_title">更新 AITURK?</string>
|
||||||
<string name="system_update_confirm_desc">这将运行 hermes update 并重启网关。</string>
|
<string name="system_update_confirm_desc">这将运行 hermes update 并重启网关。</string>
|
||||||
<string name="system_update_progress_title">软件更新</string>
|
<string name="system_update_progress_title">软件更新</string>
|
||||||
<string name="system_sec_portal">Nous Portal</string>
|
<string name="system_sec_portal">Nous Portal</string>
|
||||||
@@ -633,10 +631,10 @@
|
|||||||
<string name="system_sec_restore">从上传恢复</string>
|
<string name="system_sec_restore">从上传恢复</string>
|
||||||
<string name="system_op_restore_upload">上传恢复包</string>
|
<string name="system_op_restore_upload">上传恢复包</string>
|
||||||
<string name="system_op_restore_pick">选择备份文件</string>
|
<string name="system_op_restore_pick">选择备份文件</string>
|
||||||
<string name="system_op_import_confirm_title">恢复完整 Hermes 备份?</string>
|
<string name="system_op_import_confirm_title">恢复完整 AITURK 备份?</string>
|
||||||
<string name="system_op_import_confirm_desc">这将覆盖当前的 Hermes 配置、技能、会话和数据。此操作无法撤销。</string>
|
<string name="system_op_import_confirm_desc">这将覆盖当前的 AITURK 配置、技能、会话和数据。此操作无法撤销。</string>
|
||||||
<string name="system_sec_debug_share">调试分享</string>
|
<string name="system_sec_debug_share">调试分享</string>
|
||||||
<string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 Hermes 团队。粘贴内容 6 小时后自动删除。</string>
|
<string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 AITURK 团队。粘贴内容 6 小时后自动删除。</string>
|
||||||
<string name="system_debug_share_generate">生成分享链接</string>
|
<string name="system_debug_share_generate">生成分享链接</string>
|
||||||
<string name="system_debug_share_uploading">上传中…</string>
|
<string name="system_debug_share_uploading">上传中…</string>
|
||||||
<string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string>
|
<string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string>
|
||||||
@@ -691,7 +689,7 @@
|
|||||||
<item quantity="other">%1$d 个模型</item>
|
<item quantity="other">%1$d 个模型</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="history_empty_title">暂无历史</string>
|
<string name="history_empty_title">暂无历史</string>
|
||||||
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
<string name="history_empty_desc">之前的 AITURK 对话会话将显示在这里。</string>
|
||||||
<string name="history_untitled">未命名</string>
|
<string name="history_untitled">未命名</string>
|
||||||
<string name="history_message_count">%1$d 条消息</string>
|
<string name="history_message_count">%1$d 条消息</string>
|
||||||
<plurals name="sessions_card_message_count">
|
<plurals name="sessions_card_message_count">
|
||||||
@@ -820,7 +818,7 @@
|
|||||||
<string name="config_action_save">保存</string>
|
<string name="config_action_save">保存</string>
|
||||||
<string name="config_action_save_yaml">保存 YAML</string>
|
<string name="config_action_save_yaml">保存 YAML</string>
|
||||||
<string name="profiles_empty_title">没有可用配置</string>
|
<string name="profiles_empty_title">没有可用配置</string>
|
||||||
<string name="profiles_empty_desc">Hermes 的连接配置将显示在这里。</string>
|
<string name="profiles_empty_desc">AITURK 的连接配置将显示在这里。</string>
|
||||||
<string name="profiles_label_model">模型:%1$s</string>
|
<string name="profiles_label_model">模型:%1$s</string>
|
||||||
<string name="profiles_label_provider">提供商:%1$s</string>
|
<string name="profiles_label_provider">提供商:%1$s</string>
|
||||||
<string name="profiles_label_skills">%1$d 个技能</string>
|
<string name="profiles_label_skills">%1$d 个技能</string>
|
||||||
@@ -862,7 +860,7 @@
|
|||||||
<string name="profiles_delete_warning">这将永久删除配置"%1$s"及其数据。</string>
|
<string name="profiles_delete_warning">这将永久删除配置"%1$s"及其数据。</string>
|
||||||
<string name="profiles_delete_path">路径:%1$s</string>
|
<string name="profiles_delete_path">路径:%1$s</string>
|
||||||
<string name="profiles_delete_gateway_warning">此配置有正在运行的网关——删除它会将其停止。</string>
|
<string name="profiles_delete_gateway_warning">此配置有正在运行的网关——删除它会将其停止。</string>
|
||||||
<string name="profiles_setup_command_hint">在 Hermes CLI 中运行此命令以配置此配置文件。</string>
|
<string name="profiles_setup_command_hint">在 AITURK CLI 中运行此命令以配置此配置文件。</string>
|
||||||
<string name="profiles_builder_step_indicator">第 %1$d 步,共 5 步</string>
|
<string name="profiles_builder_step_indicator">第 %1$d 步,共 5 步</string>
|
||||||
<string name="profiles_builder_step_identity">身份</string>
|
<string name="profiles_builder_step_identity">身份</string>
|
||||||
<string name="profiles_builder_step_model">模型配置</string>
|
<string name="profiles_builder_step_model">模型配置</string>
|
||||||
@@ -880,7 +878,7 @@
|
|||||||
<string name="profiles_builder_skills_default_desc">自动打包常用系统工具</string>
|
<string name="profiles_builder_skills_default_desc">自动打包常用系统工具</string>
|
||||||
<string name="profiles_builder_skills_local">选择本地技能</string>
|
<string name="profiles_builder_skills_local">选择本地技能</string>
|
||||||
<string name="profiles_builder_skills_search_hub">搜索中心</string>
|
<string name="profiles_builder_skills_search_hub">搜索中心</string>
|
||||||
<string name="profiles_builder_skills_hub_title">从 Hermes 中心添加技能</string>
|
<string name="profiles_builder_skills_hub_title">从 AITURK 中心添加技能</string>
|
||||||
<string name="profiles_builder_skills_added">已添加技能:</string>
|
<string name="profiles_builder_skills_added">已添加技能:</string>
|
||||||
<string name="profiles_builder_skills_search_results">搜索结果:</string>
|
<string name="profiles_builder_skills_search_results">搜索结果:</string>
|
||||||
<string name="profiles_builder_mcp_title">已配置的 MCP 服务器</string>
|
<string name="profiles_builder_mcp_title">已配置的 MCP 服务器</string>
|
||||||
@@ -1016,7 +1014,7 @@
|
|||||||
<string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string>
|
<string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string>
|
||||||
<string name="pressure_banner_memory_elevated">代理内存不足</string>
|
<string name="pressure_banner_memory_elevated">代理内存不足</string>
|
||||||
<string name="cron_empty_title">没有定时任务</string>
|
<string name="cron_empty_title">没有定时任务</string>
|
||||||
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string>
|
<string name="cron_empty_desc">AITURK 的定时任务将显示在这里。</string>
|
||||||
<string name="cron_status_active">启用</string>
|
<string name="cron_status_active">启用</string>
|
||||||
<string name="cron_status_paused">已暂停</string>
|
<string name="cron_status_paused">已暂停</string>
|
||||||
<string name="cron_action_pause">暂停</string>
|
<string name="cron_action_pause">暂停</string>
|
||||||
@@ -1091,7 +1089,7 @@
|
|||||||
<string name="achievements_reset_title">重置成就?</string>
|
<string name="achievements_reset_title">重置成就?</string>
|
||||||
<string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</string>
|
<string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</string>
|
||||||
<string name="keys_empty_title">没有环境密钥数据</string>
|
<string name="keys_empty_title">没有环境密钥数据</string>
|
||||||
<string name="keys_empty_desc">Hermes 的环境密钥将显示在这里。</string>
|
<string name="keys_empty_desc">AITURK 的环境密钥将显示在这里。</string>
|
||||||
<string name="keys_label_not_configured">未配置</string>
|
<string name="keys_label_not_configured">未配置</string>
|
||||||
<string name="keys_action_toggle_visibility">切换可见性</string>
|
<string name="keys_action_toggle_visibility">切换可见性</string>
|
||||||
<string name="keys_search_placeholder">搜索密钥…</string>
|
<string name="keys_search_placeholder">搜索密钥…</string>
|
||||||
@@ -1132,7 +1130,7 @@
|
|||||||
<string name="files_dialog_delete_title">删除?</string>
|
<string name="files_dialog_delete_title">删除?</string>
|
||||||
<string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</string>
|
<string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</string>
|
||||||
<string name="channels_empty_title">未配置渠道</string>
|
<string name="channels_empty_title">未配置渠道</string>
|
||||||
<string name="channels_empty_desc">在 Hermes 中添加消息平台后,会显示在这里。</string>
|
<string name="channels_empty_desc">在 AITURK 中添加消息平台后,会显示在这里。</string>
|
||||||
<string name="channels_action_configure">配置</string>
|
<string name="channels_action_configure">配置</string>
|
||||||
<string name="channels_sec_settings">配置设置</string>
|
<string name="channels_sec_settings">配置设置</string>
|
||||||
<string name="channels_action_save_settings">保存设置</string>
|
<string name="channels_action_save_settings">保存设置</string>
|
||||||
@@ -1155,7 +1153,7 @@
|
|||||||
<string name="settings_saved_profiles">已保存配置(%1$d)</string>
|
<string name="settings_saved_profiles">已保存配置(%1$d)</string>
|
||||||
<string name="settings_logout">退出登录</string>
|
<string name="settings_logout">退出登录</string>
|
||||||
<string name="auth_login_title">认证登录</string>
|
<string name="auth_login_title">认证登录</string>
|
||||||
<string name="auth_login_desc">连接到你的 Hermes dashboard</string>
|
<string name="auth_login_desc">连接到你的 AITURK dashboard</string>
|
||||||
<string name="auth_login_base_url_label">服务器地址</string>
|
<string name="auth_login_base_url_label">服务器地址</string>
|
||||||
<string name="auth_login_token_label">会话令牌</string>
|
<string name="auth_login_token_label">会话令牌</string>
|
||||||
<string name="auth_login_username_label">用户名</string>
|
<string name="auth_login_username_label">用户名</string>
|
||||||
@@ -1168,11 +1166,11 @@
|
|||||||
<string name="auth_login_error_password_required">需要填写密码</string>
|
<string name="auth_login_error_password_required">需要填写密码</string>
|
||||||
<string name="auth_login_error_unreachable">无法访问 dashboard——检查 IP 和端口</string>
|
<string name="auth_login_error_unreachable">无法访问 dashboard——检查 IP 和端口</string>
|
||||||
<string name="auth_login_error_oauth_unsupported">此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639)</string>
|
<string name="auth_login_error_oauth_unsupported">此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639)</string>
|
||||||
<string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 Hermes Mobile 中支持。</string>
|
<string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 AITURK Mobil 中支持。</string>
|
||||||
<string name="auth_login_existing_profiles_title">使用已登录的配置:</string>
|
<string name="auth_login_existing_profiles_title">使用已登录的配置:</string>
|
||||||
<string name="auth_login_use_profile">以 %1$s 身份进入</string>
|
<string name="auth_login_use_profile">以 %1$s 身份进入</string>
|
||||||
<string name="landing_title">Hermes Mobile</string>
|
<string name="landing_title">AITURK Mobil</string>
|
||||||
<string name="landing_subtitle">Hermes Agent 控制面板</string>
|
<string name="landing_subtitle">AITURK 控制面板</string>
|
||||||
<string name="landing_action_auth_login">认证登录</string>
|
<string name="landing_action_auth_login">认证登录</string>
|
||||||
<string name="auth_init_failed">应用设置初始化失败</string>
|
<string name="auth_init_failed">应用设置初始化失败</string>
|
||||||
<string name="providers_empty_title">没有 OAuth 提供商</string>
|
<string name="providers_empty_title">没有 OAuth 提供商</string>
|
||||||
@@ -1193,7 +1191,7 @@
|
|||||||
<string name="action_dismiss">忽略</string>
|
<string name="action_dismiss">忽略</string>
|
||||||
<string name="action_done">完成</string>
|
<string name="action_done">完成</string>
|
||||||
<string name="action_close">关闭</string>
|
<string name="action_close">关闭</string>
|
||||||
<string name="update_notice_banner_text">Hermes Mobile %1$s 已可用</string>
|
<string name="update_notice_banner_text">AITURK Mobil %1$s 已可用</string>
|
||||||
<string name="update_notice_banner_action">更新</string>
|
<string name="update_notice_banner_action">更新</string>
|
||||||
<string name="processes_empty_title">没有运行中的进程</string>
|
<string name="processes_empty_title">没有运行中的进程</string>
|
||||||
<string name="processes_empty_desc">此会话的后台进程将显示在这里。</string>
|
<string name="processes_empty_desc">此会话的后台进程将显示在这里。</string>
|
||||||
@@ -1283,7 +1281,7 @@
|
|||||||
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
|
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
|
||||||
<string name="gif_play">播放 GIF</string>
|
<string name="gif_play">播放 GIF</string>
|
||||||
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
|
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
|
||||||
<string name="logs_clipboard_label">Hermes 日志</string>
|
<string name="logs_clipboard_label">AITURK 日志</string>
|
||||||
<string name="logs_search_placeholder">搜索日志…</string>
|
<string name="logs_search_placeholder">搜索日志…</string>
|
||||||
<string name="mcp_no_match">没有匹配"%1$s"的服务器</string>
|
<string name="mcp_no_match">没有匹配"%1$s"的服务器</string>
|
||||||
<string name="mcp_search_placeholder">搜索 MCP 服务器…</string>
|
<string name="mcp_search_placeholder">搜索 MCP 服务器…</string>
|
||||||
@@ -1506,7 +1504,7 @@
|
|||||||
<string name="connection_setup_open_browser">在浏览器中打开</string>
|
<string name="connection_setup_open_browser">在浏览器中打开</string>
|
||||||
<string name="connection_setup_required">%1$s为必填项</string>
|
<string name="connection_setup_required">%1$s为必填项</string>
|
||||||
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
||||||
<string name="connection_setup_pending">正在等待 Hermes…</string>
|
<string name="connection_setup_pending">正在等待 AITURK…</string>
|
||||||
<string name="connection_catalog_title">安装 %1$s</string>
|
<string name="connection_catalog_title">安装 %1$s</string>
|
||||||
<string name="connection_catalog_kind_plugin">插件</string>
|
<string name="connection_catalog_kind_plugin">插件</string>
|
||||||
<string name="connection_catalog_kind_skill">技能</string>
|
<string name="connection_catalog_kind_skill">技能</string>
|
||||||
@@ -1535,6 +1533,12 @@
|
|||||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
||||||
<string name="chat_pending_sends">待发送消息</string>
|
<string name="chat_pending_sends">待发送消息</string>
|
||||||
|
<string name="chat_send_accepted">服务器已接收</string>
|
||||||
|
<plurals name="chat_pending_count">
|
||||||
|
<item quantity="other">%d 条待处理消息</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="chat_pending_discard">移除</string>
|
||||||
|
<string name="chat_pending_discard_warning">要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。</string>
|
||||||
<string name="chat_pending_queued">已在本机排队</string>
|
<string name="chat_pending_queued">已在本机排队</string>
|
||||||
<string name="chat_pending_parked">停止后已暂停</string>
|
<string name="chat_pending_parked">停止后已暂停</string>
|
||||||
<string name="chat_pending_sending">正在发送…</string>
|
<string name="chat_pending_sending">正在发送…</string>
|
||||||
@@ -1565,7 +1569,7 @@
|
|||||||
<string name="detail_dialog_docs">文档</string>
|
<string name="detail_dialog_docs">文档</string>
|
||||||
<string name="detail_dialog_maintainer">维护者</string>
|
<string name="detail_dialog_maintainer">维护者</string>
|
||||||
<string name="detail_dialog_repository">仓库</string>
|
<string name="detail_dialog_repository">仓库</string>
|
||||||
<string name="detail_dialog_requires_hermes">需要 Hermes</string>
|
<string name="detail_dialog_requires_hermes">需要 AITURK</string>
|
||||||
<string name="detail_dialog_tier">层级</string>
|
<string name="detail_dialog_tier">层级</string>
|
||||||
<string name="group_chat_action_stop">停止生成</string>
|
<string name="group_chat_action_stop">停止生成</string>
|
||||||
<string name="group_chat_bot_passed">%1$s 本轮未发言</string>
|
<string name="group_chat_bot_passed">%1$s 本轮未发言</string>
|
||||||
@@ -1574,7 +1578,7 @@
|
|||||||
<string name="group_chat_tool_no_output_success">退出码 0(无输出)</string>
|
<string name="group_chat_tool_no_output_success">退出码 0(无输出)</string>
|
||||||
<string name="group_chat_tool_running">运行中…</string>
|
<string name="group_chat_tool_running">运行中…</string>
|
||||||
<string name="kanban_bulk_complete_title">完成 %1$d 项任务</string>
|
<string name="kanban_bulk_complete_title">完成 %1$d 项任务</string>
|
||||||
<string name="notif_input_needed">Hermes 需要你的输入</string>
|
<string name="notif_input_needed">AITURK 需要你的输入</string>
|
||||||
<string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string>
|
<string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string>
|
||||||
<string name="plugins_catalog_empty_title">未找到目录插件</string>
|
<string name="plugins_catalog_empty_title">未找到目录插件</string>
|
||||||
<string name="plugins_catalog_filter_all">全部</string>
|
<string name="plugins_catalog_filter_all">全部</string>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="chat_image_paste_loading">Adding pasted images…</string>
|
||||||
|
<string name="chat_image_paste_failed">Could not paste the image. Check access and try again.</string>
|
||||||
|
<string name="chat_image_paste_too_large">Image exceeds the 10 MB attachment limit.</string>
|
||||||
|
<string name="chat_image_paste_batch_limit">Paste up to 8 images totaling no more than 20 MB.</string>
|
||||||
|
<string name="chat_image_paste_busy">Wait for the images to finish loading before pasting again.</string>
|
||||||
|
</resources>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="chat_reply_failed_title">Hermes couldn\'t finish this reply</string>
|
<string name="chat_reply_failed_title">AITURK couldn\'t finish this reply</string>
|
||||||
<string name="chat_reply_failed_description">Something went wrong while Hermes was replying. Open the details or logs to understand what happened.</string>
|
<string name="chat_reply_failed_description">Something went wrong while AITURK was replying. Open the details or logs to understand what happened.</string>
|
||||||
<string name="chat_reply_failed_details">Details</string>
|
<string name="chat_reply_failed_details">Details</string>
|
||||||
<string name="chat_reply_failed_details_collapsed">Collapsed</string>
|
<string name="chat_reply_failed_details_collapsed">Collapsed</string>
|
||||||
<string name="chat_reply_failed_details_expanded">Expanded</string>
|
<string name="chat_reply_failed_details_expanded">Expanded</string>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Hermes Mobile</string>
|
<string name="aiturk_backend_not_ready">The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes.</string>
|
||||||
|
<string name="app_name">AITURK Mobil</string>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<string name="chat_send_desc">Send</string>
|
<string name="chat_send_desc">Send</string>
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
|
|
||||||
<!-- Navigation & Drawer -->
|
<!-- Navigation & Drawer -->
|
||||||
<string name="press_back_again_to_exit">Press back again to exit</string>
|
<string name="press_back_again_to_exit">Press back again to exit</string>
|
||||||
<string name="nav_drawer_title">Hermes</string>
|
<string name="nav_drawer_title">AITURK</string>
|
||||||
<string name="nav_drawer_subtitle">AI Agent Control</string>
|
<string name="nav_drawer_subtitle">AI Agent Control</string>
|
||||||
<string name="nav_drawer_section_converse">Converse</string>
|
<string name="nav_drawer_section_converse">Converse</string>
|
||||||
<string name="nav_drawer_section_automate">Automate</string>
|
<string name="nav_drawer_section_automate">Automate</string>
|
||||||
@@ -150,21 +151,21 @@
|
|||||||
<string name="screen_files">Files</string>
|
<string name="screen_files">Files</string>
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<string name="notif_title">Hermes</string>
|
<string name="notif_title">AITURK</string>
|
||||||
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
<string name="notif_waiting_replies">Waiting for AITURK replies</string>
|
||||||
<string name="notif_connected_in_background">Connected in background</string>
|
<string name="notif_connected_in_background">Connected in background</string>
|
||||||
<string name="notif_action_turn_off">Turn off</string>
|
<string name="notif_action_turn_off">Turn off</string>
|
||||||
<string name="notif_connecting">Connecting…</string>
|
<string name="notif_connecting">Connecting…</string>
|
||||||
<string name="notif_waiting_network">Waiting for network</string>
|
<string name="notif_waiting_network">Waiting for network</string>
|
||||||
<string name="notif_reconnecting">Reconnecting</string>
|
<string name="notif_reconnecting">Reconnecting</string>
|
||||||
<string name="notif_new_message">New message</string>
|
<string name="notif_new_message">New message</string>
|
||||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
<string name="notif_clarification_needed">AITURK needs a clarification</string>
|
||||||
<string name="notif_input_needed">Hermes needs your input</string>
|
<string name="notif_input_needed">AITURK needs your input</string>
|
||||||
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
||||||
<string name="notif_channel_service_name">Hermes Background Service</string>
|
<string name="notif_channel_service_name">AITURK Background Service</string>
|
||||||
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
||||||
<string name="notif_channel_chat_name">Hermes Chat Replies</string>
|
<string name="notif_channel_chat_name">AITURK Chat Replies</string>
|
||||||
<string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string>
|
<string name="notif_channel_chat_desc">Alerts for new replies from AITURK</string>
|
||||||
<string name="notif_session_complete">Session finished: %1$s</string>
|
<string name="notif_session_complete">Session finished: %1$s</string>
|
||||||
<string name="notif_session_failed">Session failed: %1$s</string>
|
<string name="notif_session_failed">Session failed: %1$s</string>
|
||||||
<string name="action_reply">Reply</string>
|
<string name="action_reply">Reply</string>
|
||||||
@@ -188,7 +189,7 @@
|
|||||||
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
|
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
|
||||||
<string name="chat_action_reconnect">Reconnect</string>
|
<string name="chat_action_reconnect">Reconnect</string>
|
||||||
<string name="chat_action_relogin">Re-login</string>
|
<string name="chat_action_relogin">Re-login</string>
|
||||||
<string name="chat_relogin_title">Re-login to Hermes</string>
|
<string name="chat_relogin_title">Re-login to AITURK</string>
|
||||||
<string name="chat_relogin_username">Username</string>
|
<string name="chat_relogin_username">Username</string>
|
||||||
<string name="chat_relogin_password">Password</string>
|
<string name="chat_relogin_password">Password</string>
|
||||||
<string name="chat_relogin_submit">Login</string>
|
<string name="chat_relogin_submit">Login</string>
|
||||||
@@ -196,7 +197,7 @@
|
|||||||
<string name="chat_relogin_error_empty">Username and password cannot be empty</string>
|
<string name="chat_relogin_error_empty">Username and password cannot be empty</string>
|
||||||
<string name="chat_empty_title">Ready to chat</string>
|
<string name="chat_empty_title">Ready to chat</string>
|
||||||
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
||||||
<string name="chat_status_connecting">Connecting to Hermes\u2026</string>
|
<string name="chat_status_connecting">Connecting to AITURK\u2026</string>
|
||||||
<string name="chat_resume_error_title">Couldn\'t connect to this session</string>
|
<string name="chat_resume_error_title">Couldn\'t connect to this session</string>
|
||||||
<string name="chat_status_reconnecting">Reconnecting\u2026</string>
|
<string name="chat_status_reconnecting">Reconnecting\u2026</string>
|
||||||
<string name="chat_status_disconnected">Disconnected</string>
|
<string name="chat_status_disconnected">Disconnected</string>
|
||||||
@@ -219,6 +220,7 @@
|
|||||||
<string name="chat_busy_override">Send mode for this message</string>
|
<string name="chat_busy_override">Send mode for this message</string>
|
||||||
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
||||||
<string name="chat_pending_sends">Pending messages</string>
|
<string name="chat_pending_sends">Pending messages</string>
|
||||||
|
<string name="chat_send_accepted">Accepted by server</string>
|
||||||
<plurals name="chat_pending_count">
|
<plurals name="chat_pending_count">
|
||||||
<item quantity="one">%d pending message</item>
|
<item quantity="one">%d pending message</item>
|
||||||
<item quantity="other">%d pending messages</item>
|
<item quantity="other">%d pending messages</item>
|
||||||
@@ -249,7 +251,7 @@
|
|||||||
<string name="content_desc_copied">Copied</string>
|
<string name="content_desc_copied">Copied</string>
|
||||||
<string name="content_desc_speak">Read aloud</string>
|
<string name="content_desc_speak">Read aloud</string>
|
||||||
<string name="content_desc_stop_speaking">Stop speaking</string>
|
<string name="content_desc_stop_speaking">Stop speaking</string>
|
||||||
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
|
<string name="tts_error_network">Could not reach the AITURK server for speech</string>
|
||||||
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
|
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
|
||||||
<string name="tts_error_playback">Could not play the message audio</string>
|
<string name="tts_error_playback">Could not play the message audio</string>
|
||||||
<string name="content_desc_edit">Edit</string>
|
<string name="content_desc_edit">Edit</string>
|
||||||
@@ -277,9 +279,9 @@
|
|||||||
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
|
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
|
||||||
<string name="settings_field_token">Auth Token</string>
|
<string name="settings_field_token">Auth Token</string>
|
||||||
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
||||||
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
<string name="settings_keep_connected_in_background_desc">Keep AITURK connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
||||||
<string name="settings_notify_session_completions_title">Notify when a session completes</string>
|
<string name="settings_notify_session_completions_title">Notify when a session completes</string>
|
||||||
<string name="settings_notify_session_completions_desc">Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string>
|
<string name="settings_notify_session_completions_desc">Alert when any AITURK session finishes while the app is in the background. Keeps AITURK connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string>
|
||||||
<string name="settings_sec_behavior">Behavior</string>
|
<string name="settings_sec_behavior">Behavior</string>
|
||||||
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
|
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
|
||||||
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
||||||
@@ -321,7 +323,9 @@
|
|||||||
<string name="settings_action_test_connection">Test Connection</string>
|
<string name="settings_action_test_connection">Test Connection</string>
|
||||||
<string name="settings_sec_about">About</string>
|
<string name="settings_sec_about">About</string>
|
||||||
<string name="settings_sec_language">Language</string>
|
<string name="settings_sec_language">Language</string>
|
||||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||||
|
<string name="settings_about_attribution">AITURK Mobil is adapted from Hy4ri/hermes-mobile (Apache-2.0).</string>
|
||||||
|
<string name="settings_about_download">Updates</string>
|
||||||
<string name="settings_about_version">Version</string>
|
<string name="settings_about_version">Version</string>
|
||||||
<string name="settings_about_build">Build</string>
|
<string name="settings_about_build">Build</string>
|
||||||
<string name="settings_about_debug">Debug</string>
|
<string name="settings_about_debug">Debug</string>
|
||||||
@@ -340,9 +344,9 @@
|
|||||||
<string name="settings_about_update_retry">Retry</string>
|
<string name="settings_about_update_retry">Retry</string>
|
||||||
<string name="settings_about_rc_updates_title">Check release candidate updates</string>
|
<string name="settings_about_rc_updates_title">Check release candidate updates</string>
|
||||||
<string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string>
|
<string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string>
|
||||||
<string name="settings_support_title">Support Hermes Mobile</string>
|
<string name="settings_support_title">Support the original developer</string>
|
||||||
<string name="settings_support_message">If Hermes Mobile saves you time, you can support its development.</string>
|
<string name="settings_support_message">AITURK Mobil is adapted from the open-source hermes-mobile project by Hy4ri. If it saves you time, you can support the original developer.</string>
|
||||||
<string name="settings_support_action">Support me on Ko-fi</string>
|
<string name="settings_support_action">Support on Ko-fi</string>
|
||||||
|
|
||||||
<!-- Settings list summaries (drill-down) -->
|
<!-- Settings list summaries (drill-down) -->
|
||||||
<string name="settings_summary_profiles">%1$d saved</string>
|
<string name="settings_summary_profiles">%1$d saved</string>
|
||||||
@@ -385,12 +389,13 @@
|
|||||||
<string name="language_arabic">العربية</string>
|
<string name="language_arabic">العربية</string>
|
||||||
<string name="language_chinese">简体中文</string>
|
<string name="language_chinese">简体中文</string>
|
||||||
<string name="language_korean">한국어</string>
|
<string name="language_korean">한국어</string>
|
||||||
|
<string name="language_turkish">Türkçe</string>
|
||||||
|
|
||||||
<!-- Connect Screen -->
|
<!-- Connect Screen -->
|
||||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||||
<string name="connect_health_title">Backend health</string>
|
<string name="connect_health_title">Backend health</string>
|
||||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||||
<string name="connect_health_auth_gated">Gated (OAuth)</string>
|
<string name="connect_health_auth_gated">Gated (OAuth)</string>
|
||||||
<string name="connect_health_auth_token">Token</string>
|
<string name="connect_health_auth_token">Token</string>
|
||||||
|
|
||||||
@@ -400,7 +405,7 @@
|
|||||||
<string name="connect_error_403">Access denied (403 Forbidden)</string>
|
<string name="connect_error_403">Access denied (403 Forbidden)</string>
|
||||||
<string name="connect_error_http_code">Server returned HTTP %1$d</string>
|
<string name="connect_error_http_code">Server returned HTTP %1$d</string>
|
||||||
<string name="connect_error_timeout">Connection timed out</string>
|
<string name="connect_error_timeout">Connection timed out</string>
|
||||||
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
|
<string name="connect_error_refused">Connection refused – is AITURK IDE running?</string>
|
||||||
<string name="connect_error_resolve">Could not resolve host</string>
|
<string name="connect_error_resolve">Could not resolve host</string>
|
||||||
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
||||||
|
|
||||||
@@ -608,7 +613,7 @@
|
|||||||
<string name="skills_hub_source_offline">offline</string>
|
<string name="skills_hub_source_offline">offline</string>
|
||||||
<string name="skills_hub_source_via_index">via index</string>
|
<string name="skills_hub_source_via_index">via index</string>
|
||||||
<string name="skills_hub_featured_title">Featured skills</string>
|
<string name="skills_hub_featured_title">Featured skills</string>
|
||||||
<string name="skills_hub_featured_subtitle">from the Hermes index — search above for more</string>
|
<string name="skills_hub_featured_subtitle">from the skills index — search above for more</string>
|
||||||
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
|
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
|
||||||
<string name="skills_hub_scan">Scan</string>
|
<string name="skills_hub_scan">Scan</string>
|
||||||
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
|
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
|
||||||
@@ -636,7 +641,7 @@
|
|||||||
|
|
||||||
<!-- Logs Screen -->
|
<!-- Logs Screen -->
|
||||||
<string name="logs_empty_title">No logs available</string>
|
<string name="logs_empty_title">No logs available</string>
|
||||||
<string name="logs_empty_desc">Log output from Hermes will appear here.</string>
|
<string name="logs_empty_desc">Log output from AITURK will appear here.</string>
|
||||||
<string name="logs_action_pause">Pause</string>
|
<string name="logs_action_pause">Pause</string>
|
||||||
<string name="logs_action_resume">Resume</string>
|
<string name="logs_action_resume">Resume</string>
|
||||||
<string name="logs_action_export">Share</string>
|
<string name="logs_action_export">Share</string>
|
||||||
@@ -663,7 +668,7 @@
|
|||||||
<string name="system_label_arch">Arch</string>
|
<string name="system_label_arch">Arch</string>
|
||||||
<string name="system_label_hostname">Host</string>
|
<string name="system_label_hostname">Host</string>
|
||||||
<string name="system_label_python">Python</string>
|
<string name="system_label_python">Python</string>
|
||||||
<string name="system_label_hermes_version">Hermes</string>
|
<string name="system_label_hermes_version">AITURK</string>
|
||||||
<string name="system_label_cpu">CPU</string>
|
<string name="system_label_cpu">CPU</string>
|
||||||
<string name="system_label_memory">Memory</string>
|
<string name="system_label_memory">Memory</string>
|
||||||
<string name="system_label_disk">Disk</string>
|
<string name="system_label_disk">Disk</string>
|
||||||
@@ -677,7 +682,7 @@
|
|||||||
<string name="system_version_behind">%1$d behind</string>
|
<string name="system_version_behind">%1$d behind</string>
|
||||||
<string name="system_action_check_updates">Check for updates</string>
|
<string name="system_action_check_updates">Check for updates</string>
|
||||||
<string name="system_action_update_now">Update now</string>
|
<string name="system_action_update_now">Update now</string>
|
||||||
<string name="system_update_confirm_title">Update Hermes?</string>
|
<string name="system_update_confirm_title">Update AITURK?</string>
|
||||||
<string name="system_update_confirm_desc">This will run hermes update and restart the gateway.</string>
|
<string name="system_update_confirm_desc">This will run hermes update and restart the gateway.</string>
|
||||||
<string name="system_update_progress_title">Software update</string>
|
<string name="system_update_progress_title">Software update</string>
|
||||||
|
|
||||||
@@ -766,12 +771,12 @@
|
|||||||
<string name="system_sec_restore">Restore from upload</string>
|
<string name="system_sec_restore">Restore from upload</string>
|
||||||
<string name="system_op_restore_upload">Restore upload</string>
|
<string name="system_op_restore_upload">Restore upload</string>
|
||||||
<string name="system_op_restore_pick">Choose backup archive</string>
|
<string name="system_op_restore_pick">Choose backup archive</string>
|
||||||
<string name="system_op_import_confirm_title">Restore full Hermes backup?</string>
|
<string name="system_op_import_confirm_title">Restore full AITURK backup?</string>
|
||||||
<string name="system_op_import_confirm_desc">This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone.</string>
|
<string name="system_op_import_confirm_desc">This will overwrite your current AITURK configuration, skills, sessions, and data. This cannot be undone.</string>
|
||||||
|
|
||||||
<!-- Debug share section -->
|
<!-- Debug share section -->
|
||||||
<string name="system_sec_debug_share">Debug share</string>
|
<string name="system_sec_debug_share">Debug share</string>
|
||||||
<string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send the Hermes team. Pastes auto-delete after 6 hours.</string>
|
<string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send to support. Pastes auto-delete after 6 hours.</string>
|
||||||
<string name="system_debug_share_generate">Generate share link</string>
|
<string name="system_debug_share_generate">Generate share link</string>
|
||||||
<string name="system_debug_share_uploading">Uploading…</string>
|
<string name="system_debug_share_uploading">Uploading…</string>
|
||||||
<string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string>
|
<string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string>
|
||||||
@@ -838,7 +843,7 @@
|
|||||||
|
|
||||||
<!-- Sessions (History) Screen -->
|
<!-- Sessions (History) Screen -->
|
||||||
<string name="history_empty_title">No history</string>
|
<string name="history_empty_title">No history</string>
|
||||||
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
<string name="history_empty_desc">Previous chat sessions from AITURK will appear here.</string>
|
||||||
<string name="history_untitled">Untitled</string>
|
<string name="history_untitled">Untitled</string>
|
||||||
<string name="history_message_count">%1$d messages</string>
|
<string name="history_message_count">%1$d messages</string>
|
||||||
<plurals name="sessions_card_message_count">
|
<plurals name="sessions_card_message_count">
|
||||||
@@ -988,7 +993,7 @@
|
|||||||
|
|
||||||
<!-- Profiles Screen -->
|
<!-- Profiles Screen -->
|
||||||
<string name="profiles_empty_title">No profiles available</string>
|
<string name="profiles_empty_title">No profiles available</string>
|
||||||
<string name="profiles_empty_desc">Connection profiles from Hermes will appear here.</string>
|
<string name="profiles_empty_desc">Connection profiles from AITURK will appear here.</string>
|
||||||
<string name="profiles_label_model">Model: %1$s</string>
|
<string name="profiles_label_model">Model: %1$s</string>
|
||||||
<string name="profiles_label_provider">Provider: %1$s</string>
|
<string name="profiles_label_provider">Provider: %1$s</string>
|
||||||
<string name="profiles_label_skills">%1$d skills</string>
|
<string name="profiles_label_skills">%1$d skills</string>
|
||||||
@@ -1030,7 +1035,7 @@
|
|||||||
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
|
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
|
||||||
<string name="profiles_delete_path">Path: %1$s</string>
|
<string name="profiles_delete_path">Path: %1$s</string>
|
||||||
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
|
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
|
||||||
<string name="profiles_setup_command_hint">Run this command in the Hermes CLI to configure this profile.</string>
|
<string name="profiles_setup_command_hint">Run this command in the agent CLI to configure this profile.</string>
|
||||||
<string name="profiles_builder_step_indicator">Step %1$d of 5</string>
|
<string name="profiles_builder_step_indicator">Step %1$d of 5</string>
|
||||||
<string name="profiles_builder_step_identity">Identity</string>
|
<string name="profiles_builder_step_identity">Identity</string>
|
||||||
<string name="profiles_builder_step_model">Model Configuration</string>
|
<string name="profiles_builder_step_model">Model Configuration</string>
|
||||||
@@ -1048,7 +1053,7 @@
|
|||||||
<string name="profiles_builder_skills_default_desc">Automatically bundles common system tools</string>
|
<string name="profiles_builder_skills_default_desc">Automatically bundles common system tools</string>
|
||||||
<string name="profiles_builder_skills_local">Select Local Skills</string>
|
<string name="profiles_builder_skills_local">Select Local Skills</string>
|
||||||
<string name="profiles_builder_skills_search_hub">Search Hub</string>
|
<string name="profiles_builder_skills_search_hub">Search Hub</string>
|
||||||
<string name="profiles_builder_skills_hub_title">Add Skills from Hermes Hub</string>
|
<string name="profiles_builder_skills_hub_title">Add Skills from the Skills Hub</string>
|
||||||
<string name="profiles_builder_skills_added">Added Skills:</string>
|
<string name="profiles_builder_skills_added">Added Skills:</string>
|
||||||
<string name="profiles_builder_skills_search_results">Search Results:</string>
|
<string name="profiles_builder_skills_search_results">Search Results:</string>
|
||||||
<string name="profiles_builder_mcp_title">Configured MCP Servers</string>
|
<string name="profiles_builder_mcp_title">Configured MCP Servers</string>
|
||||||
@@ -1164,7 +1169,7 @@
|
|||||||
<string name="detail_dialog_maintainer">Maintainer</string>
|
<string name="detail_dialog_maintainer">Maintainer</string>
|
||||||
<string name="detail_dialog_repository">Repository</string>
|
<string name="detail_dialog_repository">Repository</string>
|
||||||
<string name="detail_dialog_commit">Commit SHA</string>
|
<string name="detail_dialog_commit">Commit SHA</string>
|
||||||
<string name="detail_dialog_requires_hermes">Requires Hermes</string>
|
<string name="detail_dialog_requires_hermes">Requires AITURK</string>
|
||||||
<string name="detail_dialog_docs">Documentation</string>
|
<string name="detail_dialog_docs">Documentation</string>
|
||||||
<string name="detail_dialog_capabilities">Capabilities</string>
|
<string name="detail_dialog_capabilities">Capabilities</string>
|
||||||
|
|
||||||
@@ -1240,7 +1245,7 @@
|
|||||||
|
|
||||||
<!-- CronJobs Screen -->
|
<!-- CronJobs Screen -->
|
||||||
<string name="cron_empty_title">No cron jobs</string>
|
<string name="cron_empty_title">No cron jobs</string>
|
||||||
<string name="cron_empty_desc">Scheduled tasks from Hermes will appear here.</string>
|
<string name="cron_empty_desc">Scheduled tasks from AITURK will appear here.</string>
|
||||||
<string name="cron_status_active">Active</string>
|
<string name="cron_status_active">Active</string>
|
||||||
<string name="cron_status_paused">Paused</string>
|
<string name="cron_status_paused">Paused</string>
|
||||||
<string name="cron_action_pause">Pause</string>
|
<string name="cron_action_pause">Pause</string>
|
||||||
@@ -1283,11 +1288,34 @@
|
|||||||
<string name="cron_edit_hint_skills">One per line</string>
|
<string name="cron_edit_hint_skills">One per line</string>
|
||||||
<string name="cron_edit_action_save">Save</string>
|
<string name="cron_edit_action_save">Save</string>
|
||||||
<string name="cron_discard_title">Discard changes?</string>
|
<string name="cron_discard_title">Discard changes?</string>
|
||||||
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
||||||
<string name="cron_delete_title">Delete job?</string>
|
<string name="cron_delete_title">Delete job?</string>
|
||||||
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
||||||
|
<string name="cron_schedule_mode_interval">Interval</string>
|
||||||
|
<string name="cron_schedule_mode_daily">Daily</string>
|
||||||
|
<string name="cron_schedule_mode_weekly">Weekly</string>
|
||||||
|
<string name="cron_schedule_mode_monthly">Monthly</string>
|
||||||
|
<string name="cron_schedule_mode_advanced">Advanced</string>
|
||||||
|
<string name="cron_schedule_select_time">Execution Time</string>
|
||||||
|
<string name="cron_schedule_advanced_toggle">Edit raw cron expression</string>
|
||||||
|
<string name="cron_schedule_visual_toggle">Switch to visual scheduler</string>
|
||||||
|
<string name="cron_schedule_weekdays_workdays">Weekdays</string>
|
||||||
|
<string name="cron_schedule_weekdays_weekend">Weekend</string>
|
||||||
|
<string name="cron_schedule_weekdays_all">Every day</string>
|
||||||
|
<string name="cron_schedule_interval_presets">Quick intervals</string>
|
||||||
|
<string name="cron_schedule_day_of_month_label">Day of month: %1$d</string>
|
||||||
|
<string name="cron_dow_mon">Mon</string>
|
||||||
|
<string name="cron_dow_tue">Tue</string>
|
||||||
|
<string name="cron_dow_wed">Wed</string>
|
||||||
|
<string name="cron_dow_thu">Thu</string>
|
||||||
|
<string name="cron_dow_fri">Fri</string>
|
||||||
|
<string name="cron_dow_sat">Sat</string>
|
||||||
|
<string name="cron_dow_sun">Sun</string>
|
||||||
|
<string name="cron_unit_minute">min</string>
|
||||||
|
<string name="cron_unit_hour">hr</string>
|
||||||
|
<string name="cron_unit_day">day</string>
|
||||||
|
|
||||||
<!-- Achievements Screen -->
|
<!-- Achievements Screen -->
|
||||||
<string name="achievements_empty_title">No achievements yet</string>
|
<string name="achievements_empty_title">No achievements yet</string>
|
||||||
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
||||||
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
||||||
@@ -1319,7 +1347,7 @@
|
|||||||
|
|
||||||
<!-- Keys Screen -->
|
<!-- Keys Screen -->
|
||||||
<string name="keys_empty_title">No environment keys reported</string>
|
<string name="keys_empty_title">No environment keys reported</string>
|
||||||
<string name="keys_empty_desc">Environment keys from Hermes will appear here.</string>
|
<string name="keys_empty_desc">Environment keys from AITURK will appear here.</string>
|
||||||
<string name="keys_label_not_configured">Not configured</string>
|
<string name="keys_label_not_configured">Not configured</string>
|
||||||
<string name="keys_action_toggle_visibility">Toggle Visibility</string>
|
<string name="keys_action_toggle_visibility">Toggle Visibility</string>
|
||||||
<string name="keys_search_placeholder">Search keys…</string>
|
<string name="keys_search_placeholder">Search keys…</string>
|
||||||
@@ -1364,7 +1392,7 @@
|
|||||||
|
|
||||||
<!-- Channels Screen -->
|
<!-- Channels Screen -->
|
||||||
<string name="channels_empty_title">No channels configured</string>
|
<string name="channels_empty_title">No channels configured</string>
|
||||||
<string name="channels_empty_desc">Add messaging platforms in Hermes to see them here.</string>
|
<string name="channels_empty_desc">Add messaging platforms in AITURK to see them here.</string>
|
||||||
<string name="channels_action_configure">Configure</string>
|
<string name="channels_action_configure">Configure</string>
|
||||||
<string name="channels_sec_settings">Configuration Settings</string>
|
<string name="channels_sec_settings">Configuration Settings</string>
|
||||||
<string name="channels_action_save_settings">Save Settings</string>
|
<string name="channels_action_save_settings">Save Settings</string>
|
||||||
@@ -1393,7 +1421,7 @@
|
|||||||
|
|
||||||
<!-- Auth Login Screen -->
|
<!-- Auth Login Screen -->
|
||||||
<string name="auth_login_title">Auth Login</string>
|
<string name="auth_login_title">Auth Login</string>
|
||||||
<string name="auth_login_desc">Connect to your Hermes dashboard</string>
|
<string name="auth_login_desc">Connect to your AITURK IDE</string>
|
||||||
<string name="auth_login_base_url_label">Server URL</string>
|
<string name="auth_login_base_url_label">Server URL</string>
|
||||||
<string name="auth_login_token_label">Session Token</string>
|
<string name="auth_login_token_label">Session Token</string>
|
||||||
<string name="auth_login_username_label">Username</string>
|
<string name="auth_login_username_label">Username</string>
|
||||||
@@ -1406,13 +1434,13 @@
|
|||||||
<string name="auth_login_error_password_required">Password is required</string>
|
<string name="auth_login_error_password_required">Password is required</string>
|
||||||
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
|
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
|
||||||
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
|
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
|
||||||
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile.</string>
|
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to AITURK Mobil.</string>
|
||||||
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
|
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
|
||||||
<string name="auth_login_use_profile">Enter as %1$s</string>
|
<string name="auth_login_use_profile">Enter as %1$s</string>
|
||||||
|
|
||||||
<!-- Landing Screen -->
|
<!-- Landing Screen -->
|
||||||
<string name="landing_title">Hermes Mobile</string>
|
<string name="landing_title">AITURK Mobil</string>
|
||||||
<string name="landing_subtitle">Control panel for Hermes Agent</string>
|
<string name="landing_subtitle">Control panel for AITURK</string>
|
||||||
<string name="landing_action_auth_login">Auth Login</string>
|
<string name="landing_action_auth_login">Auth Login</string>
|
||||||
|
|
||||||
<!-- Loading / Startup State (issue #1171) -->
|
<!-- Loading / Startup State (issue #1171) -->
|
||||||
@@ -1439,7 +1467,7 @@
|
|||||||
<string name="action_close">Close</string>
|
<string name="action_close">Close</string>
|
||||||
|
|
||||||
<!-- Update notice banner (issue #890) -->
|
<!-- Update notice banner (issue #890) -->
|
||||||
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
|
<string name="update_notice_banner_text">AITURK Mobil %1$s is available</string>
|
||||||
<string name="update_notice_banner_action">Update</string>
|
<string name="update_notice_banner_action">Update</string>
|
||||||
<string name="update_dialog_title">Update to %1$s</string>
|
<string name="update_dialog_title">Update to %1$s</string>
|
||||||
<string name="update_dialog_changelog_header">What\'s New</string>
|
<string name="update_dialog_changelog_header">What\'s New</string>
|
||||||
@@ -1537,7 +1565,7 @@
|
|||||||
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
|
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
|
||||||
<string name="gif_play">Play GIF</string>
|
<string name="gif_play">Play GIF</string>
|
||||||
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
|
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
|
||||||
<string name="logs_clipboard_label">Hermes Logs</string>
|
<string name="logs_clipboard_label">AITURK Logs</string>
|
||||||
<string name="logs_search_placeholder">Search logs...</string>
|
<string name="logs_search_placeholder">Search logs...</string>
|
||||||
<string name="mcp_no_match">No servers match "%1$s"</string>
|
<string name="mcp_no_match">No servers match "%1$s"</string>
|
||||||
<string name="mcp_search_placeholder">Search MCP servers...</string>
|
<string name="mcp_search_placeholder">Search MCP servers...</string>
|
||||||
@@ -1787,7 +1815,7 @@
|
|||||||
<string name="connection_setup_open_browser">Open in browser</string>
|
<string name="connection_setup_open_browser">Open in browser</string>
|
||||||
<string name="connection_setup_required">%1$s is required</string>
|
<string name="connection_setup_required">%1$s is required</string>
|
||||||
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
|
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
|
||||||
<string name="connection_setup_pending">Waiting for Hermes…</string>
|
<string name="connection_setup_pending">Waiting for AITURK…</string>
|
||||||
<string name="connection_catalog_title">Install %1$s</string>
|
<string name="connection_catalog_title">Install %1$s</string>
|
||||||
<string name="connection_catalog_kind_plugin">Plugin</string>
|
<string name="connection_catalog_kind_plugin">Plugin</string>
|
||||||
<string name="connection_catalog_kind_skill">Skill</string>
|
<string name="connection_catalog_kind_skill">Skill</string>
|
||||||
@@ -1834,7 +1862,7 @@
|
|||||||
<string name="kanban_active_workers">Active workers</string>
|
<string name="kanban_active_workers">Active workers</string>
|
||||||
<string name="kanban_no_active_workers">No active workers</string>
|
<string name="kanban_no_active_workers">No active workers</string>
|
||||||
<string name="custom_headers_title">Custom headers</string>
|
<string name="custom_headers_title">Custom headers</string>
|
||||||
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string>
|
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. AITURK login is still required.</string>
|
||||||
<string name="custom_headers_name">Header name</string>
|
<string name="custom_headers_name">Header name</string>
|
||||||
<string name="custom_headers_value">Header value</string>
|
<string name="custom_headers_value">Header value</string>
|
||||||
<string name="custom_headers_add">Add header</string>
|
<string name="custom_headers_add">Add header</string>
|
||||||
@@ -1858,4 +1886,69 @@
|
|||||||
<string name="mcp_servers_catalog_empty_title">No catalog entries</string>
|
<string name="mcp_servers_catalog_empty_title">No catalog entries</string>
|
||||||
<string name="mcp_servers_catalog_installed">Installed</string>
|
<string name="mcp_servers_catalog_installed">Installed</string>
|
||||||
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
|
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
|
||||||
|
|
||||||
|
<!-- AITURK IDE pairing (QR / code) -->
|
||||||
|
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
|
||||||
|
<string name="aiturk_pair_title">Connect to your computer</string>
|
||||||
|
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. It works from anywhere with internet; the same Wi-Fi is not required. The computer must be on with AITURK IDE running.</string>
|
||||||
|
<string name="aiturk_pair_scan_qr">Scan QR</string>
|
||||||
|
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
|
||||||
|
<string name="aiturk_pair_or">or</string>
|
||||||
|
<string name="aiturk_pair_code_label">8-character code</string>
|
||||||
|
<string name="aiturk_pair_code_placeholder">ABCD-EFGH</string>
|
||||||
|
<string name="aiturk_pair_connect">Connect</string>
|
||||||
|
<string name="aiturk_pair_cancel">Cancel</string>
|
||||||
|
<string name="aiturk_pair_back">Back</string>
|
||||||
|
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
|
||||||
|
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
|
||||||
|
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
|
||||||
|
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s. Continue only if you opened this link yourself from AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
|
||||||
|
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed, so the computer could not be reached over Wi-Fi. Allow the permission in the phone\'s settings and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
|
||||||
|
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
|
||||||
|
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
|
||||||
|
<string name="aiturk_pair_error_code_invalid">The code has 8 characters. Type it exactly as shown in AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_error_code_not_found">Code not found or expired. Check it, or open Phone connection in AITURK IDE again for a new code.</string>
|
||||||
|
<string name="aiturk_pair_error_rate_limited">Too many attempts. Wait a minute and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_gateway">The code service could not be reached. Check your internet connection, or scan the QR code instead.</string>
|
||||||
|
<string name="aiturk_pair_error_pc_unreachable">Your computer could not be reached. Make sure the phone and the computer are on the same Wi-Fi and Phone connection is open in AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_error_wrong_code">Wrong code. Check the code in AITURK IDE and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_pairing_closed">Pairing is closed, or the code expired or was already used. Open Phone connection in AITURK IDE again.</string>
|
||||||
|
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
|
||||||
|
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
|
||||||
|
<string name="aiturk_pair_error_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_host_timeout">The computer did not answer. Check that AITURK IDE is running and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_relay_busy">Too many connections. Try again in a moment.</string>
|
||||||
|
<string name="aiturk_pair_error_relay_unknown">This pairing is no longer valid. Pair again from AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_error_tls_pin">The computer\'s identity could not be verified. Pair again from AITURK IDE.</string>
|
||||||
|
<string name="aiturk_pair_security_title">Connected</string>
|
||||||
|
<string name="aiturk_pair_security_message">Security code: %1$s\n\nIt should match the security code shown under the QR code in AITURK IDE. The connection is end-to-end encrypted.</string>
|
||||||
|
<string name="aiturk_pair_security_ok">OK</string>
|
||||||
|
<string name="aiturk_pair_error_no_internet">The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again.</string>
|
||||||
|
<string name="aiturk_pair_error_relay">Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil.</string>
|
||||||
|
<string name="aiturk_pair_error_code">Diagnostic code: %1$s</string>
|
||||||
|
<string name="aiturk_status_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off</string>
|
||||||
|
<string name="aiturk_feature_unavailable">This feature is not available in this version of AITURK IDE.</string>
|
||||||
|
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
|
||||||
|
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
|
||||||
|
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
||||||
|
<string name="cron_repeat_label">Repeat</string>
|
||||||
|
<string name="cron_repeat_hourly">Every hour</string>
|
||||||
|
<string name="cron_repeat_minutely">Every minute</string>
|
||||||
|
<string name="cron_repeat_yearly">Every year</string>
|
||||||
|
<string name="cron_repeat_seconds_unavailable">Every second · unsupported by server</string>
|
||||||
|
<string name="cron_repeat_month">Month</string>
|
||||||
|
<string name="cron_repeat_minute">At which minute of the hour?</string>
|
||||||
|
<string name="cron_summary_daily">Every day at %1$s</string>
|
||||||
|
<string name="cron_summary_hourly">Every hour at minute %1$02d</string>
|
||||||
|
<string name="cron_summary_weekly">%1$s · %2$s</string>
|
||||||
|
<string name="cron_summary_monthly">Day %1$d of each month · %2$s</string>
|
||||||
|
<string name="cron_summary_yearly">Every year on %1$d/%2$d · %3$s</string>
|
||||||
|
<string name="cron_summary_interval">Every %1$d %2$s</string>
|
||||||
|
<string name="cron_repeat_calendar_note">Skips months or years without the selected date.</string>
|
||||||
|
<string name="cron_repeat_count">Interval</string>
|
||||||
|
<string name="cron_repeat_unit">Unit</string>
|
||||||
|
<string name="cron_schedule_timezone_note">Times use the connected AITURK server’s time zone. Its zone cannot be read here and may differ from your phone’s.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
Registers Hermes Mobile as a candidate for the Android digital-assistant
|
Registers AITURK Mobil as a candidate for the Android digital-assistant
|
||||||
role (Settings > Apps > Default apps > Digital assistant app).
|
role (Settings > Apps > Default apps > Digital assistant app).
|
||||||
|
|
||||||
Both sessionService and recognitionService are REQUIRED by the framework
|
Both sessionService and recognitionService are REQUIRED by the framework
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user