Compare commits
18
Commits
63105b7843
...
6f4309c746
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f4309c746 | ||
|
|
c2b08ff73e | ||
|
|
8e48a0fa1e | ||
|
|
34758975b7 | ||
|
|
e3d0a97921 | ||
|
|
5f548f5a17 | ||
|
|
944390bec1 | ||
|
|
62fcf07e1b | ||
|
|
e7927f6631 | ||
|
|
1cef9e7ebb | ||
|
|
82cf8df238 | ||
|
|
ba029e83dc | ||
|
|
728daa9acd | ||
|
|
ed915d0816 | ||
|
|
e4004decb4 | ||
|
|
7386c3035b | ||
|
|
15a0abc7a7 | ||
|
|
ed26f54855 |
@@ -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.
|
||||
@@ -18,7 +18,7 @@ android {
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
defaultConfig {
|
||||
applicationId = "com.m57.hermescontrol"
|
||||
applicationId = "online.turkservis.aiturk.mobil"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||
@@ -201,6 +201,9 @@ dependencies {
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
|
||||
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
|
||||
implementation(libs.zxing.android.embedded)
|
||||
|
||||
// Encrypted storage
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore)
|
||||
@@ -218,6 +221,7 @@ dependencies {
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.okhttp.tls)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.turbine)
|
||||
|
||||
Vendored
+5
@@ -57,3 +57,8 @@
|
||||
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
||||
-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);
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.CompositionLocalProvider
|
||||
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.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,6 +60,7 @@ class ComposerInteractionTest {
|
||||
private var fileTaps = 0
|
||||
|
||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun setComposer(
|
||||
reasoningLevel: String? = "medium",
|
||||
model: String = "openai/gpt-5.5",
|
||||
@@ -67,18 +70,17 @@ class ComposerInteractionTest {
|
||||
showModelProvider: Boolean = false,
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
var listening by remember { mutableStateOf(false) }
|
||||
var currentModel by remember { mutableStateOf(model) }
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||
val composer: @Composable () -> Unit = {
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = {
|
||||
sends++
|
||||
input = TextFieldValue("")
|
||||
input.clearText()
|
||||
},
|
||||
onMicTap = {
|
||||
micTaps++
|
||||
|
||||
+7
-10
@@ -1,9 +1,8 @@
|
||||
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.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
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.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
class ComposerReadinessTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
@@ -26,10 +25,9 @@ class ComposerReadinessTest {
|
||||
val ready = mutableStateOf(false)
|
||||
var sends = 0
|
||||
compose.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = { sends++ },
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
@@ -64,10 +62,9 @@ class ComposerReadinessTest {
|
||||
@Test
|
||||
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||
compose.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
|
||||
+120
-2
@@ -1,27 +1,42 @@
|
||||
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.ui.test.assertIsDisplayed
|
||||
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.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
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.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PendingSendRecoveryTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val uncertain =
|
||||
PendingSend(
|
||||
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
|
||||
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
|
||||
fun uncertainRetryRequiresExplicitConfirmation() {
|
||||
var retried: String? = null
|
||||
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
|
||||
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
|
||||
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
||||
compose.setContent {
|
||||
|
||||
+7
-3
@@ -3,9 +3,11 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
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.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
@@ -52,6 +54,7 @@ import java.io.File
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
class VoiceNoteGestureTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
@@ -88,8 +91,7 @@ class VoiceNoteGestureTest {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue(draft),
|
||||
onInputChange = {},
|
||||
inputState = rememberTextFieldState(initialText = draft),
|
||||
onSend = { sendCount++ },
|
||||
onMicTap = { micTaps++ },
|
||||
isListening = false,
|
||||
@@ -275,6 +277,8 @@ class VoiceNoteGestureTest {
|
||||
assertEquals("release must submit the note", 1, holdEnds)
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<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>
|
||||
<intent>
|
||||
@@ -38,8 +41,23 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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
|
||||
android:name=".ui.aiturkpair.AiturkQrCaptureActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
|
||||
<service
|
||||
android:name=".assistant.AssistantService"
|
||||
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.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -28,6 +29,10 @@ class HermesControlApp :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val startupTimeMs = System.currentTimeMillis()
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
cleanStalePastedImages(cacheDir, startupTimeMs)
|
||||
}
|
||||
AuthManager.init(this)
|
||||
NetworkMonitor.init(this)
|
||||
SessionListCacheStore.init(this)
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
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.LoadingState
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
@@ -62,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
@@ -129,6 +131,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -52,6 +52,7 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
|
||||
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||
@@ -66,6 +67,7 @@ import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
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.landing.LandingScreen as LandingScreenContent
|
||||
|
||||
@@ -79,6 +81,9 @@ private fun appEntryProvider(
|
||||
onAuthLogin = {
|
||||
NavigationController.navigateTo(AuthLoginScreen)
|
||||
},
|
||||
onAiturkPair = {
|
||||
NavigationController.navigateTo(AiturkPairScreen)
|
||||
},
|
||||
)
|
||||
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
|
||||
DisableDrawerGestures()
|
||||
@@ -96,6 +101,18 @@ private fun appEntryProvider(
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
entry<AiturkPairScreen> {
|
||||
AiturkPairScreenContent(
|
||||
onConnected = {
|
||||
NavigationController.resetTo(ChatScreen)
|
||||
},
|
||||
onBack = {
|
||||
NavigationController.goBack()
|
||||
},
|
||||
)
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||
addEntryProvider(clazz = screen.key::class) {
|
||||
screen.content(sessionId, openDrawer)
|
||||
@@ -193,6 +210,7 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
|
||||
val backStack = remember(startScreen) { NavBackStack(startScreen) }
|
||||
NavigationController.backStack = backStack
|
||||
AiturkPairDeepLinkEffect()
|
||||
|
||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
|
||||
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object AuthLoginScreen : NavKey
|
||||
|
||||
@Serializable data object AiturkPairScreen : NavKey
|
||||
|
||||
@Serializable data object ChatScreen : NavKey
|
||||
|
||||
@Serializable data object SettingsScreen : NavKey
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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) {
|
||||
val baseUrl = device.endpoint.baseUrl
|
||||
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
|
||||
|
||||
// Trust first: every later request to this host:port is checked against the pin.
|
||||
AiturkLanTrust.pin(device.endpoint.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,345 @@
|
||||
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 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)
|
||||
}.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,
|
||||
): OkHttpClient {
|
||||
val lookup: (String) -> String? = { fingerprint }
|
||||
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
|
||||
return base
|
||||
.newBuilder()
|
||||
.sslSocketFactory(socketFactoryFor(manager), manager)
|
||||
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
|
||||
.build()
|
||||
}
|
||||
|
||||
fun pin(
|
||||
hostPort: String,
|
||||
fingerprint: String,
|
||||
) {
|
||||
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
|
||||
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 socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
|
||||
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,330 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.data.remote.await
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
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.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.util.concurrent.TimeUnit
|
||||
|
||||
/** 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 candidate address answered `hello` with the pinned certificate in time. */
|
||||
PC_UNREACHABLE,
|
||||
|
||||
/** `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,
|
||||
}
|
||||
|
||||
data class AiturkPairedDevice(
|
||||
val endpoint: AiturkEndpoint,
|
||||
val fingerprint: String,
|
||||
val token: String,
|
||||
val deviceId: String,
|
||||
val pcName: String,
|
||||
val version: String,
|
||||
)
|
||||
|
||||
sealed interface AiturkPairResult<out T> {
|
||||
data class Success<T>(
|
||||
val value: T,
|
||||
) : AiturkPairResult<T>
|
||||
|
||||
data class Failure(
|
||||
val error: AiturkPairError,
|
||||
) : AiturkPairResult<Nothing>
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone side of the AITURK IDE pairing handshake.
|
||||
*
|
||||
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
|
||||
*/
|
||||
class AiturkPairingClient(
|
||||
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
|
||||
private val baseClient: OkHttpClient = defaultBaseClient,
|
||||
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
|
||||
) {
|
||||
/** 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(gatewayBaseUrl.newBuilder().addPathSegments(RESOLVE_PATH).build())
|
||||
.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)
|
||||
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED)
|
||||
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `hello` to every candidate in parallel (pinned to the offer's fingerprint),
|
||||
* then exactly ONE `claim` to the first that answered.
|
||||
*/
|
||||
suspend fun pair(
|
||||
offer: AiturkPairingOffer,
|
||||
normalizedCode: String,
|
||||
deviceName: String,
|
||||
): AiturkPairResult<AiturkPairedDevice> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint)
|
||||
val reachable =
|
||||
firstReachable(pinned, offer.endpoints)
|
||||
?: return@withContext AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
|
||||
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@withContext AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED)
|
||||
}
|
||||
claim(pinned, reachable.endpoint, offer.fingerprint, normalizedCode, deviceName)
|
||||
}
|
||||
|
||||
private data class Reachable(
|
||||
val endpoint: AiturkEndpoint,
|
||||
val pairingOpen: Boolean,
|
||||
)
|
||||
|
||||
private suspend fun firstReachable(
|
||||
pinned: OkHttpClient,
|
||||
endpoints: List<AiturkEndpoint>,
|
||||
): Reachable? {
|
||||
val helloClient =
|
||||
pinned
|
||||
.newBuilder()
|
||||
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
|
||||
return withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
|
||||
channelFlow {
|
||||
endpoints.forEach { endpoint ->
|
||||
launch { hello(helloClient, endpoint)?.let { send(it) } }
|
||||
}
|
||||
}.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hello(
|
||||
client: OkHttpClient,
|
||||
endpoint: AiturkEndpoint,
|
||||
): Reachable? =
|
||||
try {
|
||||
val request = Request.Builder().url(lanUrl(endpoint, HELLO_PATH)).get().build()
|
||||
client.newCall(request).await().use { response ->
|
||||
if (response.code != 200) return null
|
||||
val json = readJson(response) ?: return null
|
||||
if (json.string("app") != IDE_APP_ID) return null
|
||||
Reachable(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun claim(
|
||||
pinned: OkHttpClient,
|
||||
endpoint: AiturkEndpoint,
|
||||
fingerprint: String,
|
||||
normalizedCode: String,
|
||||
deviceName: String,
|
||||
): AiturkPairResult<AiturkPairedDevice> {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("code", normalizedCode)
|
||||
put("device_name", sanitizeDeviceName(deviceName))
|
||||
}.toString().toRequestBody(JSON)
|
||||
val request = Request.Builder().url(lanUrl(endpoint, 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 client =
|
||||
pinned
|
||||
.newBuilder()
|
||||
.retryOnConnectionFailure(false)
|
||||
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
return try {
|
||||
client.newCall(request).await().use { response ->
|
||||
val json = readJson(response)
|
||||
if (response.code == 200) {
|
||||
parseClaim(json, endpoint, fingerprint)
|
||||
} else {
|
||||
AiturkPairResult.Failure(claimError(response.code, json?.string("error")))
|
||||
}
|
||||
}
|
||||
} catch (_: java.io.IOException) {
|
||||
AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClaim(
|
||||
json: JsonObject?,
|
||||
endpoint: AiturkEndpoint,
|
||||
fingerprint: String,
|
||||
): AiturkPairResult<AiturkPairedDevice> {
|
||||
val token = json?.string("token")
|
||||
if (token == null || !TOKEN_PATTERN.matches(token)) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
|
||||
}
|
||||
return AiturkPairResult.Success(
|
||||
AiturkPairedDevice(
|
||||
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 fingerprint = json?.string("fp")
|
||||
val urls = (json?.get("urls") as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
if (fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint) || urls == null) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||
}
|
||||
// The rendezvous record is untrusted input: only private IPv4 targets, 1..4.
|
||||
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
|
||||
if (endpoints.isEmpty() || endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||
}
|
||||
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint))
|
||||
}
|
||||
|
||||
private fun lanUrl(
|
||||
endpoint: AiturkEndpoint,
|
||||
path: String,
|
||||
): HttpUrl =
|
||||
HttpUrl
|
||||
.Builder()
|
||||
.scheme("https")
|
||||
.host(endpoint.host)
|
||||
.port(endpoint.port)
|
||||
.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 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}$")
|
||||
|
||||
// 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" }
|
||||
|
||||
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,196 @@
|
||||
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&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
|
||||
*/
|
||||
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. */
|
||||
data class AiturkPairingOffer(
|
||||
val endpoints: List<AiturkEndpoint>,
|
||||
val fingerprint: String,
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
|
||||
BAD_ADDRESS_COUNT,
|
||||
|
||||
/** `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)
|
||||
}
|
||||
|
||||
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
|
||||
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
|
||||
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),
|
||||
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")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
|
||||
|
||||
/**
|
||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
messageProvenance =
|
||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||
?: MessageProvenance.UNKNOWN,
|
||||
)
|
||||
).normalizedGatewayNotice()
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
ChatMessageEntity(
|
||||
|
||||
@@ -76,7 +76,7 @@ data class MessagingPlatformUpdateResponse(
|
||||
|
||||
@Serializable
|
||||
data class TelegramOnboardingStartRequest(
|
||||
@SerialName("bot_name") val botName: String = "Hermes Agent",
|
||||
@SerialName("bot_name") val botName: String = "AITURK",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
@@ -100,20 +101,10 @@ data class SessionMessage(
|
||||
}
|
||||
|
||||
val contentText: String
|
||||
get() =
|
||||
when (content) {
|
||||
is JsonPrimitive -> content.content
|
||||
null -> ""
|
||||
else -> content.toString()
|
||||
}
|
||||
get() = content?.transcriptText().orEmpty()
|
||||
|
||||
val displayContentText: String?
|
||||
get() =
|
||||
when (display_content) {
|
||||
is JsonPrimitive -> display_content.content
|
||||
null -> null
|
||||
else -> display_content.toString()
|
||||
}
|
||||
get() = display_content?.transcriptText()
|
||||
|
||||
val reasoningText: String
|
||||
get() =
|
||||
@@ -160,4 +151,25 @@ data class SessionMessage(
|
||||
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+")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
|
||||
@Synchronized
|
||||
fun refresh(): String? =
|
||||
try {
|
||||
if (isAiturkPaired()) return null
|
||||
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||
AuthManager.setToken(token)
|
||||
token
|
||||
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
|
||||
suspend fun refreshAsync(): String? =
|
||||
refreshMutex.withLock {
|
||||
try {
|
||||
if (isAiturkPaired()) return@withLock null
|
||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
||||
AuthManager.setToken(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(
|
||||
baseUrl: String,
|
||||
client: OkHttpClient,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -39,6 +40,9 @@ object OkHttpProvider {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.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()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ object UpdateNoticeManager {
|
||||
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
|
||||
* 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
|
||||
|
||||
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
||||
|
||||
@@ -279,7 +279,7 @@ object EventParser {
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
WsEvent.StatusUpdate(status, payload, sessionId)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
|
||||
@@ -205,6 +205,7 @@ sealed class WsEvent {
|
||||
data class StatusUpdate(
|
||||
val status: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class SessionUpdated(
|
||||
|
||||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
|
||||
@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
|
||||
data class SessionResumeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("source") val source: String? = null,
|
||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
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.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
@@ -61,7 +62,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
NotificationCompat
|
||||
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("Hermes")
|
||||
.setContentTitle("AITURK")
|
||||
.setContentText("Replied")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = storedSessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
@@ -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,291 @@
|
||||
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.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.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)
|
||||
}
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) onConnected()
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.aiturk_pair_back))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,259 @@
|
||||
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.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.AiturkPairingOffer
|
||||
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,
|
||||
/** Address of a deep-link pairing waiting for the user's confirmation. */
|
||||
val confirmAddress: String? = null,
|
||||
val lanPermissionNeeded: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
) {
|
||||
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: Request? = null
|
||||
private var confirmPayload: AiturkPairingPayload? = null
|
||||
|
||||
fun onCodeChange(raw: String) {
|
||||
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null) }
|
||||
}
|
||||
|
||||
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
|
||||
val address =
|
||||
result.payload.offer.endpoints
|
||||
.first()
|
||||
.hostPort
|
||||
_uiState.update { it.copy(confirmAddress = address, errorRes = null) }
|
||||
}
|
||||
|
||||
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 request = pending
|
||||
pending = null
|
||||
if (!granted) {
|
||||
showError(R.string.aiturk_pair_error_lan_permission)
|
||||
} else if (request != null) {
|
||||
run(request)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
confirmAddress = null,
|
||||
lanPermissionNeeded = false,
|
||||
connectionSuccess = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(
|
||||
request: Request,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
if (!lanPermissionGranted) {
|
||||
pending = request
|
||||
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null) }
|
||||
return
|
||||
}
|
||||
run(request)
|
||||
}
|
||||
|
||||
private fun run(request: Request) {
|
||||
if (_uiState.value.busy) return
|
||||
val firstStep = if (request is Request.Code) AiturkPairStep.RESOLVING else AiturkPairStep.CONNECTING
|
||||
_uiState.update { it.copy(step = firstStep, errorRes = null) }
|
||||
viewModelScope.launch {
|
||||
val outcome =
|
||||
try {
|
||||
pair(request)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
|
||||
}
|
||||
when (outcome) {
|
||||
is AiturkPairResult.Success -> {
|
||||
val saved = runCatching { register(outcome.value) }.isSuccess
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AiturkPairStep.IDLE,
|
||||
code = "",
|
||||
connectionSuccess = saved,
|
||||
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
_uiState.update { it.copy(step = AiturkPairStep.IDLE, errorRes = pairErrorRes(outcome.error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pair(request: Request): AiturkPairResult<AiturkPairedDevice> {
|
||||
val offer: AiturkPairingOffer
|
||||
val code: String
|
||||
when (request) {
|
||||
is Request.Payload -> {
|
||||
offer = request.payload.offer
|
||||
code = request.payload.code
|
||||
}
|
||||
|
||||
is Request.Code -> {
|
||||
code = request.code
|
||||
when (val resolved = client.resolve(code)) {
|
||||
is AiturkPairResult.Success -> offer = resolved.value
|
||||
is AiturkPairResult.Failure -> return resolved
|
||||
}
|
||||
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING) }
|
||||
}
|
||||
}
|
||||
return client.pair(offer, code, deviceName)
|
||||
}
|
||||
|
||||
private fun showError(
|
||||
@StringRes res: Int,
|
||||
) {
|
||||
_uiState.update { it.copy(errorRes = res) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@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_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.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.ws.HermesWsClient
|
||||
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.ProfilesListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
@@ -564,7 +565,7 @@ class GroupChatViewModel(
|
||||
method = RpcMethods.SESSION_CREATE,
|
||||
params =
|
||||
SessionCreateParams(
|
||||
source = "desktop",
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
profile = bot.name,
|
||||
title = title,
|
||||
hidden = true,
|
||||
|
||||
@@ -322,7 +322,7 @@ class ChannelsViewModel :
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.startTelegramOnboarding(
|
||||
TelegramOnboardingStartRequest(botName = "Hermes Agent"),
|
||||
TelegramOnboardingStartRequest(botName = "AITURK"),
|
||||
)
|
||||
}
|
||||
_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.unit.dp
|
||||
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
|
||||
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[message.id] = toolCount
|
||||
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
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.shape.CircleShape
|
||||
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.filled.Add
|
||||
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.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -118,9 +119,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
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.rememberChatScrollController
|
||||
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.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -338,20 +342,18 @@ fun ChatScreen(
|
||||
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||
}
|
||||
}
|
||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(""))
|
||||
}
|
||||
val inputState = rememberTextFieldState()
|
||||
|
||||
LaunchedEffect(state.pendingPrefillText) {
|
||||
val prefill = state.pendingPrefillText
|
||||
if (prefill != null) {
|
||||
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
|
||||
inputState.replaceComposerDraft(prefill)
|
||||
viewModel.consumePendingPrefill()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.composerTextToRestore) {
|
||||
state.composerTextToRestore?.let { text ->
|
||||
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
||||
inputState.restoreRejectedComposerDraft(text)
|
||||
viewModel.consumeComposerTextRestore()
|
||||
}
|
||||
}
|
||||
@@ -364,6 +366,10 @@ fun ChatScreen(
|
||||
}
|
||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val imagePaste =
|
||||
rememberChatImagePaste(viewModel) { message ->
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(message) }
|
||||
}
|
||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
@@ -411,8 +417,7 @@ fun ChatScreen(
|
||||
|
||||
val mediaLaunchers =
|
||||
rememberChatMediaLaunchers(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputFieldValueChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
onAddAttachment = { uri, name, mimeType, size ->
|
||||
viewModel.addAttachment(uri, name, mimeType, size)
|
||||
},
|
||||
@@ -785,7 +790,7 @@ fun ChatScreen(
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||
},
|
||||
onShare = { details ->
|
||||
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||
val report = "AITURK Mobil ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
@@ -859,15 +864,18 @@ fun ChatScreen(
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendAgain = viewModel::sendQueuedNow,
|
||||
onDiscard = viewModel::discardPendingSend,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
receiveContentListener = imagePaste,
|
||||
isReceivingContent = imagePaste.isReceiving,
|
||||
onSend = {
|
||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
|
||||
inputState.clearText()
|
||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.isTimelineMarker
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
||||
@@ -100,7 +101,7 @@ class ChatSearchController {
|
||||
}
|
||||
|
||||
MessageRole.TOOL -> {
|
||||
scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
}
|
||||
|
||||
MessageRole.SYSTEM -> {
|
||||
|
||||
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
|
||||
.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. */
|
||||
class ChatSendStore(
|
||||
private val prefs: SharedPreferences? = null,
|
||||
@@ -158,9 +177,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
|
||||
val recoveryIds =
|
||||
pending
|
||||
.filter {
|
||||
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
|
||||
it.state == PendingSendState.REJECTED
|
||||
it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
|
||||
}.mapTo(mutableSetOf()) { it.id }
|
||||
if (recoveryIds.isEmpty()) return messages
|
||||
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
|
||||
}
|
||||
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)
|
||||
if (items.isNotEmpty()) {
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
|
||||
completionId = completionId,
|
||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||
reactions = msg.reactions,
|
||||
),
|
||||
).normalizedGatewayNotice(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
|
||||
match.id to
|
||||
rich.copy(
|
||||
id = match.id,
|
||||
attachments =
|
||||
if (rich.role == MessageRole.USER) {
|
||||
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
|
||||
} else {
|
||||
rich.attachments
|
||||
},
|
||||
content = preservedContent ?: rich.content,
|
||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
|
||||
when {
|
||||
match?.role == MessageRole.USER -> {
|
||||
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 },
|
||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||
reactions = message.reactions.ifEmpty { match.reactions },
|
||||
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
|
||||
.mapNotNull { index ->
|
||||
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||
}.toMap()
|
||||
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
||||
.reconcileReasoningRows()
|
||||
return dedupeCachedMessages(
|
||||
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. */
|
||||
private fun List<ChatMessage>.inTranscriptOrder(
|
||||
previous: List<ChatMessage>,
|
||||
resolvedOrders: Map<String, Long>,
|
||||
observedSuccessorAnchors: List<ChatMessage>? = null,
|
||||
): List<ChatMessage> {
|
||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||
// 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
|
||||
val localAnchors = 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 ->
|
||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||
if (order != null) {
|
||||
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else if (message.isPermanentlyLocal()) {
|
||||
localAnchors[message.id] =
|
||||
if (hasPendingPredecessor) {
|
||||
Long.MAX_VALUE
|
||||
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
|
||||
} else {
|
||||
precedingCanonical?.takeIf { it >= 0L }
|
||||
?: if (message.role == MessageRole.USER) {
|
||||
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else {
|
||||
hasPendingPredecessor = true
|
||||
pendingLocalOrder = message.localOrder
|
||||
nextCanonicalOrder[message.id]?.let { successor ->
|
||||
localAnchors[message.id] = successor - 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||
|
||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
|
||||
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||
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.ImageAttachBytesParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||
@@ -165,7 +166,7 @@ data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
val sessions: List<SessionUi> = emptyList(),
|
||||
val chatTitle: String = "Hermes",
|
||||
val chatTitle: String = "AITURK",
|
||||
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||
val isAgentTyping: Boolean = false,
|
||||
val isMainTurnBusy: Boolean = false,
|
||||
@@ -543,6 +544,7 @@ class ChatViewModel(
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
|
||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||
private val pendingSendReservationLock = Any()
|
||||
@@ -918,7 +920,7 @@ class ChatViewModel(
|
||||
.collect { _ ->
|
||||
pendingGoneSessionNotice = 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):
|
||||
@@ -930,7 +932,7 @@ class ChatViewModel(
|
||||
.collect { _ ->
|
||||
pendingGoneSessionNotice = 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
|
||||
@@ -1005,7 +1007,7 @@ class ChatViewModel(
|
||||
// even when the route looks already active).
|
||||
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
|
||||
cancelResumeRetry()
|
||||
addSystemMessage("Connected to Hermes")
|
||||
addSystemMessage("Connected to AITURK")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
modelSwitchDelegate.preloadModelOptions()
|
||||
@@ -1510,6 +1512,10 @@ class ChatViewModel(
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
|
||||
is ReducerEffect.DeleteLocalMessage -> {
|
||||
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
|
||||
}
|
||||
|
||||
is ReducerEffect.AttachHostMedia -> {
|
||||
// Issue #724: turn host-path MEDIA: directives into real
|
||||
// attachments (images inline, every other file tappable)
|
||||
@@ -1702,6 +1708,17 @@ class ChatViewModel(
|
||||
}
|
||||
when {
|
||||
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.
|
||||
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
||||
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
||||
@@ -1786,12 +1803,14 @@ class ChatViewModel(
|
||||
isSessionReady = true,
|
||||
isLoading = false,
|
||||
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
|
||||
chatTitle = "Hermes",
|
||||
chatTitle = "AITURK",
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
}
|
||||
publishPendingSends()
|
||||
@@ -1853,7 +1872,7 @@ class ChatViewModel(
|
||||
val generation =
|
||||
resetSessionState(
|
||||
sessionId = storageId,
|
||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "Hermes",
|
||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "AITURK",
|
||||
isLoading = false,
|
||||
)
|
||||
runtimeSessionId = runtimeId
|
||||
@@ -2631,12 +2650,14 @@ class ChatViewModel(
|
||||
state: PendingSendState,
|
||||
) {
|
||||
sendStore.update(messageId) { it.copy(state = state) }
|
||||
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
|
||||
publishPendingSends()
|
||||
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
||||
if (state != PendingSendState.SENDING) drainPendingQueue()
|
||||
}
|
||||
|
||||
private fun removePendingSend(messageId: String) {
|
||||
acceptedTurnEpochById.remove(messageId)
|
||||
sendStore.remove(messageId)
|
||||
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
||||
publishPendingSends()
|
||||
@@ -3662,6 +3683,34 @@ class ChatViewModel(
|
||||
|
||||
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 openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||
@@ -4113,44 +4162,46 @@ class ChatViewModel(
|
||||
val arg = parts.getOrElse(1) { "" }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
// Primary path: command.dispatch handles quick/plugin/bundle/
|
||||
// skill commands + a few hardcoded ones. It returns a hard 4018
|
||||
// "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.
|
||||
// Desktop parity: slash.exec owns generic commands. Handle errors here,
|
||||
// not again through the shared RpcError banner (/usage regression).
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||
RpcMethods.SLASH_EXEC,
|
||||
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
handleDispatchResult(result.toAny())
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
val msg = e.message.orEmpty()
|
||||
// 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}")
|
||||
}
|
||||
val map = result.toAny() as? Map<*, *>
|
||||
if (map?.get("type") is String) {
|
||||
handleDispatchResult(map)
|
||||
} else {
|
||||
// Legit error from command.dispatch (busy, no history, etc.)
|
||||
addAssistantMessage("/$name: ${e.message}")
|
||||
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
|
||||
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 +4567,11 @@ class ChatViewModel(
|
||||
fun createNewSession(setLoading: Boolean = true) {
|
||||
// A fresh create has no persisted row until the first prompt.
|
||||
sessionHasServerPresence = false
|
||||
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading)
|
||||
val generation = resetSessionState(sessionId = null, title = "AITURK", isLoading = setLoading)
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
RpcMethods.SESSION_CREATE,
|
||||
SessionCreateParams(source = "desktop"),
|
||||
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||
)
|
||||
}
|
||||
@@ -4904,7 +4955,7 @@ class ChatViewModel(
|
||||
val title =
|
||||
_uiState.value.sessions
|
||||
.find { it.id == sessionId }
|
||||
?.title ?: "Hermes"
|
||||
?.title ?: "AITURK"
|
||||
val generation = resetSessionState(sessionId, title, isLoading = true)
|
||||
AuthManager.setLastOpenedSessionId(sessionId)
|
||||
viewModelScope.launch {
|
||||
@@ -5056,7 +5107,22 @@ class ChatViewModel(
|
||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||
val valid = { isCurrent() && cacheCursor == before }
|
||||
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
|
||||
cacheCursor = page.cursor
|
||||
cacheHasOlder = page.hasOlder
|
||||
@@ -5202,7 +5268,11 @@ class ChatViewModel(
|
||||
val scope = sendScope()
|
||||
val scopedPending =
|
||||
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()
|
||||
drainPendingQueue()
|
||||
}
|
||||
@@ -5310,6 +5380,9 @@ class ChatViewModel(
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
// #1433: compaction state belongs to the session being left.
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
pendingAttachments = emptyList(),
|
||||
composerTextToRestore = null,
|
||||
reactionKind = null,
|
||||
@@ -5338,6 +5411,7 @@ class ChatViewModel(
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = sessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
@@ -5648,6 +5722,17 @@ class ChatViewModel(
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
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 =
|
||||
if (useLatest) {
|
||||
0
|
||||
@@ -5682,6 +5767,14 @@ class ChatViewModel(
|
||||
)
|
||||
} ?: return@launch
|
||||
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.
|
||||
// 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.
|
||||
|
||||
@@ -136,6 +136,7 @@ object ChatWsEventReducer {
|
||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||
is WsEvent.StatusUpdate -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||
@@ -457,6 +458,7 @@ object ChatWsEventReducer {
|
||||
): ReducerResult {
|
||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||
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 usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||
@@ -1052,36 +1054,107 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.StatusUpdate,
|
||||
): ReducerResult {
|
||||
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) {
|
||||
// In-progress compaction is live state: the tail indicator shows only while it runs.
|
||||
"compressing", "compacting" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = text ?: "⏳ Compressing context...",
|
||||
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
|
||||
"compacted" -> {
|
||||
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
|
||||
}
|
||||
|
||||
"ready" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = text,
|
||||
),
|
||||
state = state.copy(isCompressing = false, compressionStatus = null),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// Durable notices the desktop/TUI also paint into the transcript.
|
||||
"goal", "loop", "warn", "fallback" -> {
|
||||
appendGatewayNotice(state, streamingState, text)
|
||||
}
|
||||
|
||||
else -> {
|
||||
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. */
|
||||
private fun onMessageReaction(
|
||||
state: ChatUiState,
|
||||
@@ -1293,6 +1366,11 @@ sealed class 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
|
||||
* 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ object MediaImageStore {
|
||||
val safe = sanitizeName(displayName)
|
||||
val (collection, relativePath) =
|
||||
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 {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ sealed class SlashResult {
|
||||
/** Create a new session (client-side immediate). */
|
||||
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()
|
||||
|
||||
/** 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.filled.Check
|
||||
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.IconButton
|
||||
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.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -81,6 +84,7 @@ fun UserBubble(
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showUserMessageTokens: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
pendingSendState: PendingSendState? = null,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.80f
|
||||
@@ -99,18 +103,36 @@ fun UserBubble(
|
||||
var copied by rememberCopyFeedback()
|
||||
|
||||
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 =
|
||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
buildHighlightedString(
|
||||
message.content,
|
||||
displayContent,
|
||||
searchQuery,
|
||||
isCurrentMatch,
|
||||
statusColors,
|
||||
)
|
||||
} else {
|
||||
AnnotatedString(message.content)
|
||||
AnnotatedString(displayContent)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -152,7 +174,7 @@ fun UserBubble(
|
||||
color = Color.Transparent,
|
||||
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
|
||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
@@ -218,6 +240,11 @@ fun UserBubble(
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
DeliveryChecks(
|
||||
state = deliveryState,
|
||||
tint = userBubbleTextColor,
|
||||
messageId = message.id,
|
||||
)
|
||||
if (messageStatsEnabled && showUserMessageTokens &&
|
||||
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.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
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.Box
|
||||
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.shape.RoundedCornerShape
|
||||
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.automirrored.filled.InsertDriveFile
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
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.testTag
|
||||
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.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
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.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onSend: () -> Unit,
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
@@ -127,15 +135,18 @@ fun ChatInputBar(
|
||||
isRecordingVoice: Boolean = false,
|
||||
amplitudeProvider: () -> Float = { 0f },
|
||||
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
|
||||
// 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
|
||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||
val canSend =
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
!isReceivingContent && pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
@@ -232,6 +243,16 @@ fun ChatInputBar(
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
) {
|
||||
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
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
@@ -245,13 +266,13 @@ fun ChatInputBar(
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
|
||||
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(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
@@ -280,7 +301,7 @@ fun ChatInputBar(
|
||||
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)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
@@ -356,9 +377,9 @@ fun ChatInputBar(
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
remember(inputText, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(
|
||||
inputFieldValue.text,
|
||||
inputText,
|
||||
fallback = ambientLayoutDirection,
|
||||
)
|
||||
}
|
||||
@@ -381,8 +402,7 @@ fun ChatInputBar(
|
||||
.heightIn(min = 42.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
||||
state = inputState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -392,18 +412,28 @@ fun ChatInputBar(
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
inputFocused = focusState.isFocused
|
||||
}.testTag("chat_input"),
|
||||
}.testTag("chat_input")
|
||||
.then(
|
||||
if (receiveContentListener != null) {
|
||||
Modifier.contentReceiver(receiveContentListener)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
enabled = isConnected,
|
||||
inputTransformation =
|
||||
InputTransformation {
|
||||
if (isRecordingVoice) revertAllChanges()
|
||||
},
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
decorator = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
@@ -416,7 +446,7 @@ fun ChatInputBar(
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
||||
if (inputText.isEmpty() && !isRecordingVoice) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
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")
|
||||
}
|
||||
+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 androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.R
|
||||
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.VoiceNoteRecorder
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
|
||||
|
||||
@Composable
|
||||
fun rememberChatMediaLaunchers(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||
onAddAttachments: (List<Attachment>) -> Unit,
|
||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||
val currentInputState by rememberUpdatedState(inputState)
|
||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val currentText = currentInputFieldValue.text
|
||||
val merged =
|
||||
if (currentText.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"$currentText $spokenText"
|
||||
}
|
||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
||||
currentInputState.appendSpeechComposerDraft(spokenText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.needsRecovery
|
||||
|
||||
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
|
||||
mainTurnBusy: Boolean,
|
||||
onSendAgain: (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 retryId 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"),
|
||||
) {
|
||||
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
|
||||
Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
|
||||
}
|
||||
if (open) {
|
||||
ModalBottomSheet(onDismissRequest = { open = false }) {
|
||||
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
||||
items(sends, key = { it.id }) { send ->
|
||||
items(recoverySends, key = { it.id }) { send ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (send.attachments.isNotEmpty()) {
|
||||
Text(stringResource(R.string.chat_pending_attachments))
|
||||
send.attachments.forEach { attachment ->
|
||||
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 {
|
||||
TextButton(
|
||||
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
|
||||
}
|
||||
}
|
||||
}
|
||||
sends.firstOrNull { it.id == retryId }?.let { send ->
|
||||
recoverySends.firstOrNull { it.id == retryId }?.let { send ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { retryId = null },
|
||||
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(
|
||||
onDismissRequest = { discardId = null },
|
||||
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.ChatSearchState
|
||||
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.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||
@@ -57,6 +58,23 @@ private object FullBleedContentType {
|
||||
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
|
||||
* surface since the bubble renderer was removed. User messages keep their
|
||||
@@ -104,7 +122,7 @@ fun FullBleedChatList(
|
||||
val isCompressing = transcript.isCompressing
|
||||
val compressionStatus = transcript.compressionStatus
|
||||
val speakingMessageId = transcript.speakingMessageId
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -311,6 +329,7 @@ fun FullBleedChatList(
|
||||
onImageClick = actions.onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
pendingSendState = transcript.pendingSendStates[userMessage.id],
|
||||
)
|
||||
if (userMessage.reactions.isNotEmpty()) {
|
||||
MessageReactionChips(
|
||||
@@ -561,6 +580,7 @@ private fun renderUserBubble(
|
||||
onImageClick: (ImageViewerModel) -> Unit,
|
||||
messageStatsEnabled: Boolean,
|
||||
showUserMessageTokens: Boolean,
|
||||
pendingSendState: PendingSendState?,
|
||||
) {
|
||||
UserBubble(
|
||||
message = message,
|
||||
@@ -574,5 +594,6 @@ private fun renderUserBubble(
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
pendingSendState = pendingSendState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
||||
displayKind == null &&
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
agentEntries += AgentEntry.Prose(message)
|
||||
}
|
||||
|
||||
message.isHiddenTool() -> {
|
||||
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
|
||||
}
|
||||
|
||||
MessageRole.TOOL == message.role -> {
|
||||
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.ClarifyUi
|
||||
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.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
@@ -37,6 +38,7 @@ data class TranscriptUiState(
|
||||
val isCompressing: Boolean,
|
||||
val compressionStatus: String?,
|
||||
val speakingMessageId: String?,
|
||||
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
|
||||
) {
|
||||
companion object {
|
||||
/** 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,
|
||||
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
||||
speakingMessageId = speakingMessageId,
|
||||
pendingSendStates =
|
||||
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,6 +32,7 @@ import com.m57.hermescontrol.R
|
||||
fun LandingScreen(
|
||||
onAuthLogin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onAiturkPair: () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -88,6 +90,16 @@ fun LandingScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
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,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
|
||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.R
|
||||
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.SectionCard
|
||||
|
||||
private const val AITURK_DOWNLOAD_URL = "https://turkservis.online/indir"
|
||||
|
||||
@Composable
|
||||
internal fun AboutSection(
|
||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||
@@ -55,21 +58,31 @@ internal fun AboutSection(
|
||||
label = stringResource(R.string.settings_about_version),
|
||||
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
)
|
||||
UpdateRow(
|
||||
state = updateState,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
ReleaseCandidateRow(
|
||||
checked = checkReleaseCandidateUpdates,
|
||||
// Mid-download / mid-install the channel must not flip under the
|
||||
// in-flight APK.
|
||||
enabled =
|
||||
updateState !is AppUpdateState.Downloading &&
|
||||
updateState !is AppUpdateState.Installing,
|
||||
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
||||
)
|
||||
// AITURK: the upstream GitHub self-updater is switched off
|
||||
// (UpdateNoticeManager.enabled = false); new builds ship from the
|
||||
// AITURK download page instead.
|
||||
if (UpdateNoticeManager.enabled) {
|
||||
UpdateRow(
|
||||
state = updateState,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
ReleaseCandidateRow(
|
||||
checked = checkReleaseCandidateUpdates,
|
||||
// Mid-download / mid-install the channel must not flip under the
|
||||
// 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(
|
||||
label = stringResource(R.string.settings_about_build),
|
||||
value =
|
||||
@@ -87,6 +100,14 @@ internal fun AboutSection(
|
||||
}
|
||||
|
||||
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 = "https://github.com/Hy4ri/hermes-mobile",
|
||||
style =
|
||||
|
||||
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
|
||||
listOf(
|
||||
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
||||
"en" to stringResource(R.string.language_english),
|
||||
"tr" to stringResource(R.string.language_turkish),
|
||||
"ar" to stringResource(R.string.language_arabic),
|
||||
"zh" to stringResource(R.string.language_chinese),
|
||||
"ko" to stringResource(R.string.language_korean),
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Solid brand background -->
|
||||
<!-- Solid AITURK brand background -->
|
||||
<path
|
||||
android:fillColor="#1B1B2F"
|
||||
android:fillColor="#081525"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
@@ -3,23 +3,18 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Left pillar of H -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
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" />
|
||||
|
||||
<!-- Right pillar of H — extends upward into a wing/swoop -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
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" />
|
||||
|
||||
<!-- Crossbar of H -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
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" />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- AITURK "A" mark (source: AITURK IDE aiturk_icon, 256 viewport), scaled
|
||||
into the 66dp adaptive-icon safe zone and centred. -->
|
||||
<group
|
||||
android:scaleX="0.238"
|
||||
android:scaleY="0.238"
|
||||
android:translateX="23.536"
|
||||
android:translateY="24.488">
|
||||
<path
|
||||
android:fillColor="#24D4C6"
|
||||
android:pathData="M128,30L230,218H185L164,178H92L71,218H26L128,30ZM128,109L106,151H150L128,109Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M128,58L148,96H108L128,58Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
<string name="chat_send_desc">إرسال</string>
|
||||
<string name="chat_mic_listening">جارٍ الاستماع…</string>
|
||||
<string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string>
|
||||
@@ -57,7 +57,7 @@
|
||||
<string name="billing_plan">الخطة</string>
|
||||
<string name="billing_topup">شحن الرصيد</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_section_converse">محادثة</string>
|
||||
<string name="nav_drawer_section_automate">أتمتة</string>
|
||||
@@ -127,27 +127,27 @@
|
||||
<string name="screen_analytics">التحليلات</string>
|
||||
<string name="screen_billing">الفواتير والاشتراكات</string>
|
||||
<string name="screen_files">الملفات</string>
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">بانتظار ردود Hermes</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">بانتظار ردود AITURK</string>
|
||||
<string name="notif_connected_in_background">متصل في الخلفية</string>
|
||||
<string name="notif_action_turn_off">إيقاف</string>
|
||||
<string name="notif_connecting">جارٍ الاتصال…</string>
|
||||
<string name="notif_waiting_network">في انتظار الشبكة</string>
|
||||
<string name="notif_reconnecting">جارٍ إعادة الاتصال</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_channel_service_name">خدمة Hermes في الخلفية</string>
|
||||
<string name="notif_channel_service_name">خدمة AITURK في الخلفية</string>
|
||||
<string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string>
|
||||
<string name="notif_channel_chat_name">ردود محادثة Hermes</string>
|
||||
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من Hermes</string>
|
||||
<string name="notif_channel_chat_name">ردود محادثة AITURK</string>
|
||||
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من AITURK</string>
|
||||
<string name="action_reply">رد</string>
|
||||
<string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string>
|
||||
<string name="chat_action_search">بحث</string>
|
||||
<string name="chat_action_close_search">إغلاق البحث</string>
|
||||
<string name="chat_action_reconnect">إعادة الاتصال</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_password">كلمة المرور</string>
|
||||
<string name="chat_relogin_submit">تسجيل الدخول</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string>
|
||||
<string name="chat_empty_title">جاهز للدردشة</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_status_reconnecting">جارٍ إعادة الاتصال\u2026</string>
|
||||
<string name="chat_status_disconnected">تم قطع الاتصال</string>
|
||||
@@ -227,9 +227,7 @@
|
||||
<string name="settings_action_test_connection">اختبار الاتصال</string>
|
||||
<string name="settings_sec_about">حول التطبيق</string>
|
||||
<string name="settings_sec_language">اللغة</string>
|
||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
||||
<string name="settings_support_title">ادعم Hermes Mobile</string>
|
||||
<string name="settings_support_message">إذا وفّر لك Hermes Mobile الوقت، يمكنك دعم تطويره.</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">ادعمني على Ko-fi</string>
|
||||
<string name="settings_about_version">الإصدار</string>
|
||||
<string name="settings_about_build">رقم البنية</string>
|
||||
@@ -289,7 +287,7 @@
|
||||
<string name="connect_selected_profile">الملف التعريفي: %1$s</string>
|
||||
<string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</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_token">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_http_code">استجاب الخادم برمز HTTP %1$d</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_connection_failed">فشل الاتصال: %1$s</string>
|
||||
<string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string>
|
||||
@@ -500,7 +498,7 @@
|
||||
<string name="skills_hub_source_offline">غير متصل</string>
|
||||
<string name="skills_hub_source_via_index">عبر الفهرس</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_scan">فحص</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_save_changes">حفظ التغييرات</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_resume">استئناف</string>
|
||||
<string name="logs_action_export">مشاركة</string>
|
||||
@@ -552,7 +550,7 @@
|
||||
<string name="system_label_arch">المعمارية</string>
|
||||
<string name="system_label_hostname">اسم المضيف</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_memory">الذاكرة (RAM)</string>
|
||||
<string name="system_label_disk">القرص</string>
|
||||
@@ -566,7 +564,7 @@
|
||||
<string name="system_version_behind">متأخر بمقدار %1$d</string>
|
||||
<string name="system_action_check_updates">التحقق من وجود تحديثات</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_progress_title">تحديث البرنامج</string>
|
||||
<string name="system_sec_portal">بوابة Nous Portal</string>
|
||||
@@ -642,10 +640,10 @@
|
||||
<string name="system_sec_restore">استعادة من ملف تم رفعه</string>
|
||||
<string name="system_op_restore_upload">رفع واستعادة</string>
|
||||
<string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string>
|
||||
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ Hermes؟</string>
|
||||
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات Hermes الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ AITURK؟</string>
|
||||
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات AITURK الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</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_uploading">جارٍ الرفع…</string>
|
||||
<string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string>
|
||||
@@ -704,7 +702,7 @@
|
||||
<item quantity="other">%1$d نموذج</item>
|
||||
</plurals>
|
||||
<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_message_count">%1$d رسالة</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -849,7 +847,7 @@
|
||||
<string name="config_action_save">حفظ</string>
|
||||
<string name="config_action_save_yaml">حفظ YAML</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_provider">المزود: %1$s</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_path">المسار: %1$s</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_identity">الهوية</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_local">تحديد المهارات المحلية</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_search_results">نتائج البحث:</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_elevated">ذاكرة الوكيل منخفضة</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_paused">متوقف مؤقتاً</string>
|
||||
<string name="cron_action_pause">إيقاف مؤقت</string>
|
||||
@@ -1124,7 +1122,7 @@
|
||||
<string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string>
|
||||
<string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</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_action_toggle_visibility">تبديل مستوى الرؤية</string>
|
||||
<string name="keys_search_placeholder">بحث في المفاتيح…</string>
|
||||
@@ -1165,7 +1163,7 @@
|
||||
<string name="files_dialog_delete_title">هل تريد الحذف؟</string>
|
||||
<string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</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_sec_settings">إعدادات التهيئة</string>
|
||||
<string name="channels_action_save_settings">حفظ الإعدادات</string>
|
||||
@@ -1188,7 +1186,7 @@
|
||||
<string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string>
|
||||
<string name="settings_logout">تسجيل الخروج</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_token_label">رمز الجلسة (Session Token)</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_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</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_use_profile">الدخول باسم %1$s</string>
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">لوحة التحكم لـ AITURK</string>
|
||||
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
|
||||
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
|
||||
<string name="providers_empty_title">لا يوجد موفرو OAuth</string>
|
||||
@@ -1226,7 +1224,7 @@
|
||||
<string name="action_dismiss">تجاهل</string>
|
||||
<string name="action_done">تم</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_dialog_title">التحديث إلى %1$s</string>
|
||||
<string name="update_dialog_changelog_header">ما الجديد</string>
|
||||
@@ -1316,7 +1314,7 @@
|
||||
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
|
||||
<string name="gif_play">تشغيل GIF</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="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string>
|
||||
<string name="mcp_search_placeholder">البحث في خوادم MCP...</string>
|
||||
@@ -1554,7 +1552,7 @@
|
||||
<string name="connection_setup_open_browser">فتح في المتصفح</string>
|
||||
<string name="connection_setup_required">%1$s مطلوب</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_kind_plugin">إضافة</string>
|
||||
<string name="connection_catalog_kind_skill">مهارة</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">헤르메스</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">전송</string>
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<!-- Navigation & Drawer -->
|
||||
<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_section_converse">대화</string>
|
||||
<string name="nav_drawer_section_automate">자동화</string>
|
||||
@@ -124,20 +124,20 @@
|
||||
<string name="screen_files">파일</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_title">헤르메스</string>
|
||||
<string name="notif_waiting_replies">헤르메스 응답 대기 중</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">AITURK 응답 대기 중</string>
|
||||
<string name="notif_connected_in_background">백그라운드에서 연결됨</string>
|
||||
<string name="notif_action_turn_off">끄기</string>
|
||||
<string name="notif_connecting">연결 중…</string>
|
||||
<string name="notif_waiting_network">네트워크 대기 중</string>
|
||||
<string name="notif_reconnecting">재연결 중</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_channel_service_name">헤르메스 백그라운드 서비스</string>
|
||||
<string name="notif_channel_service_name">AITURK 백그라운드 서비스</string>
|
||||
<string name="notif_channel_service_desc">백그라운드에서 연결 유지</string>
|
||||
<string name="notif_channel_chat_name">헤르메스 채팅 응답</string>
|
||||
<string name="notif_channel_chat_desc">헤르메스의 새 응답 알림</string>
|
||||
<string name="notif_channel_chat_name">AITURK 채팅 응답</string>
|
||||
<string name="notif_channel_chat_desc">AITURK의 새 응답 알림</string>
|
||||
<string name="action_reply">답장</string>
|
||||
|
||||
<!-- Chat Screen & Bubble -->
|
||||
@@ -146,7 +146,7 @@
|
||||
<string name="chat_action_close_search">검색 닫기</string>
|
||||
<string name="chat_action_reconnect">재접속</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_password">비밀번호</string>
|
||||
<string name="chat_relogin_submit">로그인</string>
|
||||
@@ -154,7 +154,7 @@
|
||||
<string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string>
|
||||
<string name="chat_empty_title">채팅 준비 완료</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_disconnected">연결 끊김</string>
|
||||
<string name="chat_status_no_network">네트워크 없음</string>
|
||||
@@ -188,7 +188,7 @@
|
||||
<string name="settings_sec_connection">연결</string>
|
||||
<string name="settings_field_token">인증 토큰</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_item_auto_reconnect">자동 재연결</string>
|
||||
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
||||
@@ -229,9 +229,7 @@
|
||||
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
|
||||
<string name="settings_action_test_connection">연결 테스트</string>
|
||||
<string name="settings_sec_about">정보</string>
|
||||
<string name="settings_about_app_name">헤르메스 모바일</string>
|
||||
<string name="settings_support_title">Hermes Mobile 후원</string>
|
||||
<string name="settings_support_message">Hermes Mobile이 시간을 절약해 준다면 개발을 후원해 주세요.</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">Ko-fi에서 후원하기</string>
|
||||
<string name="settings_about_version">버전</string>
|
||||
<string name="settings_about_build">빌드</string>
|
||||
@@ -286,7 +284,7 @@
|
||||
<string name="connect_selected_profile">프로필: %1$s</string>
|
||||
<string name="connect_action_select_profile">저장된 프로필 선택</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_token">토큰</string>
|
||||
|
||||
@@ -296,13 +294,13 @@
|
||||
<string name="connect_error_403">접근 거부 (403 Forbidden)</string>
|
||||
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</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_connection_failed">연결 실패: %1$s</string>
|
||||
|
||||
<string name="connect_save_profile">연결 프로필 저장</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_show_manual">수동으로 입력</string>
|
||||
<string name="connect_placeholder_token">API 토큰</string>
|
||||
@@ -512,7 +510,7 @@
|
||||
|
||||
<!-- Logs Screen -->
|
||||
<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_resume">재개</string>
|
||||
<string name="logs_action_export">공유</string>
|
||||
@@ -539,7 +537,7 @@
|
||||
<string name="system_label_arch">아키텍처</string>
|
||||
<string name="system_label_hostname">호스트명</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_memory">메모리</string>
|
||||
<string name="system_label_disk">디스크</string>
|
||||
@@ -553,8 +551,8 @@
|
||||
<string name="system_version_behind">%1$d 버전 뒤짐</string>
|
||||
<string name="system_action_check_updates">업데이트 확인</string>
|
||||
<string name="system_action_update_now">지금 업데이트</string>
|
||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
||||
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||
<string name="system_update_confirm_title">AITURK 업데이트?</string>
|
||||
<string name="system_update_confirm_desc">AITURK 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||
<string name="system_migration_title">게이트웨이 마이그레이션</string>
|
||||
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
|
||||
<string name="system_migration_profiles">프로필</string>
|
||||
@@ -703,7 +701,7 @@
|
||||
|
||||
<!-- Profiles Screen -->
|
||||
<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_provider">제공자: %1$s</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_path">경로: %1$s</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_identity">아이덴티티</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_local">로컬 스킬 선택</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_search_results">검색 결과:</string>
|
||||
<string name="profiles_builder_mcp_title">설정된 MCP 서버</string>
|
||||
@@ -963,7 +961,7 @@
|
||||
|
||||
<!-- Keys Screen -->
|
||||
<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_action_toggle_visibility">표시 전환</string>
|
||||
<string name="keys_search_placeholder">키 검색…</string>
|
||||
@@ -978,7 +976,7 @@
|
||||
|
||||
<!-- Channels Screen -->
|
||||
<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_sec_settings">설정</string>
|
||||
<string name="channels_action_save_settings">설정 저장</string>
|
||||
@@ -1007,7 +1005,7 @@
|
||||
|
||||
<!-- Auth Login Screen -->
|
||||
<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_username_label">사용자명</string>
|
||||
<string name="auth_login_password_label">비밀번호</string>
|
||||
@@ -1022,8 +1020,8 @@
|
||||
<string name="auth_login_use_profile">%1$s(으)로 접속</string>
|
||||
|
||||
<!-- Landing Screen -->
|
||||
<string name="landing_title">헤르메스 모바일</string>
|
||||
<string name="landing_subtitle">Hermes Agent 제어판</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">AITURK 제어판</string>
|
||||
<string name="landing_action_auth_login">인증 로그인</string>
|
||||
|
||||
<!-- Loading / Startup State (issue #1171) -->
|
||||
@@ -1210,7 +1208,7 @@
|
||||
<string name="connection_setup_open_browser">브라우저에서 열기</string>
|
||||
<string name="connection_setup_required">%1$s은(는) 필수입니다</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_kind_plugin">플러그인</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>
|
||||
<!-- 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_mic_listening">正在聆听…</string>
|
||||
<string name="stt_permission_denied">语音输入需要麦克风权限</string>
|
||||
@@ -57,7 +57,7 @@
|
||||
<string name="billing_plan">套餐</string>
|
||||
<string name="billing_topup">充值</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_section_converse">对话</string>
|
||||
<string name="nav_drawer_section_automate">自动化</string>
|
||||
@@ -127,27 +127,27 @@
|
||||
<string name="screen_analytics">分析</string>
|
||||
<string name="screen_billing">计费</string>
|
||||
<string name="screen_files">文件</string>
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">等待 Hermes 回复</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">等待 AITURK 回复</string>
|
||||
<string name="notif_connected_in_background">在后台保持连接</string>
|
||||
<string name="notif_action_turn_off">关闭</string>
|
||||
<string name="notif_connecting">正在连接…</string>
|
||||
<string name="notif_waiting_network">等待网络连接</string>
|
||||
<string name="notif_reconnecting">正在重新连接</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_channel_service_name">Hermes 后台服务</string>
|
||||
<string name="notif_channel_service_name">AITURK 后台服务</string>
|
||||
<string name="notif_channel_service_desc">在后台保持连接</string>
|
||||
<string name="notif_channel_chat_name">Hermes 对话回复</string>
|
||||
<string name="notif_channel_chat_desc">Hermes 新回复提醒</string>
|
||||
<string name="notif_channel_chat_name">AITURK 对话回复</string>
|
||||
<string name="notif_channel_chat_desc">AITURK 新回复提醒</string>
|
||||
<string name="action_reply">回复</string>
|
||||
<string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string>
|
||||
<string name="chat_action_search">搜索</string>
|
||||
<string name="chat_action_close_search">关闭搜索</string>
|
||||
<string name="chat_action_reconnect">重新连接</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_password">密码</string>
|
||||
<string name="chat_relogin_submit">登录</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="chat_relogin_error_empty">用户名和密码不能为空</string>
|
||||
<string name="chat_empty_title">准备就绪</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_status_reconnecting">重新连接中…</string>
|
||||
<string name="chat_status_disconnected">已断开</string>
|
||||
@@ -185,7 +185,7 @@
|
||||
<string name="settings_sec_connection">连接</string>
|
||||
<string name="settings_field_token">认证令牌</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_item_auto_reconnect">自动重连</string>
|
||||
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
||||
@@ -227,9 +227,7 @@
|
||||
<string name="settings_action_test_connection">测试连接</string>
|
||||
<string name="settings_sec_about">关于</string>
|
||||
<string name="settings_sec_language">语言</string>
|
||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
||||
<string name="settings_support_title">支持 Hermes Mobile</string>
|
||||
<string name="settings_support_message">如果 Hermes Mobile 为你节省了时间,欢迎支持它的开发。</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">在 Ko-fi 上支持我</string>
|
||||
<string name="settings_about_version">版本</string>
|
||||
<string name="settings_about_build">构建</string>
|
||||
@@ -289,7 +287,7 @@
|
||||
<string name="connect_selected_profile">配置:%1$s</string>
|
||||
<string name="connect_action_select_profile">选择已保存配置</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_token">令牌</string>
|
||||
<string name="connect_error_token_required">需要填写令牌</string>
|
||||
@@ -297,7 +295,7 @@
|
||||
<string name="connect_error_403">访问被拒绝(403 禁止访问)</string>
|
||||
<string name="connect_error_http_code">服务器返回 HTTP %1$d</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_connection_failed">连接失败:%1$s</string>
|
||||
<string name="connect_save_profile">保存连接配置</string>
|
||||
@@ -499,7 +497,7 @@
|
||||
<string name="skills_hub_source_offline">离线</string>
|
||||
<string name="skills_hub_source_via_index">通过索引</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_scan">扫描</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_save_changes">保存更改</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_resume">继续</string>
|
||||
<string name="logs_action_export">分享</string>
|
||||
@@ -543,7 +541,7 @@
|
||||
<string name="system_label_arch">架构</string>
|
||||
<string name="system_label_hostname">主机名</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_memory">内存</string>
|
||||
<string name="system_label_disk">磁盘</string>
|
||||
@@ -557,7 +555,7 @@
|
||||
<string name="system_version_behind">落后 %1$d 个版本</string>
|
||||
<string name="system_action_check_updates">检查更新</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_progress_title">软件更新</string>
|
||||
<string name="system_sec_portal">Nous Portal</string>
|
||||
@@ -633,10 +631,10 @@
|
||||
<string name="system_sec_restore">从上传恢复</string>
|
||||
<string name="system_op_restore_upload">上传恢复包</string>
|
||||
<string name="system_op_restore_pick">选择备份文件</string>
|
||||
<string name="system_op_import_confirm_title">恢复完整 Hermes 备份?</string>
|
||||
<string name="system_op_import_confirm_desc">这将覆盖当前的 Hermes 配置、技能、会话和数据。此操作无法撤销。</string>
|
||||
<string name="system_op_import_confirm_title">恢复完整 AITURK 备份?</string>
|
||||
<string name="system_op_import_confirm_desc">这将覆盖当前的 AITURK 配置、技能、会话和数据。此操作无法撤销。</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_uploading">上传中…</string>
|
||||
<string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string>
|
||||
@@ -691,7 +689,7 @@
|
||||
<item quantity="other">%1$d 个模型</item>
|
||||
</plurals>
|
||||
<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_message_count">%1$d 条消息</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -820,7 +818,7 @@
|
||||
<string name="config_action_save">保存</string>
|
||||
<string name="config_action_save_yaml">保存 YAML</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_provider">提供商:%1$s</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_path">路径:%1$s</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_identity">身份</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_local">选择本地技能</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_search_results">搜索结果:</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_elevated">代理内存不足</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_paused">已暂停</string>
|
||||
<string name="cron_action_pause">暂停</string>
|
||||
@@ -1091,7 +1089,7 @@
|
||||
<string name="achievements_reset_title">重置成就?</string>
|
||||
<string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</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_action_toggle_visibility">切换可见性</string>
|
||||
<string name="keys_search_placeholder">搜索密钥…</string>
|
||||
@@ -1132,7 +1130,7 @@
|
||||
<string name="files_dialog_delete_title">删除?</string>
|
||||
<string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</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_sec_settings">配置设置</string>
|
||||
<string name="channels_action_save_settings">保存设置</string>
|
||||
@@ -1155,7 +1153,7 @@
|
||||
<string name="settings_saved_profiles">已保存配置(%1$d)</string>
|
||||
<string name="settings_logout">退出登录</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_token_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_unreachable">无法访问 dashboard——检查 IP 和端口</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_use_profile">以 %1$s 身份进入</string>
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">Hermes Agent 控制面板</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">AITURK 控制面板</string>
|
||||
<string name="landing_action_auth_login">认证登录</string>
|
||||
<string name="auth_init_failed">应用设置初始化失败</string>
|
||||
<string name="providers_empty_title">没有 OAuth 提供商</string>
|
||||
@@ -1193,7 +1191,7 @@
|
||||
<string name="action_dismiss">忽略</string>
|
||||
<string name="action_done">完成</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="processes_empty_title">没有运行中的进程</string>
|
||||
<string name="processes_empty_desc">此会话的后台进程将显示在这里。</string>
|
||||
@@ -1283,7 +1281,7 @@
|
||||
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
|
||||
<string name="gif_play">播放 GIF</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="mcp_no_match">没有匹配"%1$s"的服务器</string>
|
||||
<string name="mcp_search_placeholder">搜索 MCP 服务器…</string>
|
||||
@@ -1506,7 +1504,7 @@
|
||||
<string name="connection_setup_open_browser">在浏览器中打开</string>
|
||||
<string name="connection_setup_required">%1$s为必填项</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_kind_plugin">插件</string>
|
||||
<string name="connection_catalog_kind_skill">技能</string>
|
||||
@@ -1535,6 +1533,12 @@
|
||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</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_parked">停止后已暂停</string>
|
||||
<string name="chat_pending_sending">正在发送…</string>
|
||||
@@ -1565,7 +1569,7 @@
|
||||
<string name="detail_dialog_docs">文档</string>
|
||||
<string name="detail_dialog_maintainer">维护者</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="group_chat_action_stop">停止生成</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_running">运行中…</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_title">未找到目录插件</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"?>
|
||||
<resources>
|
||||
<string name="chat_reply_failed_title">Hermes 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_title">AITURK couldn\'t finish this reply</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_collapsed">Collapsed</string>
|
||||
<string name="chat_reply_failed_details_expanded">Expanded</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">Send</string>
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
<!-- Navigation & Drawer -->
|
||||
<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_section_converse">Converse</string>
|
||||
<string name="nav_drawer_section_automate">Automate</string>
|
||||
@@ -150,21 +150,21 @@
|
||||
<string name="screen_files">Files</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">Waiting for AITURK replies</string>
|
||||
<string name="notif_connected_in_background">Connected in background</string>
|
||||
<string name="notif_action_turn_off">Turn off</string>
|
||||
<string name="notif_connecting">Connecting…</string>
|
||||
<string name="notif_waiting_network">Waiting for network</string>
|
||||
<string name="notif_reconnecting">Reconnecting</string>
|
||||
<string name="notif_new_message">New message</string>
|
||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
||||
<string name="notif_input_needed">Hermes needs your input</string>
|
||||
<string name="notif_clarification_needed">AITURK needs a clarification</string>
|
||||
<string name="notif_input_needed">AITURK needs your input</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_chat_name">Hermes Chat Replies</string>
|
||||
<string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string>
|
||||
<string name="notif_channel_chat_name">AITURK Chat Replies</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_failed">Session failed: %1$s</string>
|
||||
<string name="action_reply">Reply</string>
|
||||
@@ -188,7 +188,7 @@
|
||||
<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_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_password">Password</string>
|
||||
<string name="chat_relogin_submit">Login</string>
|
||||
@@ -196,7 +196,7 @@
|
||||
<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_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_status_reconnecting">Reconnecting\u2026</string>
|
||||
<string name="chat_status_disconnected">Disconnected</string>
|
||||
@@ -219,6 +219,7 @@
|
||||
<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_pending_sends">Pending messages</string>
|
||||
<string name="chat_send_accepted">Accepted by server</string>
|
||||
<plurals name="chat_pending_count">
|
||||
<item quantity="one">%d pending message</item>
|
||||
<item quantity="other">%d pending messages</item>
|
||||
@@ -249,7 +250,7 @@
|
||||
<string name="content_desc_copied">Copied</string>
|
||||
<string name="content_desc_speak">Read aloud</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_playback">Could not play the message audio</string>
|
||||
<string name="content_desc_edit">Edit</string>
|
||||
@@ -277,9 +278,9 @@
|
||||
<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_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_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_item_auto_reconnect">Auto-Reconnect</string>
|
||||
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
||||
@@ -321,7 +322,9 @@
|
||||
<string name="settings_action_test_connection">Test Connection</string>
|
||||
<string name="settings_sec_about">About</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_build">Build</string>
|
||||
<string name="settings_about_debug">Debug</string>
|
||||
@@ -340,9 +343,9 @@
|
||||
<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_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_message">If Hermes Mobile saves you time, you can support its development.</string>
|
||||
<string name="settings_support_action">Support me on Ko-fi</string>
|
||||
<string name="settings_support_title">Support the original developer</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 on Ko-fi</string>
|
||||
|
||||
<!-- Settings list summaries (drill-down) -->
|
||||
<string name="settings_summary_profiles">%1$d saved</string>
|
||||
@@ -385,12 +388,13 @@
|
||||
<string name="language_arabic">العربية</string>
|
||||
<string name="language_chinese">简体中文</string>
|
||||
<string name="language_korean">한국어</string>
|
||||
<string name="language_turkish">Türkçe</string>
|
||||
|
||||
<!-- Connect Screen -->
|
||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||
<string name="connect_action_select_profile">Select Saved Profile</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_token">Token</string>
|
||||
|
||||
@@ -400,7 +404,7 @@
|
||||
<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_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_connection_failed">Connection failed: %1$s</string>
|
||||
|
||||
@@ -608,7 +612,7 @@
|
||||
<string name="skills_hub_source_offline">offline</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_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_scan">Scan</string>
|
||||
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
|
||||
@@ -636,7 +640,7 @@
|
||||
|
||||
<!-- Logs Screen -->
|
||||
<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_resume">Resume</string>
|
||||
<string name="logs_action_export">Share</string>
|
||||
@@ -663,7 +667,7 @@
|
||||
<string name="system_label_arch">Arch</string>
|
||||
<string name="system_label_hostname">Host</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_memory">Memory</string>
|
||||
<string name="system_label_disk">Disk</string>
|
||||
@@ -677,7 +681,7 @@
|
||||
<string name="system_version_behind">%1$d behind</string>
|
||||
<string name="system_action_check_updates">Check for updates</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_progress_title">Software update</string>
|
||||
|
||||
@@ -766,12 +770,12 @@
|
||||
<string name="system_sec_restore">Restore from 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_import_confirm_title">Restore full Hermes 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_title">Restore full AITURK backup?</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 -->
|
||||
<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_uploading">Uploading…</string>
|
||||
<string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string>
|
||||
@@ -838,7 +842,7 @@
|
||||
|
||||
<!-- Sessions (History) Screen -->
|
||||
<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_message_count">%1$d messages</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -988,7 +992,7 @@
|
||||
|
||||
<!-- Profiles Screen -->
|
||||
<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_provider">Provider: %1$s</string>
|
||||
<string name="profiles_label_skills">%1$d skills</string>
|
||||
@@ -1030,7 +1034,7 @@
|
||||
<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_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_identity">Identity</string>
|
||||
<string name="profiles_builder_step_model">Model Configuration</string>
|
||||
@@ -1048,7 +1052,7 @@
|
||||
<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_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_search_results">Search Results:</string>
|
||||
<string name="profiles_builder_mcp_title">Configured MCP Servers</string>
|
||||
@@ -1164,7 +1168,7 @@
|
||||
<string name="detail_dialog_maintainer">Maintainer</string>
|
||||
<string name="detail_dialog_repository">Repository</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_capabilities">Capabilities</string>
|
||||
|
||||
@@ -1240,7 +1244,7 @@
|
||||
|
||||
<!-- CronJobs Screen -->
|
||||
<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_paused">Paused</string>
|
||||
<string name="cron_action_pause">Pause</string>
|
||||
@@ -1319,7 +1323,7 @@
|
||||
|
||||
<!-- Keys Screen -->
|
||||
<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_action_toggle_visibility">Toggle Visibility</string>
|
||||
<string name="keys_search_placeholder">Search keys…</string>
|
||||
@@ -1364,7 +1368,7 @@
|
||||
|
||||
<!-- Channels Screen -->
|
||||
<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_sec_settings">Configuration Settings</string>
|
||||
<string name="channels_action_save_settings">Save Settings</string>
|
||||
@@ -1393,7 +1397,7 @@
|
||||
|
||||
<!-- Auth Login Screen -->
|
||||
<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_token_label">Session Token</string>
|
||||
<string name="auth_login_username_label">Username</string>
|
||||
@@ -1406,13 +1410,13 @@
|
||||
<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_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_use_profile">Enter as %1$s</string>
|
||||
|
||||
<!-- Landing Screen -->
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">Control panel for Hermes Agent</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">Control panel for AITURK</string>
|
||||
<string name="landing_action_auth_login">Auth Login</string>
|
||||
|
||||
<!-- Loading / Startup State (issue #1171) -->
|
||||
@@ -1439,7 +1443,7 @@
|
||||
<string name="action_close">Close</string>
|
||||
|
||||
<!-- 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_dialog_title">Update to %1$s</string>
|
||||
<string name="update_dialog_changelog_header">What\'s New</string>
|
||||
@@ -1537,7 +1541,7 @@
|
||||
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
|
||||
<string name="gif_play">Play GIF</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="mcp_no_match">No servers match "%1$s"</string>
|
||||
<string name="mcp_search_placeholder">Search MCP servers...</string>
|
||||
@@ -1787,7 +1791,7 @@
|
||||
<string name="connection_setup_open_browser">Open in browser</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_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_kind_plugin">Plugin</string>
|
||||
<string name="connection_catalog_kind_skill">Skill</string>
|
||||
@@ -1834,7 +1838,7 @@
|
||||
<string name="kanban_active_workers">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_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_value">Header value</string>
|
||||
<string name="custom_headers_add">Add header</string>
|
||||
@@ -1858,4 +1862,36 @@
|
||||
<string name="mcp_servers_catalog_empty_title">No catalog entries</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>
|
||||
|
||||
<!-- 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. The phone and the computer must be on the same Wi-Fi.</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 on your network. 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. Allow it to connect to your computer.</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>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?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).
|
||||
|
||||
Both sessionService and recognitionService are REQUIRED by the framework
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import java.net.Socket
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class AiturkLanTrustTest {
|
||||
private val memory = MemoryPins()
|
||||
private val ides = mutableListOf<FakeIde>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
AiturkLanTrust.resetForTest(memory)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
ides.forEach { runCatching { it.stop() } }
|
||||
AiturkLanTrust.resetForTest(MemoryPins())
|
||||
}
|
||||
|
||||
private fun ide(certificate: okhttp3.tls.HeldCertificate = FakeIde.selfSigned()): FakeIde =
|
||||
FakeIde(certificate).start().also { ides += it }
|
||||
|
||||
/** Built exactly like OkHttpProvider.base wires it: the single `applyTo` hook. */
|
||||
private fun appClient(): OkHttpClient =
|
||||
AiturkLanTrust
|
||||
.applyTo(OkHttpClient.Builder())
|
||||
.connectTimeout(3, TimeUnit.SECONDS)
|
||||
.readTimeout(3, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun get(
|
||||
client: OkHttpClient,
|
||||
server: FakeIde,
|
||||
): Int =
|
||||
client
|
||||
.newCall(Request.Builder().url("https://${server.endpoint.hostPort}/aiturk/pair/hello").build())
|
||||
.execute()
|
||||
.use { it.code }
|
||||
|
||||
@Test
|
||||
fun pairedHostPortIsTrustedByFingerprintAlone() {
|
||||
val server = ide()
|
||||
AiturkLanTrust.pin(server.endpoint.hostPort, server.fingerprint)
|
||||
|
||||
assertEquals(200, get(appClient(), server))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairedHostPortWithAnotherCertificateIsRejected() {
|
||||
val server = ide()
|
||||
AiturkLanTrust.pin(server.endpoint.hostPort, FakeIde.fingerprintOf(FakeIde.selfSigned()))
|
||||
|
||||
assertThrows(IOException::class.java) { get(appClient(), server) }
|
||||
assertTrue(server.paths.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinDoesNotRelaxValidationForAnyOtherAddress() {
|
||||
// Same certificate on two ports of the same host; only one port is paired.
|
||||
val certificate = FakeIde.selfSigned()
|
||||
val paired = ide(certificate)
|
||||
val other = ide(certificate)
|
||||
AiturkLanTrust.pin(paired.endpoint.hostPort, paired.fingerprint)
|
||||
val client = appClient()
|
||||
|
||||
assertEquals(200, get(client, paired))
|
||||
// The unpaired port falls through to the platform trust store, which
|
||||
// does not know this self-signed certificate.
|
||||
assertThrows(IOException::class.java) { get(client, other) }
|
||||
assertTrue(other.paths.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withoutAnyPinSelfSignedServersAreStillRejected() {
|
||||
val server = ide()
|
||||
|
||||
assertThrows(IOException::class.java) { get(appClient(), server) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpairedPeersAreHandedToThePlatformTrustManagerUnchanged() {
|
||||
val server = ide()
|
||||
val chain = arrayOf(server.certificate.certificate)
|
||||
val delegate = RecordingTrustManager()
|
||||
val manager = PinAwareTrustManager(delegate, { null }, { emptyList() })
|
||||
|
||||
manager.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?)
|
||||
manager.checkServerTrusted(chain, "ECDHE_ECDSA")
|
||||
manager.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com")
|
||||
|
||||
assertEquals(3, delegate.serverChecks)
|
||||
// And a delegate that refuses is not overruled.
|
||||
val strict = PinAwareTrustManager(RecordingTrustManager(reject = true), { null }, { emptyList() })
|
||||
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?) }
|
||||
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA") }
|
||||
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hostnameVerificationIsDelegatedForUnpairedHosts() {
|
||||
var delegated = 0
|
||||
val delegate =
|
||||
HostnameVerifier { _, _ ->
|
||||
delegated++
|
||||
false
|
||||
}
|
||||
val verifier = PinAwareHostnameVerifier(delegate) { null }
|
||||
val session = io.mockk.mockk<javax.net.ssl.SSLSession>()
|
||||
io.mockk.every { session.peerPort } returns 443
|
||||
|
||||
assertFalse(verifier.verify("example.com", session))
|
||||
assertEquals(1, delegated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pinsSurviveARestartAndCanBeRemoved() {
|
||||
val fp = "A".repeat(43)
|
||||
AiturkLanTrust.pin("192.168.1.20:47321", fp)
|
||||
|
||||
AiturkLanTrust.resetForTest(memory)
|
||||
|
||||
assertEquals(fp, AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||
assertTrue(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
|
||||
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47322/"))
|
||||
assertFalse(AiturkLanTrust.isPairedUrl("http://192.168.1.20:47321/"))
|
||||
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.21:47321/"))
|
||||
assertFalse(AiturkLanTrust.isPairedUrl(null))
|
||||
|
||||
AiturkLanTrust.unpin("192.168.1.20:47321")
|
||||
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||
assertTrue(memory.stored.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedFingerprintCannotBePinned() {
|
||||
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("192.168.1.20:47321", "nope") }
|
||||
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableStorageMeansNothingIsPaired() {
|
||||
AiturkLanTrust.resetForTest(
|
||||
object : AiturkLanTrust.PinPersistence {
|
||||
override fun load(): Map<String, String> = error("storage not ready")
|
||||
|
||||
override fun save(pins: Map<String, String>) = Unit
|
||||
},
|
||||
)
|
||||
|
||||
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
|
||||
}
|
||||
|
||||
private class RecordingTrustManager(
|
||||
private val reject: Boolean = false,
|
||||
) : X509TrustManager {
|
||||
var serverChecks = 0
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) = Unit
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) {
|
||||
serverChecks++
|
||||
if (reject) throw CertificateException("untrusted")
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pairing code handles a one-time code and a long-lived device token. The
|
||||
* simplest guarantee that neither reaches logcat is that this code does not
|
||||
* log at all — this test keeps it that way.
|
||||
*/
|
||||
class AiturkNoSecretLoggingTest {
|
||||
private val sourceRoot = File("src/main/java/com/m57/hermescontrol")
|
||||
private val forbidden =
|
||||
listOf(
|
||||
Regex("""\bLog\.[a-z]+\("""),
|
||||
Regex("""\bprintln\("""),
|
||||
Regex("""\bprint\("""),
|
||||
Regex("""printStackTrace"""),
|
||||
Regex("""HttpLoggingInterceptor"""),
|
||||
Regex("""\bTimber\b"""),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun pairingSourcesDoNotLog() {
|
||||
val files =
|
||||
listOf("data/aiturk", "ui/aiturkpair")
|
||||
.map { File(sourceRoot, it) }
|
||||
.onEach { assertTrue(it.isDirectory, "missing source directory: ${it.absolutePath}") }
|
||||
.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() }
|
||||
assertTrue(files.size >= 5, "expected the pairing sources, found ${files.map { it.name }}")
|
||||
|
||||
val offenders =
|
||||
files.flatMap { file ->
|
||||
file.readLines().mapIndexedNotNull { index, line ->
|
||||
if (forbidden.any { it.containsMatchIn(line) }) "${file.name}:${index + 1}: ${line.trim()}" else null
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(offenders.isEmpty(), "logging found in pairing code:\n" + offenders.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingClientUsesItsOwnBareHttpClient() {
|
||||
// The shared OkHttpProvider clients carry the cookie jar, user-defined
|
||||
// custom headers and (in debug) request logging — none of that may
|
||||
// touch the gateway lookup or the claim that carries the code.
|
||||
val source = File(sourceRoot, "data/aiturk/AiturkPairingClient.kt").readText()
|
||||
|
||||
assertTrue(!source.contains("OkHttpProvider"), "AiturkPairingClient must not use OkHttpProvider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.net.ServerSocket
|
||||
|
||||
class AiturkPairingClientTest {
|
||||
private lateinit var gateway: MockWebServer
|
||||
private val ides = mutableListOf<FakeIde>()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
gateway = MockWebServer()
|
||||
gateway.start()
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
gateway.shutdown()
|
||||
ides.forEach { runCatching { it.stop() } }
|
||||
}
|
||||
|
||||
private fun ide(configure: FakeIde.() -> Unit = {}): FakeIde = FakeIde().apply(configure).start().also { ides += it }
|
||||
|
||||
private fun client(helloTimeoutMs: Long = 2_500L) =
|
||||
AiturkPairingClient(gatewayBaseUrl = gateway.url("/"), helloTimeoutMs = helloTimeoutMs)
|
||||
|
||||
private fun <T> AiturkPairResult<T>.error(): AiturkPairError? = (this as? AiturkPairResult.Failure)?.error
|
||||
|
||||
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
|
||||
|
||||
// ── resolve (gateway) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun resolveSendsOnlyTheLookupHashAndReturnsTheOffer() =
|
||||
runBlocking {
|
||||
val fp = "A".repeat(43)
|
||||
gateway.enqueue(
|
||||
FakeIde.json(200, """{"urls":["https://192.168.1.20:47321","https://10.0.0.5:47322"],"fp":"$fp","expiresIn":280}"""),
|
||||
)
|
||||
|
||||
val result = client().resolve("ABCDEFGH")
|
||||
|
||||
val offer = (result as AiturkPairResult.Success).value
|
||||
assertEquals(listOf(AiturkEndpoint("192.168.1.20", 47321), AiturkEndpoint("10.0.0.5", 47322)), offer.endpoints)
|
||||
assertEquals(fp, offer.fingerprint)
|
||||
val request = gateway.takeRequest()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/v1/aiturk/mobile-pair/resolve", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
assertEquals("""{"lookup":"${AiturkPairingPayloadTest.LOOKUP_ABCDEFGH}"}""", body)
|
||||
assertFalse(body.contains("ABCDEFGH"), "the raw code must never reach the gateway")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveMapsNotFoundAndRateLimit() =
|
||||
runBlocking {
|
||||
gateway.enqueue(MockResponse().setResponseCode(404))
|
||||
gateway.enqueue(MockResponse().setResponseCode(429))
|
||||
gateway.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertEquals(AiturkPairError.CODE_NOT_FOUND, client().resolve("ABCDEFGH").error())
|
||||
assertEquals(AiturkPairError.RATE_LIMITED, client().resolve("ABCDEFGH").error())
|
||||
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveRejectsOffersThatPointOutsideThePrivateNetwork() =
|
||||
runBlocking {
|
||||
val fp = "A".repeat(43)
|
||||
listOf(
|
||||
"""{"urls":["https://8.8.8.8:47321"],"fp":"$fp"}""",
|
||||
"""{"urls":["http://192.168.1.20:47321"],"fp":"$fp"}""",
|
||||
"""{"urls":["https://evil.example:443"],"fp":"$fp"}""",
|
||||
"""{"urls":[],"fp":"$fp"}""",
|
||||
"""{"urls":["https://192.168.1.1:1","https://192.168.1.2:1","https://192.168.1.3:1","https://192.168.1.4:1","https://192.168.1.5:1"],"fp":"$fp"}""",
|
||||
"""{"urls":["https://192.168.1.20:47321"],"fp":"short"}""",
|
||||
"""{"urls":["https://192.168.1.20:47321"]}""",
|
||||
"""not json""",
|
||||
).forEach { body ->
|
||||
gateway.enqueue(FakeIde.json(200, body))
|
||||
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error(), body)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveReportsAnUnreachableGateway() =
|
||||
runBlocking {
|
||||
gateway.shutdown()
|
||||
|
||||
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
|
||||
}
|
||||
|
||||
// ── hello + claim (computer) ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun firstReachableCandidateIsClaimedExactlyOnce() =
|
||||
runBlocking {
|
||||
val good = ide()
|
||||
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
|
||||
val offer = AiturkPairingOffer(listOf(dead, good.endpoint), good.fingerprint)
|
||||
|
||||
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
|
||||
|
||||
val device = (result as AiturkPairResult.Success).value
|
||||
assertEquals(good.endpoint, device.endpoint)
|
||||
assertEquals(good.fingerprint, device.fingerprint)
|
||||
assertEquals(FakeIde.TOKEN, device.token)
|
||||
assertEquals("MASTE-PC", device.pcName)
|
||||
assertEquals("7c0e8a52-0000-4000-8000-000000000001", device.deviceId)
|
||||
assertEquals(1, good.helloCount)
|
||||
assertEquals(1, good.claimCount)
|
||||
assertEquals("""{"code":"ABCDEFGH","device_name":"Pixel 9"}""", good.claimBodies.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyOneClaimIsSentWhenSeveralCandidatesAnswer() =
|
||||
runBlocking {
|
||||
val certificate = FakeIde.selfSigned()
|
||||
val first = FakeIde(certificate).start().also { ides += it }
|
||||
val second = FakeIde(certificate).start().also { ides += it }
|
||||
val offer = AiturkPairingOffer(listOf(first.endpoint, second.endpoint), first.fingerprint)
|
||||
|
||||
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
|
||||
|
||||
assertTrue(result is AiturkPairResult.Success)
|
||||
assertEquals(1, first.claimCount + second.claimCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimErrorsAreMappedByNameAndNeverRetried() =
|
||||
runBlocking {
|
||||
val cases =
|
||||
listOf(
|
||||
Triple(401, "wrong_code", AiturkPairError.WRONG_CODE),
|
||||
Triple(410, "pairing_closed", AiturkPairError.PAIRING_CLOSED),
|
||||
Triple(410, "pairing_locked", AiturkPairError.PAIRING_LOCKED),
|
||||
Triple(400, "invalid_request", AiturkPairError.INVALID_REQUEST),
|
||||
)
|
||||
cases.forEach { (status, name, expected) ->
|
||||
val server = ide { claim = FakeIde.json(status, """{"error":"$name"}""") }
|
||||
|
||||
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(expected, result.error(), name)
|
||||
assertEquals(1, server.claimCount, "$name must not be retried")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimErrorsFallBackToTheStatusCodeWithoutABody() {
|
||||
assertEquals(AiturkPairError.WRONG_CODE, AiturkPairingClient.claimError(401, null))
|
||||
assertEquals(AiturkPairError.PAIRING_CLOSED, AiturkPairingClient.claimError(410, null))
|
||||
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(413, null))
|
||||
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(400, "something_new"))
|
||||
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, AiturkPairingClient.claimError(500, null))
|
||||
// The contract name wins over the status.
|
||||
assertEquals(AiturkPairError.PAIRING_LOCKED, AiturkPairingClient.claimError(410, "pairing_locked"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun claimThatEndsWithoutAUsableAnswerFailsAfterASingleAttempt() =
|
||||
runBlocking {
|
||||
val server = ide { claim = MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) }
|
||||
|
||||
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error())
|
||||
assertEquals(1, server.claimCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pairingThatIsNotOpenIsReportedWithoutSpendingAClaim() =
|
||||
runBlocking {
|
||||
val server = ide { hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":false}""") }
|
||||
|
||||
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(AiturkPairError.PAIRING_CLOSED, result.error())
|
||||
assertEquals(0, server.claimCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverWithAnotherCertificateIsRejectedBeforeAnyRequest() =
|
||||
runBlocking {
|
||||
val impostor = ide()
|
||||
val expected = FakeIde.fingerprintOf(FakeIde.selfSigned())
|
||||
|
||||
val result = client().pair(AiturkPairingOffer(listOf(impostor.endpoint), expected), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
|
||||
// The TLS handshake fails, so neither hello nor the code ever reaches it.
|
||||
assertTrue(impostor.paths.isEmpty(), "requests seen: ${impostor.paths}")
|
||||
assertTrue(impostor.claimBodies.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun somethingThatIsNotTheIdeIsNotClaimed() =
|
||||
runBlocking {
|
||||
val other = ide { hello = FakeIde.json(200, """{"app":"something-else"}""") }
|
||||
val notFound = ide { hello = FakeIde.json(404, """{"error":"not_found"}""") }
|
||||
val fp = other.fingerprint
|
||||
|
||||
assertEquals(
|
||||
AiturkPairError.PC_UNREACHABLE,
|
||||
client().pair(AiturkPairingOffer(listOf(other.endpoint), fp), "ABCDEFGH", "x").error(),
|
||||
)
|
||||
assertEquals(
|
||||
AiturkPairError.PC_UNREACHABLE,
|
||||
client().pair(AiturkPairingOffer(listOf(notFound.endpoint), notFound.fingerprint), "ABCDEFGH", "x").error(),
|
||||
)
|
||||
assertEquals(0, other.claimCount + notFound.claimCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun silentCandidateTimesOut() =
|
||||
runBlocking {
|
||||
val silent = ide { hello = MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE) }
|
||||
val startedAt = System.nanoTime()
|
||||
|
||||
val result =
|
||||
client(helloTimeoutMs = 400L)
|
||||
.pair(AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
|
||||
assertTrue((System.nanoTime() - startedAt) / 1_000_000 < 5_000, "hello must give up quickly")
|
||||
assertEquals(0, silent.claimCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedTokenIsNotAccepted() =
|
||||
runBlocking {
|
||||
listOf("amd_short", "xyz_" + "t".repeat(43), "amd_" + "t".repeat(42) + "!").forEach { token ->
|
||||
val server = ide { claim = FakeIde.json(200, FakeIde.claimBody(token = token)) }
|
||||
|
||||
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||
|
||||
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error(), token)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceNameIsTrimmedToTheContractLimit() {
|
||||
assertEquals("Android", AiturkPairingClient.sanitizeDeviceName(" "))
|
||||
assertEquals(60, AiturkPairingClient.sanitizeDeviceName("x".repeat(200)).length)
|
||||
assertEquals("Galaxy S24", AiturkPairingClient.sanitizeDeviceName(" Galaxy\u0000 S24\n"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class AiturkPairingPayloadTest {
|
||||
private val fp = "A".repeat(43)
|
||||
|
||||
private fun link(
|
||||
v: String? = "1",
|
||||
us: List<String> = listOf("192.168.1.20:47321"),
|
||||
fingerprint: String? = fp,
|
||||
code: String? = "ABCDEFGH",
|
||||
): String =
|
||||
buildList {
|
||||
v?.let { add("v=$it") }
|
||||
us.forEach { add("u=$it") }
|
||||
fingerprint?.let { add("fp=$it") }
|
||||
code?.let { add("c=$it") }
|
||||
}.joinToString("&", prefix = "aiturk://pair?")
|
||||
|
||||
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
|
||||
|
||||
@Test
|
||||
fun validPayloadWithSeveralAddressesIsParsedInOrder() {
|
||||
val result =
|
||||
AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322", "172.16.4.9:47321")))
|
||||
|
||||
val payload = (result as AiturkPayloadResult.Ok).payload
|
||||
assertEquals(
|
||||
listOf(
|
||||
AiturkEndpoint("192.168.1.20", 47321),
|
||||
AiturkEndpoint("10.0.0.5", 47322),
|
||||
AiturkEndpoint("172.16.4.9", 47321),
|
||||
),
|
||||
payload.offer.endpoints,
|
||||
)
|
||||
assertEquals(fp, payload.offer.fingerprint)
|
||||
assertEquals("ABCDEFGH", payload.code)
|
||||
assertEquals("https://192.168.1.20:47321/", payload.offer.endpoints.first().baseUrl)
|
||||
assertEquals("192.168.1.20:47321", payload.offer.endpoints.first().hostPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionMismatchAsksForAnAppUpdate() {
|
||||
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "2")))
|
||||
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "01")))
|
||||
// A newer QR is reported as such even when its other fields are unknown to us.
|
||||
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf("aiturk://pair?v=2&x=1"))
|
||||
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link(v = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrBrokenFingerprintIsRejected() {
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = null)))
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42))))
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(44))))
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "=")))
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "*")))
|
||||
// Standard base64 alphabet ('+', '/') is not base64url.
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "%2B")))
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "/")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPrivateIpv4AddressesAreAccepted() {
|
||||
listOf(
|
||||
"8.8.8.8:47321",
|
||||
"172.15.0.1:47321",
|
||||
"172.32.0.1:47321",
|
||||
"192.169.1.1:47321",
|
||||
"127.0.0.1:47321",
|
||||
"169.254.1.1:47321",
|
||||
"100.64.0.1:47321",
|
||||
"example.com:47321",
|
||||
"192.168.1.20",
|
||||
"192.168.1.20:0",
|
||||
"192.168.1.20:65536",
|
||||
"192.168.1.256:47321",
|
||||
"192.168.01.20:47321",
|
||||
"[fe80::1]:47321",
|
||||
"192.168.1.20:47321/path",
|
||||
"user@192.168.1.20:47321",
|
||||
).forEach { address ->
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf(address))), address)
|
||||
}
|
||||
// One bad entry spoils the whole payload.
|
||||
assertEquals(
|
||||
AiturkPayloadError.BAD_ADDRESS,
|
||||
errorOf(link(us = listOf("192.168.1.20:47321", "8.8.8.8:47321"))),
|
||||
)
|
||||
listOf("10.255.255.255:1", "172.16.0.1:65535", "172.31.255.254:47321", "192.168.0.1:47340").forEach {
|
||||
assertTrue(AiturkPairing.parse(link(us = listOf(it))) is AiturkPayloadResult.Ok, it)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressCountMustBeOneToFour() {
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = emptyList())))
|
||||
val five = (1..5).map { "192.168.1.$it:47321" }
|
||||
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = five)))
|
||||
assertTrue(AiturkPairing.parse(link(us = five.take(4))) is AiturkPayloadResult.Ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun codeOutsideTheAlphabetIsRejected() {
|
||||
// I, O, 0 and 1 are not in the alphabet.
|
||||
listOf("ABCDEFGI", "ABCDEFGO", "ABCDEFG0", "ABCDEFG1", "ABCDEFG", "ABCDEFGHJ", "ABCD_FGH", "").forEach {
|
||||
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = it)), it)
|
||||
}
|
||||
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherSchemesAndHostsAreNotPairingLinks() {
|
||||
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("https://pair?v=1"))
|
||||
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("aiturk://other?v=1"))
|
||||
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("just some text"))
|
||||
assertFalse(AiturkPairing.looksLikePairingLink("https://example.com"))
|
||||
assertFalse(AiturkPairing.looksLikePairingLink(null))
|
||||
assertTrue(AiturkPairing.looksLikePairingLink(link()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicatedScalarFieldsAreRejected() {
|
||||
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link() + "&fp=" + "B".repeat(43)))
|
||||
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link() + "&c=HJKLMNPQ"))
|
||||
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&v=1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun codeIsNormalizedFromLowerCaseSpacesAndDashes() {
|
||||
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("abcdefgh"))
|
||||
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("ABCD-EFGH"))
|
||||
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode(" abcd efgh \n"))
|
||||
assertEquals("ABCD2345", AiturkPairing.normalizeCode("ab-cd 23-45"))
|
||||
assertNull(AiturkPairing.normalizeCode("ABCDEFG"))
|
||||
assertNull(AiturkPairing.normalizeCode("ABCDEFGHJ"))
|
||||
assertNull(AiturkPairing.normalizeCode("ABCDEFG0"))
|
||||
assertNull(AiturkPairing.normalizeCode("ABCD_EFGH"))
|
||||
assertEquals("ABCDEFGH", (AiturkPairing.parse(link(code = "abcd-efgh")) as AiturkPayloadResult.Ok).payload.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizationIgnoresTheDeviceLocale() {
|
||||
val previous = Locale.getDefault()
|
||||
try {
|
||||
// Turkish upper-cases 'i' to a dotted capital that is not in the alphabet.
|
||||
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
|
||||
assertEquals("QJKLMNPH", AiturkPairing.normalizeCode("qjklmnph"))
|
||||
assertNull(AiturkPairing.normalizeCode("abcdefgi"))
|
||||
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("abcd-efgh-i"))
|
||||
} finally {
|
||||
Locale.setDefault(previous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun textFieldInputKeepsOnlyAlphabetCharactersUpToEight() {
|
||||
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("ab cd-ef01ghjk"))
|
||||
assertEquals("AB", AiturkPairing.cleanCodeInput("a-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lookupIsTheHexSha256OfThePrefixedCode() {
|
||||
val computed =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest("aiturk-mobile-pair-v1:ABCDEFGH".toByteArray())
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
assertEquals(computed, AiturkPairing.lookup("ABCDEFGH"))
|
||||
assertEquals(LOOKUP_ABCDEFGH, AiturkPairing.lookup("ABCDEFGH"))
|
||||
assertEquals(64, AiturkPairing.lookup("ABCDEFGH").length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayUrlsMustBeHttpsPrivateAddresses() {
|
||||
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321"))
|
||||
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/"))
|
||||
assertNull(AiturkPairing.parseEndpointUrl("http://192.168.1.20:47321"))
|
||||
assertNull(AiturkPairing.parseEndpointUrl("https://8.8.8.8:47321"))
|
||||
assertNull(AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/x"))
|
||||
assertNull(AiturkPairing.parseEndpointUrl("https://evil.example:47321"))
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** hex(sha256("aiturk-mobile-pair-v1:ABCDEFGH")), computed independently (Python hashlib). */
|
||||
const val LOOKUP_ABCDEFGH = "10c03d82b47dff47053d4a829d4615e63da1739e2d7fb418f9d4ee8cc6b4c38a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okhttp3.tls.HandshakeCertificates
|
||||
import okhttp3.tls.HeldCertificate
|
||||
import java.net.InetAddress
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/** A local stand-in for the IDE's LAN proxy: TLS with a self-signed certificate. */
|
||||
internal class FakeIde(
|
||||
val certificate: HeldCertificate = selfSigned(),
|
||||
) {
|
||||
val server = MockWebServer()
|
||||
val paths = CopyOnWriteArrayList<String>()
|
||||
val claimBodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
@Volatile
|
||||
var hello: MockResponse = json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":true}""")
|
||||
|
||||
@Volatile
|
||||
var claim: MockResponse = json(200, claimBody())
|
||||
|
||||
val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port)
|
||||
val fingerprint: String get() = fingerprintOf(certificate)
|
||||
val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" }
|
||||
val helloCount: Int get() = paths.count { it == "/aiturk/pair/hello" }
|
||||
|
||||
fun start(): FakeIde {
|
||||
val handshake = HandshakeCertificates.Builder().heldCertificate(certificate).build()
|
||||
server.useHttps(handshake.sslSocketFactory(), false)
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path.orEmpty()
|
||||
paths += path
|
||||
return when (path) {
|
||||
"/aiturk/pair/hello" -> {
|
||||
hello
|
||||
}
|
||||
|
||||
"/aiturk/pair/claim" -> {
|
||||
claimBodies += request.body.readUtf8()
|
||||
claim
|
||||
}
|
||||
|
||||
else -> {
|
||||
json(404, """{"error":"not_found"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start(InetAddress.getByName(LOOPBACK), 0)
|
||||
return this
|
||||
}
|
||||
|
||||
fun stop() = server.shutdown()
|
||||
|
||||
companion object {
|
||||
const val LOOPBACK = "127.0.0.1"
|
||||
val TOKEN = "amd_" + "t".repeat(43)
|
||||
|
||||
fun selfSigned(): HeldCertificate =
|
||||
HeldCertificate
|
||||
.Builder()
|
||||
.commonName("aiturk-ide")
|
||||
.addSubjectAlternativeName(LOOPBACK)
|
||||
.build()
|
||||
|
||||
fun fingerprintOf(certificate: HeldCertificate): String =
|
||||
Base64
|
||||
.getUrlEncoder()
|
||||
.withoutPadding()
|
||||
.encodeToString(MessageDigest.getInstance("SHA-256").digest(certificate.certificate.encoded))
|
||||
|
||||
fun json(
|
||||
code: Int,
|
||||
body: String,
|
||||
): MockResponse =
|
||||
MockResponse()
|
||||
.setResponseCode(code)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
|
||||
fun claimBody(
|
||||
token: String = TOKEN,
|
||||
pcName: String = "MASTE-PC",
|
||||
): String = """{"token":"$token","device_id":"7c0e8a52-0000-4000-8000-000000000001","pc_name":"$pcName","version":"1.2.3"}"""
|
||||
}
|
||||
}
|
||||
|
||||
internal class MemoryPins(
|
||||
val stored: MutableMap<String, String> = mutableMapOf(),
|
||||
) : AiturkLanTrust.PinPersistence {
|
||||
override fun load(): Map<String, String> = stored.toMap()
|
||||
|
||||
override fun save(pins: Map<String, String>) {
|
||||
stored.clear()
|
||||
stored.putAll(pins)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Token mode refreshes the dashboard token with `GET <baseUrl>/` before every
|
||||
* WebSocket handshake and on REST 401. An AITURK-paired IDE answers 404 there
|
||||
* and its device token is long-lived, so the refresh must not run — and the
|
||||
* stored `amd_…` token must never be replaced.
|
||||
*/
|
||||
class AiturkTokenRefreshSkipTest {
|
||||
private val pairedUrl = "https://192.168.1.20:47321/"
|
||||
private val calls = AtomicInteger()
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
AiturkLanTrust.resetForTest(MemoryPins())
|
||||
mockkObject(AuthManager)
|
||||
mockkObject(OkHttpProvider)
|
||||
every { AuthManager.setToken(any()) } returns Unit
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
AiturkLanTrust.resetForTest(MemoryPins())
|
||||
}
|
||||
|
||||
private fun countingClient(): OkHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(
|
||||
Interceptor {
|
||||
calls.incrementAndGet()
|
||||
throw IOException("no network in this test")
|
||||
},
|
||||
).build()
|
||||
|
||||
@Test
|
||||
fun pairedConnectionNeverAsksTheDashboardForAToken() =
|
||||
runBlocking {
|
||||
AiturkLanTrust.pin("192.168.1.20:47321", "A".repeat(43))
|
||||
every { AuthManager.baseUrl() } returns pairedUrl
|
||||
every { OkHttpProvider.probe } returns countingClient()
|
||||
|
||||
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||
assertNull(DashboardSessionTokenRefresher.refresh())
|
||||
|
||||
assertEquals(0, calls.get())
|
||||
verify(exactly = 0) { AuthManager.setToken(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unpairedConnectionStillRefreshesAsBefore() =
|
||||
runBlocking {
|
||||
every { AuthManager.baseUrl() } returns pairedUrl
|
||||
every { OkHttpProvider.probe } returns countingClient()
|
||||
|
||||
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||
|
||||
assertEquals(1, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notFoundOnTheRootPathLeavesTheStoredTokenAlone() =
|
||||
runBlocking {
|
||||
// What the IDE proxy answers on `/`, seen by a connection that is not marked as paired.
|
||||
val server = MockWebServer()
|
||||
server.start()
|
||||
try {
|
||||
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
|
||||
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
|
||||
every { AuthManager.baseUrl() } returns server.url("/").toString()
|
||||
every { OkHttpProvider.probe } returns OkHttpClient()
|
||||
|
||||
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||
assertNull(DashboardSessionTokenRefresher.refresh())
|
||||
|
||||
assertEquals(2, server.requestCount)
|
||||
verify(exactly = 0) { AuthManager.setToken(any()) }
|
||||
} finally {
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for SessionMessage multipart content and display_content text extraction.
|
||||
*
|
||||
* Contract requirements (Issue #1432):
|
||||
* - [SessionMessage.contentText] and [SessionMessage.displayContentText] extract recognized text parts
|
||||
* of multipart JsonArray in order.
|
||||
* - Recognized text parts: {"type": "text", "text": "..."} or {"text": "..."}.
|
||||
* - Omit "image_url", inline base64 ("data:image/..."), and unknown/malformed parts rather than JSON dumping.
|
||||
* - Null content -> empty string ("").
|
||||
* - Absent display_content (null) -> null.
|
||||
* - Explicit empty string display wins ("" -> "").
|
||||
* - Plain string primitives are retained unchanged (including very long plain text).
|
||||
* - Avoid imposing changes on unknown object tool content (non-array objects dump or retain their object representation).
|
||||
*/
|
||||
class SessionMessageMultipartTest {
|
||||
@Test
|
||||
fun contentText_whenContentIsNull_returnsEmptyString() {
|
||||
val message = SessionMessage(content = null)
|
||||
assertEquals("", message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayContentText_whenDisplayContentIsNull_returnsNull() {
|
||||
val message = SessionMessage(display_content = null)
|
||||
assertNull(message.displayContentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayContentText_whenDisplayContentIsExplicitEmptyString_returnsEmptyString() {
|
||||
val message = SessionMessage(display_content = JsonPrimitive(""))
|
||||
assertEquals("", message.displayContentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenContentIsPrimitiveString_retainsUnchanged() {
|
||||
val text = "Hello world, this is a plain message turn."
|
||||
val message = SessionMessage(content = JsonPrimitive(text))
|
||||
assertEquals(text, message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenContentIsLongPlainText_retainsEntireStringUnchanged() {
|
||||
val longText = "A".repeat(100_000)
|
||||
val message = SessionMessage(content = JsonPrimitive(longText))
|
||||
assertEquals(longText, message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenMultipartWith748kBase64AndImageCaption_extractsCaptionOnly() {
|
||||
val largeBase64 = "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAA".repeat(31_000) // ~748k chars
|
||||
val caption = "Check out this screenshot\n@image:/opt/data/images/screen.png"
|
||||
|
||||
val multipart =
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", largeBase64)
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", caption)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val message = SessionMessage(content = multipart)
|
||||
assertEquals(caption, message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenMultipleTextPartsInArray_extractsInOrder() {
|
||||
val multipart =
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "First paragraph.")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "https://example.com/photo.png")
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Second paragraph.")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
// Shorthand text block without explicit type
|
||||
put("text", "Third paragraph.")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val message = SessionMessage(content = multipart)
|
||||
val expected = "First paragraph.\nSecond paragraph.\nThird paragraph."
|
||||
assertEquals(expected, message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenMultipartContainsMalformedOrUnknownElements_omitsThemWithoutJsonDumping() {
|
||||
val multipart =
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive(12345))
|
||||
add(JsonPrimitive(true))
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "unknown_blob")
|
||||
put("raw", "skip_me")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("invalid_key", "no text property here")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Valid extracted text.")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image")
|
||||
put("source", "base64data")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val message = SessionMessage(content = multipart)
|
||||
assertEquals("Valid extracted text.", message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenMultipartHasNoTextParts_returnsEmptyString() {
|
||||
val multipart =
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "https://example.com/icon.png")
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "audio")
|
||||
put("data", "abc123audio")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val message = SessionMessage(content = multipart)
|
||||
assertEquals("", message.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun displayContentText_whenProjectedDisplayContentIsMultipartArray_extractsTextParts() {
|
||||
val projectedDisplayArray =
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Summary of conversation")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
put("url", "data:image/jpeg;base64,12345")
|
||||
}
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "text")
|
||||
put("text", "Key takeaways follow.")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val message =
|
||||
SessionMessage(
|
||||
content = JsonPrimitive("raw fallback content"),
|
||||
display_content = projectedDisplayArray,
|
||||
)
|
||||
|
||||
assertEquals("Summary of conversation\nKey takeaways follow.", message.displayContentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentText_whenContentIsJsonObjectToolCallOrResult_doesNotImposeMultipartChanges() {
|
||||
val toolObject =
|
||||
buildJsonObject {
|
||||
put("status", "success")
|
||||
put("output", "tool execution output")
|
||||
}
|
||||
|
||||
val message = SessionMessage(content = toolObject)
|
||||
// Ensure non-array JsonObject continues existing behavior (e.g. toString/not mangled as multipart array)
|
||||
assertEquals(toolObject.toString(), message.contentText)
|
||||
}
|
||||
}
|
||||
@@ -71,14 +71,20 @@ class GatewayContractFramesTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
try {
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
||||
@@ -91,7 +97,7 @@ class GatewayContractFramesTest {
|
||||
val captured = CountDownLatch(1)
|
||||
val frame = AtomicReference<JsonObject?>(null)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
|
||||
@@ -105,7 +105,7 @@ class GatewayContractTest {
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.create" to setOf("profile", "title", "source", "hidden"),
|
||||
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
||||
"session.resume" to setOf("session_id", "omit_messages"),
|
||||
"session.resume" to setOf("session_id", "source", "omit_messages"),
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.interrupt" to setOf("session_id"),
|
||||
)
|
||||
|
||||
+17
-11
@@ -76,15 +76,21 @@ class HermesWsClientSessionControlTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
try {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
@@ -101,7 +107,7 @@ class HermesWsClientSessionControlTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -163,7 +169,7 @@ class HermesWsClientSessionControlTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -224,7 +230,7 @@ class HermesWsClientSessionControlTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
|
||||
@@ -104,18 +104,17 @@ class HermesWsClientTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer
|
||||
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
|
||||
Thread.sleep(500)
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
} finally {
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -126,7 +125,7 @@ class HermesWsClientTest {
|
||||
var receivedMessage: String? = null
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -171,7 +170,7 @@ class HermesWsClientTest {
|
||||
var capabilityFrame: String? = null
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -221,7 +220,7 @@ class HermesWsClientTest {
|
||||
val capabilityResponseCount = AtomicInteger(0)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -301,7 +300,7 @@ class HermesWsClientTest {
|
||||
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
|
||||
runBlocking {
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -759,7 +758,7 @@ class HermesWsClientTest {
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||
HermesWsClient.webSocketForTest = oldSocket
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
|
||||
HermesWsClient.connect()
|
||||
|
||||
@@ -789,7 +788,7 @@ class HermesWsClientTest {
|
||||
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val replacementConnect = thread { HermesWsClient.connect() }
|
||||
val generationDeadline = System.currentTimeMillis() + 5_000
|
||||
@@ -821,7 +820,7 @@ class HermesWsClientTest {
|
||||
val serverLatch = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -862,7 +861,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testBackgroundWithoutPendingWorkDisconnects() {
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
HermesWsClient.connect()
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
@@ -874,7 +873,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
HermesWsClient.connect()
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
@@ -891,7 +890,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() {
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
HermesWsClient.connect()
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
@@ -908,7 +907,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testBackgroundWithPendingReplyStaysConnected() {
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
HermesWsClient.connect()
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
HermesWsClient.sendMessage("session-1", "hello")
|
||||
@@ -923,7 +922,7 @@ class HermesWsClientTest {
|
||||
lateinit var serverSocket: WebSocket
|
||||
val connectedLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -958,7 +957,7 @@ class HermesWsClientTest {
|
||||
fun testBackgroundQueuedSendDisconnectsAfterFlush() {
|
||||
val messageLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
@@ -987,7 +986,7 @@ class HermesWsClientTest {
|
||||
lateinit var serverSocket: WebSocket
|
||||
val connectedLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1033,7 +1032,7 @@ class HermesWsClientTest {
|
||||
val closedLatch = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1073,7 +1072,7 @@ class HermesWsClientTest {
|
||||
var receivedMessage: String? = null
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1128,7 +1127,7 @@ class HermesWsClientTest {
|
||||
val connect2Latch = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1142,7 +1141,7 @@ class HermesWsClientTest {
|
||||
)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1206,7 +1205,7 @@ class HermesWsClientTest {
|
||||
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1235,7 +1234,7 @@ class HermesWsClientTest {
|
||||
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1265,7 +1264,7 @@ class HermesWsClientTest {
|
||||
fun testDoubleConnect_ignoresSecondCallWhenConnected() {
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1291,7 +1290,7 @@ class HermesWsClientTest {
|
||||
fun testStatusTransitionOnConnect() {
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1328,7 +1327,7 @@ class HermesWsClientTest {
|
||||
|
||||
val connectLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1342,7 +1341,7 @@ class HermesWsClientTest {
|
||||
|
||||
// Enqueue a second response for reconnect attempt
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1400,7 +1399,7 @@ class HermesWsClientTest {
|
||||
|
||||
val connectLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
@@ -1537,7 +1536,7 @@ class HermesWsClientTest {
|
||||
var serverWebSocket: WebSocket? = null
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1589,7 +1588,7 @@ class HermesWsClientTest {
|
||||
var serverWebSocket: WebSocket? = null
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1664,7 +1663,7 @@ class HermesWsClientTest {
|
||||
var serverWebSocket: WebSocket? = null
|
||||
val serverLatch = CountDownLatch(1)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1719,7 +1718,7 @@ class HermesWsClientTest {
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1780,7 +1779,7 @@ class HermesWsClientTest {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||
// Directly install the reflection-built listener so onMessage runs even
|
||||
// Directly install the transport listener so onMessage runs even
|
||||
// though this test never performed a real OkHttp connect.
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
mockkObject(EventParser)
|
||||
@@ -1826,7 +1825,7 @@ class HermesWsClientTest {
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1888,7 +1887,7 @@ class HermesWsClientTest {
|
||||
var pingReceived = false
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -1971,7 +1970,7 @@ class HermesWsClientTest {
|
||||
val serverLatch = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
|
||||
@@ -103,15 +103,21 @@ class HermesWsClientTypedCallTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
try {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
@@ -128,7 +134,7 @@ class HermesWsClientTypedCallTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -190,7 +196,7 @@ class HermesWsClientTypedCallTest {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -233,7 +239,7 @@ class HermesWsClientTypedCallTest {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -281,7 +287,7 @@ class HermesWsClientTypedCallTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -332,7 +338,7 @@ class HermesWsClientTypedCallTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -382,7 +388,7 @@ class HermesWsClientTypedCallTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -429,7 +435,7 @@ class HermesWsClientTypedCallTest {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -475,7 +481,7 @@ class HermesWsClientTypedCallTest {
|
||||
val requestReceived = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
|
||||
@@ -77,15 +77,21 @@ class HermesWsClientTypedSendTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
try {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
@@ -102,7 +108,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -163,7 +169,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -216,7 +222,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -274,7 +280,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -325,7 +331,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -382,7 +388,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
@@ -437,7 +443,7 @@ class HermesWsClientTypedSendTest {
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okio.ByteString
|
||||
|
||||
/**
|
||||
* Keep the test listener's behavior, but acknowledge peer close frames so MockWebServer's
|
||||
* WebSocket task can finish. WebSocketListener's default onClosing is a no-op.
|
||||
*/
|
||||
internal fun MockResponse.withClosingWebSocketUpgrade(delegate: WebSocketListener): MockResponse =
|
||||
withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = delegate.onOpen(webSocket, response)
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) = delegate.onMessage(webSocket, text)
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) = delegate.onMessage(webSocket, bytes)
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
try {
|
||||
delegate.onClosing(webSocket, code, reason)
|
||||
} finally {
|
||||
// The delegate may already have initiated a close; a false return is valid.
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) = delegate.onClosed(webSocket, code, reason)
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) = delegate.onFailure(webSocket, t, response)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verifySequence
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class MockWebSocketCleanupListenerTest {
|
||||
private val socket = mockk<WebSocket>()
|
||||
private val delegate = mockk<WebSocketListener>(relaxed = true)
|
||||
private val listener: WebSocketListener
|
||||
get() = requireNotNull(MockResponse().withClosingWebSocketUpgrade(delegate).webSocketListener)
|
||||
|
||||
@Test
|
||||
fun acknowledgesCloseAfterDelegatedCallback() {
|
||||
every { socket.close(1000, "complete") } returns true
|
||||
|
||||
listener.onClosing(socket, 1000, "complete")
|
||||
|
||||
verifySequence {
|
||||
delegate.onClosing(socket, 1000, "complete")
|
||||
socket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acknowledgesCloseEvenWhenDelegatedCallbackThrows() {
|
||||
val failure = IllegalStateException("callback failure")
|
||||
every { delegate.onClosing(socket, 1000, "complete") } throws failure
|
||||
every { socket.close(1000, "complete") } returns true
|
||||
|
||||
val thrown =
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
listener.onClosing(socket, 1000, "complete")
|
||||
}
|
||||
|
||||
assertSame(failure, thrown)
|
||||
verifySequence {
|
||||
delegate.onClosing(socket, 1000, "complete")
|
||||
socket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsAlreadyInitiatedCustomCloseWithoutOverridingIt() {
|
||||
every { socket.close(1001, "custom close") } returns true
|
||||
every { socket.close(1000, "complete") } returns false
|
||||
every { delegate.onClosing(socket, 1000, "complete") } answers {
|
||||
socket.close(1001, "custom close")
|
||||
Unit
|
||||
}
|
||||
|
||||
listener.onClosing(socket, 1000, "complete")
|
||||
|
||||
verifySequence {
|
||||
delegate.onClosing(socket, 1000, "complete")
|
||||
socket.close(1001, "custom close")
|
||||
socket.close(1000, "complete")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forwardsOpenWithOriginalSocketAndResponse() {
|
||||
val response = mockk<Response>()
|
||||
|
||||
listener.onOpen(socket, response)
|
||||
|
||||
verifySequence { delegate.onOpen(socket, response) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forwardsBothTextAndBinaryMessages() {
|
||||
val bytes = "binary message".encodeUtf8()
|
||||
val wrapped = listener
|
||||
|
||||
wrapped.onMessage(socket, "text message")
|
||||
wrapped.onMessage(socket, bytes)
|
||||
|
||||
verifySequence {
|
||||
delegate.onMessage(socket, "text message")
|
||||
delegate.onMessage(socket, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forwardsClosedWithOriginalCodeAndReason() {
|
||||
listener.onClosed(socket, 1001, "server close")
|
||||
|
||||
verifySequence { delegate.onClosed(socket, 1001, "server close") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forwardsFailureWithOriginalThrowableAndNullableResponse() {
|
||||
val failure = IllegalStateException("transport failure")
|
||||
val response = mockk<Response>()
|
||||
val wrapped = listener
|
||||
|
||||
wrapped.onFailure(socket, failure, response)
|
||||
wrapped.onFailure(socket, failure, null)
|
||||
|
||||
verifySequence {
|
||||
delegate.onFailure(socket, failure, response)
|
||||
delegate.onFailure(socket, failure, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class MockWebSocketCleanupTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.isLoggable(any(), any()) } returns false
|
||||
every { Log.println(any(), any(), any()) } returns 0
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peerCloseAllowsMockServerShutdownWithoutLeavingRunningTasks() {
|
||||
val server = MockWebServer()
|
||||
val client = OkHttpClient()
|
||||
val serverSocket = AtomicReference<WebSocket>()
|
||||
val opened = CountDownLatch(1)
|
||||
val closing = CountDownLatch(1)
|
||||
val closed = CountDownLatch(1)
|
||||
server.enqueue(
|
||||
MockResponse().withClosingWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
serverSocket.set(webSocket)
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
closing.countDown()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
server.start()
|
||||
val socket =
|
||||
client.newWebSocket(
|
||||
Request.Builder().url(server.url("/")).build(),
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
opened.countDown()
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
closed.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
assertTrue("The WebSocket must open", opened.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(socket.close(1000, "test complete"))
|
||||
assertTrue("The mock peer must observe the close", closing.await(5, TimeUnit.SECONDS))
|
||||
// Complete the handshake before shutdown stops the server's task runner.
|
||||
assertTrue("The close handshake must complete", closed.await(5, TimeUnit.SECONDS))
|
||||
// No fixed sleep or swallowed shutdown exception: an active server queue fails this regression.
|
||||
server.shutdown()
|
||||
} finally {
|
||||
socket.cancel()
|
||||
// MockWebServer's accepted socket has no outbound Call, so cancel() is not supported.
|
||||
serverSocket.get()?.close(1000, "cleanup")
|
||||
try {
|
||||
server.shutdown()
|
||||
} finally {
|
||||
client.dispatcher.executorService.shutdown()
|
||||
client.connectionPool.evictAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,48 @@ class SessionPromptTypesTest {
|
||||
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWithDesktopSourceEncodesSourceKey() {
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = "s-457",
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-457")
|
||||
put("source", "desktop")
|
||||
put("omit_messages", true)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "source", "omit_messages"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeOmitsSourceWhenUnset() {
|
||||
val encoded =
|
||||
OkHttpProvider.json.encodeToJsonElement(
|
||||
SessionResumeParams.serializer(),
|
||||
SessionResumeParams(sessionId = "s-458"),
|
||||
)
|
||||
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertNull((encoded as JsonObject)["source"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createAndResumeDeclareTheSameDesktopSource() {
|
||||
// #1450: create passed source and resume did not, so the gateway resolved the resumed
|
||||
// runtime from its host env ("tui") and staged a bogus surface switch. Same constant,
|
||||
// so the two call sites cannot drift again.
|
||||
assertEquals("desktop", DESKTOP_SESSION_SOURCE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
|
||||
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
|
||||
|
||||
+7
-1
@@ -330,7 +330,13 @@ class NotificationReplyReceiverTest {
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_RESUME,
|
||||
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
|
||||
match {
|
||||
it.str("session_id") == "session-abc" &&
|
||||
it["omit_messages"] == JsonPrimitive(true) &&
|
||||
// #1450: source must match session.create, or the gateway resolves the
|
||||
// resumed runtime from its host env and stages a bogus surface switch.
|
||||
it["source"] == JsonPrimitive("desktop")
|
||||
},
|
||||
any(),
|
||||
)
|
||||
HermesWsClient.request(
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression coverage for issue #1451:
|
||||
* Transcript ordering when REST confirms a newer prompt while older live rows
|
||||
* (observed live user/tool + review notices) remain unmatched/unconfirmed.
|
||||
*/
|
||||
class ChatLiveOrderingRegressionTest {
|
||||
@Test
|
||||
fun unconfirmedLiveUserAndToolWithReviewNoticesStayBeforeConfirmedNewerPrompt() {
|
||||
// Given an observed live history:
|
||||
// 1. Live user prompt (unconfirmed by REST)
|
||||
// 2. Live tool call / result (unconfirmed by REST)
|
||||
// 3. First system review notice
|
||||
// 4. Second system review notice
|
||||
// 5. Later live user prompt
|
||||
val liveUserOld =
|
||||
ChatMessage(
|
||||
id = "live-user-old",
|
||||
role = MessageRole.USER,
|
||||
content = "first task",
|
||||
timestamp = 1_000L,
|
||||
)
|
||||
val liveTool =
|
||||
ChatMessage(
|
||||
id = "live-tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = """{"result":{"output":"task done","exit_code":0}}""",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call_task_1",
|
||||
timestamp = 1_100L,
|
||||
)
|
||||
val systemReview1 =
|
||||
ChatMessage(
|
||||
id = "sys-review-1",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: skill updated",
|
||||
timestamp = 1_200L,
|
||||
)
|
||||
val systemReview2 =
|
||||
ChatMessage(
|
||||
id = "sys-review-2",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: playbook refreshed",
|
||||
timestamp = 1_300L,
|
||||
)
|
||||
val liveUserNew =
|
||||
ChatMessage(
|
||||
id = "live-user-new",
|
||||
role = MessageRole.USER,
|
||||
content = "second prompt",
|
||||
timestamp = 1_400L,
|
||||
)
|
||||
|
||||
val currentMessages =
|
||||
listOf(
|
||||
liveUserOld,
|
||||
liveTool,
|
||||
systemReview1,
|
||||
systemReview2,
|
||||
liveUserNew,
|
||||
)
|
||||
|
||||
// REST sync only returns/confirms the second prompt (e.g. rest-s-5),
|
||||
// while the older live user/tool and review notices remain unmatched in REST.
|
||||
val restConfirmedNew =
|
||||
ChatMessage(
|
||||
id = "rest-s-5",
|
||||
role = MessageRole.USER,
|
||||
content = "second prompt",
|
||||
timestamp = 1_450L,
|
||||
)
|
||||
val restMessages = listOf(restConfirmedNew)
|
||||
|
||||
// When merged with chronological = true, preserveLiveIds = false
|
||||
val merged =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = restMessages,
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
|
||||
// Then:
|
||||
// 1. All rows must be preserved.
|
||||
assertEquals(5, merged.size)
|
||||
|
||||
// 2. Ordering contract: the older live user, tool, and two system review notices
|
||||
// MUST remain before the newer confirmed prompt.
|
||||
val expectedIds =
|
||||
listOf(
|
||||
"live-user-old",
|
||||
"live-tool-1",
|
||||
"sys-review-1",
|
||||
"sys-review-2",
|
||||
"live-user-new", // Merged with rest-s-5; when role=USER, match's ID is preserved, restId recorded
|
||||
)
|
||||
assertEquals(expectedIds, merged.map { it.id })
|
||||
|
||||
// 3. The confirmed prompt acquires its canonical REST identity
|
||||
val confirmedRow = merged.last()
|
||||
assertEquals("rest-s-5", confirmedRow.canonicalRestId)
|
||||
assertEquals(MessageRole.USER, confirmedRow.role)
|
||||
assertEquals("second prompt", confirmedRow.content)
|
||||
|
||||
// 4. Older unconfirmed rows must NOT invent or acquire rest identities
|
||||
val unconfirmedRows = merged.take(4)
|
||||
unconfirmedRows.forEach { row ->
|
||||
assertNotEquals("rest-s-5", row.restId)
|
||||
assertEquals(null, row.canonicalRestId)
|
||||
}
|
||||
|
||||
// 5. Repeated merge must be idempotent
|
||||
val repeatedMerge =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = restMessages,
|
||||
currentMessages = merged,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
|
||||
assertEquals(merged, repeatedMerge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlPlainAppendAndNonConfirmationMaintainsObservedLiveOrder() {
|
||||
// Control scenario:
|
||||
// Current live transcript contains an old user prompt, tool call, two review notices,
|
||||
// and a later user prompt.
|
||||
// REST returns no confirmations (emptyList) or an unmatching server message.
|
||||
val liveUserOld =
|
||||
ChatMessage(
|
||||
id = "live-user-old",
|
||||
role = MessageRole.USER,
|
||||
content = "first task",
|
||||
timestamp = 1_000L,
|
||||
)
|
||||
val liveTool =
|
||||
ChatMessage(
|
||||
id = "live-tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = """{"result":{"output":"task done","exit_code":0}}""",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call_task_1",
|
||||
timestamp = 1_100L,
|
||||
)
|
||||
val systemReview1 =
|
||||
ChatMessage(
|
||||
id = "sys-review-1",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: note 1",
|
||||
timestamp = 1_200L,
|
||||
)
|
||||
val systemReview2 =
|
||||
ChatMessage(
|
||||
id = "sys-review-2",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: note 2",
|
||||
timestamp = 1_300L,
|
||||
)
|
||||
val liveUserNew =
|
||||
ChatMessage(
|
||||
id = "live-user-new",
|
||||
role = MessageRole.USER,
|
||||
content = "second prompt",
|
||||
timestamp = 1_400L,
|
||||
)
|
||||
|
||||
val currentMessages =
|
||||
listOf(
|
||||
liveUserOld,
|
||||
liveTool,
|
||||
systemReview1,
|
||||
systemReview2,
|
||||
liveUserNew,
|
||||
)
|
||||
|
||||
// Case A: REST sync is empty
|
||||
val mergedEmpty =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = emptyList(),
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"live-user-old",
|
||||
"live-tool-1",
|
||||
"sys-review-1",
|
||||
"sys-review-2",
|
||||
"live-user-new",
|
||||
),
|
||||
mergedEmpty.map {
|
||||
it.id
|
||||
},
|
||||
)
|
||||
|
||||
// Case B: REST returns an unrelated / earlier server message (e.g. session start rest-s-0)
|
||||
val canonicalPrior =
|
||||
ChatMessage(
|
||||
id = "rest-s-0",
|
||||
role = MessageRole.USER,
|
||||
content = "very first conversation starter",
|
||||
timestamp = 500L,
|
||||
)
|
||||
val mergedWithPrior =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = listOf(canonicalPrior),
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
|
||||
// Canonical server rows maintain server order, while observed live unconfirmed block stays intact
|
||||
assertEquals(
|
||||
listOf("rest-s-0", "live-user-old", "live-tool-1", "sys-review-1", "sys-review-2", "live-user-new"),
|
||||
mergedWithPrior.map { it.id },
|
||||
)
|
||||
|
||||
// Idempotence check on control
|
||||
val idempotentMergedWithPrior =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = listOf(canonicalPrior),
|
||||
currentMessages = mergedWithPrior,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
assertEquals(mergedWithPrior.map { it.id }, idempotentMergedWithPrior.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionStartMarkerRemainsBeforeOldUnresolvedUserToolReviewBlockWhenNewerUserConfirmsAsRestS0() {
|
||||
val sessionStart =
|
||||
ChatMessage(
|
||||
id = "system-session-start",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "Session created",
|
||||
timestamp = 100L,
|
||||
)
|
||||
val liveUserOld =
|
||||
ChatMessage(
|
||||
id = "live-user-old",
|
||||
role = MessageRole.USER,
|
||||
content = "unconfirmed first prompt",
|
||||
timestamp = 200L,
|
||||
)
|
||||
val liveTool =
|
||||
ChatMessage(
|
||||
id = "live-tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = """{"result":"done"}""",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call_1",
|
||||
timestamp = 300L,
|
||||
)
|
||||
val sysReview =
|
||||
ChatMessage(
|
||||
id = "sys-review-1",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: note",
|
||||
timestamp = 400L,
|
||||
)
|
||||
val liveUserNew =
|
||||
ChatMessage(
|
||||
id = "live-user-new",
|
||||
role = MessageRole.USER,
|
||||
content = "confirmed second prompt",
|
||||
timestamp = 500L,
|
||||
)
|
||||
|
||||
val currentMessages =
|
||||
listOf(
|
||||
sessionStart,
|
||||
liveUserOld,
|
||||
liveTool,
|
||||
sysReview,
|
||||
liveUserNew,
|
||||
)
|
||||
|
||||
// Newer observed USER confirms as canonical index 0 (rest-s-0)
|
||||
val restConfirmedNew =
|
||||
ChatMessage(
|
||||
id = "rest-s-0",
|
||||
role = MessageRole.USER,
|
||||
content = "confirmed second prompt",
|
||||
timestamp = 550L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = listOf(restConfirmedNew),
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
|
||||
val expectedIds =
|
||||
listOf(
|
||||
"system-session-start",
|
||||
"live-user-old",
|
||||
"live-tool-1",
|
||||
"sys-review-1",
|
||||
"live-user-new",
|
||||
)
|
||||
assertEquals(expectedIds, merged.map { it.id })
|
||||
assertEquals("rest-s-0", merged.last().canonicalRestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalRowsRemainServerOrderedAcrossGapsWithOldLiveBlockBeforeNextConfirmedUserAcrossSkewedTimestamps() {
|
||||
// Skewed/inverted timestamps across rows
|
||||
val canonicalPrior =
|
||||
ChatMessage(
|
||||
id = "rest-s-2",
|
||||
role = MessageRole.USER,
|
||||
content = "prior prompt",
|
||||
timestamp = 8_000L, // skewed higher timestamp than later turns
|
||||
)
|
||||
val liveUserOld =
|
||||
ChatMessage(
|
||||
id = "live-user-old",
|
||||
role = MessageRole.USER,
|
||||
content = "unresolved old prompt",
|
||||
timestamp = 2_000L,
|
||||
)
|
||||
val liveTool =
|
||||
ChatMessage(
|
||||
id = "live-tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = """{"result":"ok"}""",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call_skew",
|
||||
timestamp = 2_000L, // equal timestamp
|
||||
)
|
||||
val sysReview =
|
||||
ChatMessage(
|
||||
id = "sys-review-1",
|
||||
role = MessageRole.SYSTEM,
|
||||
content = "💾 Self-improvement review: updated",
|
||||
timestamp = 1_000L, // earlier timestamp
|
||||
)
|
||||
val liveUserNew =
|
||||
ChatMessage(
|
||||
id = "live-user-new",
|
||||
role = MessageRole.USER,
|
||||
content = "next user prompt",
|
||||
timestamp = 3_000L,
|
||||
)
|
||||
|
||||
val currentMessages =
|
||||
listOf(
|
||||
canonicalPrior,
|
||||
liveUserOld,
|
||||
liveTool,
|
||||
sysReview,
|
||||
liveUserNew,
|
||||
)
|
||||
|
||||
// Gap in canonical sequence: rest-s-2 then rest-s-9 (gap 3..8 missing/unloaded)
|
||||
val canonicalFollow =
|
||||
ChatMessage(
|
||||
id = "rest-s-9",
|
||||
role = MessageRole.USER,
|
||||
content = "next user prompt",
|
||||
timestamp = 2_500L, // skewed timestamp compared to rest-s-2
|
||||
)
|
||||
val restMessages = listOf(canonicalPrior, canonicalFollow)
|
||||
|
||||
val merged =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = restMessages,
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
|
||||
val expectedIds =
|
||||
listOf(
|
||||
"rest-s-2",
|
||||
"live-user-old",
|
||||
"live-tool-1",
|
||||
"sys-review-1",
|
||||
"live-user-new",
|
||||
)
|
||||
assertEquals(expectedIds, merged.map { it.id })
|
||||
|
||||
// Repeated merge must remain identical
|
||||
val repeatedMerge =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = restMessages,
|
||||
currentMessages = merged,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
assertEquals(merged.map { it.id }, repeatedMerge.map { it.id })
|
||||
assertEquals(merged, repeatedMerge)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantConfirmationDoesNotPullUnresolvedUserOutOfPendingTail() {
|
||||
val unresolvedUser =
|
||||
ChatMessage(
|
||||
id = "live-user-unresolved",
|
||||
role = MessageRole.USER,
|
||||
content = "prompt not matching rest",
|
||||
timestamp = 1_000L,
|
||||
)
|
||||
val liveTool =
|
||||
ChatMessage(
|
||||
id = "live-tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = """{"result":"in progress"}""",
|
||||
toolName = "terminal",
|
||||
toolCallId = "call_assistant_test",
|
||||
timestamp = 1_100L,
|
||||
)
|
||||
val liveAssistant =
|
||||
ChatMessage(
|
||||
id = "live-assistant-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Here is the response",
|
||||
timestamp = 1_200L,
|
||||
)
|
||||
|
||||
val currentMessages =
|
||||
listOf(
|
||||
unresolvedUser,
|
||||
liveTool,
|
||||
liveAssistant,
|
||||
)
|
||||
|
||||
// REST confirms only the ASSISTANT message as rest-s-4 (e.g. user was dropped or mismatched)
|
||||
val restConfirmedAssistant =
|
||||
ChatMessage(
|
||||
id = "rest-s-4",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Here is the response",
|
||||
timestamp = 1_200L,
|
||||
)
|
||||
|
||||
val merged =
|
||||
mergeTranscriptWithLive(
|
||||
restMessages = listOf(restConfirmedAssistant),
|
||||
currentMessages = currentMessages,
|
||||
chronological = true,
|
||||
preserveLiveIds = false,
|
||||
)
|
||||
|
||||
// Assistant confirmation alone must not anchor/pull the unresolved user or tool
|
||||
// before rest-s-4. The confirmed assistant appears first, unresolved user/tool remain in pending tail.
|
||||
val expectedIds =
|
||||
listOf(
|
||||
"rest-s-4",
|
||||
"live-user-unresolved",
|
||||
"live-tool-1",
|
||||
)
|
||||
assertEquals(expectedIds, merged.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.Callable
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ChatPastedImageStoreTest {
|
||||
@get:Rule val temp = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun startupSweepDeletesOnlyPastedImagesOlderThanSevenDays() {
|
||||
val cacheDir = temp.newFolder("cache")
|
||||
val directory = cacheDir.resolve("pasted_images").apply { mkdir() }
|
||||
val now = 2_000_000_000_000L
|
||||
val cutoff = now - TimeUnit.DAYS.toMillis(7)
|
||||
val stale = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), directory, "png")!!
|
||||
assertTrue(stale.setLastModified(cutoff - 1000))
|
||||
val boundary = directory.resolve("boundary.png").apply { writeText("keep") }
|
||||
assertTrue(boundary.setLastModified(cutoff))
|
||||
val recent = directory.resolve("recent.png").apply { writeText("keep") }
|
||||
assertTrue(recent.setLastModified(now))
|
||||
val future = directory.resolve("future.png").apply { writeText("keep") }
|
||||
assertTrue(future.setLastModified(now + 1000))
|
||||
val unrelated = cacheDir.resolve("unrelated.png").apply { writeText("keep") }
|
||||
assertTrue(unrelated.setLastModified(cutoff - 1000))
|
||||
val nested =
|
||||
directory
|
||||
.resolve("nested")
|
||||
.apply { mkdir() }
|
||||
.resolve("old.png")
|
||||
.apply { writeText("keep") }
|
||||
assertTrue(nested.setLastModified(cutoff - 1000))
|
||||
|
||||
cleanStalePastedImages(cacheDir, now)
|
||||
cleanStalePastedImages(cacheDir, now)
|
||||
|
||||
assertFalse(stale.exists())
|
||||
listOf(boundary, recent, future, unrelated, nested).forEach { assertEquals("keep", it.readText()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupSweepDoesNotCreateAMissingCacheDirectory() {
|
||||
val cacheDir = temp.newFolder("empty-cache")
|
||||
|
||||
cleanStalePastedImages(cacheDir)
|
||||
|
||||
assertTrue(cacheDir.listFiles()!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startupSweepPreservesAnImportCreatedAfterTheStartupSnapshot() {
|
||||
val cacheDir = temp.newFolder("live-cache")
|
||||
val directory = cacheDir.resolve("pasted_images")
|
||||
val startupTime = System.currentTimeMillis()
|
||||
val imported = stagePastedImage(ByteArrayInputStream(byteArrayOf(1, 2)), directory, "png")!!
|
||||
|
||||
cleanStalePastedImages(cacheDir, startupTime)
|
||||
|
||||
assertArrayEquals(byteArrayOf(1, 2), imported.readBytes())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactLimitIsAcceptedWithItsActualByteCount() {
|
||||
val dir = temp.newFolder("exact")
|
||||
val bytes = byteArrayOf(1, 2, 3, 4)
|
||||
|
||||
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "png", maxBytes = 4)
|
||||
|
||||
assertArrayEquals(bytes, file!!.readBytes())
|
||||
assertEquals(4L, file.length())
|
||||
assertTrue(file.name.endsWith(".png"))
|
||||
assertEquals(listOf(file), dir.listFiles()!!.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callerLimitAbovePerFileCapStillRejectsAfterTenMiBAndCleansUp() {
|
||||
val dir = temp.newFolder("per-file-cap")
|
||||
val input = ByteArrayInputStream(ByteArray(10 * 1024 * 1024 + 1))
|
||||
try {
|
||||
stagePastedImage(input, dir, "png", maxBytes = 20L * 1024 * 1024)
|
||||
fail("Expected the per-file limit")
|
||||
} catch (_: PastedImageTooLargeException) {
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownSizeIsBoundedByTheActualStreamAndReadsAtMostLimitPlusOne() {
|
||||
val dir = temp.newFolder("oversize")
|
||||
val input =
|
||||
object : ByteArrayInputStream(ByteArray(100)) {
|
||||
var consumed = 0
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int = super.read(b, off, len).also { if (it > 0) consumed += it }
|
||||
}
|
||||
|
||||
try {
|
||||
stagePastedImage(input, dir, "png", maxBytes = 4)
|
||||
fail("Expected size limit rejection")
|
||||
} catch (_: PastedImageTooLargeException) {
|
||||
// The exact limit is accepted by the preceding test; only the extra byte rejects.
|
||||
}
|
||||
assertEquals(5, input.consumed)
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyStreamIsRejectedAndLeavesNoCacheFile() {
|
||||
val dir = temp.newFolder("empty")
|
||||
|
||||
assertNull(stagePastedImage(ByteArrayInputStream(byteArrayOf()), dir, "webp", maxBytes = 4))
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamFailureDeletesPartialFileAndClosesSource() {
|
||||
val dir = temp.newFolder("broken")
|
||||
val input =
|
||||
object : InputStream() {
|
||||
var reads = 0
|
||||
var closed = false
|
||||
|
||||
override fun read(): Int = if (reads++ == 0) 7 else throw IOException("provider failed")
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int =
|
||||
if (reads++ == 0) {
|
||||
b[off] = 7
|
||||
1
|
||||
} else {
|
||||
throw IOException("provider failed")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
stagePastedImage(input, dir, "png", maxBytes = 4)
|
||||
fail("Expected stream failure")
|
||||
} catch (_: IOException) {
|
||||
assertTrue(input.closed)
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existingNameCollisionNeverTruncatesOrDeletesExistingFile() {
|
||||
val dir = temp.newFolder("collision")
|
||||
val id = UUID.randomUUID().toString()
|
||||
val sentinel = dir.resolve("$id.png")
|
||||
sentinel.writeText("keep me")
|
||||
|
||||
try {
|
||||
stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png", newId = { id })
|
||||
fail("Expected exclusive-create failure")
|
||||
} catch (_: IOException) {
|
||||
assertEquals("keep me", sentinel.readText())
|
||||
assertEquals(listOf(sentinel), dir.listFiles()!!.toList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun simultaneousImportsUseDistinctFilesAndKeepTheirOwnBytes() {
|
||||
val dir = temp.newFolder("concurrent")
|
||||
val executor = Executors.newFixedThreadPool(8)
|
||||
try {
|
||||
val tasks =
|
||||
(1..16).map { n ->
|
||||
Callable {
|
||||
val bytes = ByteArray(n) { n.toByte() }
|
||||
val file = stagePastedImage(ByteArrayInputStream(bytes), dir, "jpeg", maxBytes = 64)!!
|
||||
file to bytes
|
||||
}
|
||||
}
|
||||
val results = executor.invokeAll(tasks).map { it.get(10, TimeUnit.SECONDS) }
|
||||
|
||||
assertEquals(16, results.map { it.first.name }.toSet().size)
|
||||
results.forEach { (file, bytes) -> assertArrayEquals(bytes, file.readBytes()) }
|
||||
assertEquals(16, dir.listFiles()!!.size)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellationCheckDeletesPartialFileAndPropagatesCancellation() {
|
||||
val dir = temp.newFolder("cancel")
|
||||
var checks = 0
|
||||
val input = ByteArrayInputStream(ByteArray(100))
|
||||
|
||||
try {
|
||||
stagePastedImage(
|
||||
input,
|
||||
dir,
|
||||
"png",
|
||||
maxBytes = 100,
|
||||
checkActive = {
|
||||
if (++checks == 2) throw CancellationException("cancelled")
|
||||
},
|
||||
)
|
||||
fail("Expected cancellation")
|
||||
} catch (_: CancellationException) {
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
assertTrue(checks >= 2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDiscardedResultDeletesOnlyItsOwnedFile() {
|
||||
val dir = temp.newFolder("discard")
|
||||
val file = stagePastedImage(ByteArrayInputStream(byteArrayOf(1)), dir, "png")!!
|
||||
val unrelated = dir.resolve("unrelated.png").apply { writeText("keep") }
|
||||
val result =
|
||||
PastedImage(
|
||||
attachment =
|
||||
com.m57.hermescontrol.data.model.Attachment(
|
||||
uri = "content://example/file",
|
||||
name = file.name,
|
||||
mimeType = "image/png",
|
||||
size = 1,
|
||||
),
|
||||
file = file,
|
||||
)
|
||||
|
||||
result.discard()
|
||||
assertFalse(file.exists())
|
||||
assertTrue(unrelated.exists())
|
||||
assertEquals("keep", unrelated.readText())
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,29 @@ class ChatSendStoreTest {
|
||||
assertEquals(setOf("send-1"), confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowIdOnHistoryPageConfirmsAcceptedReceiptWithoutMergeMatch() {
|
||||
val receipts =
|
||||
listOf(
|
||||
pending("send-1", "A").copy(userRowId = 334755L),
|
||||
pending("send-2", "B").copy(userRowId = 999L),
|
||||
pending("send-3", "C"),
|
||||
)
|
||||
|
||||
val confirmed = pendingSendIdsConfirmedByRowIds(setOf(334755L), receipts)
|
||||
|
||||
assertEquals(setOf("send-1"), confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowIdReceiptIsNotDowngradedButIdlessReceiptIs() {
|
||||
val withRow = pending("send-1", "A").copy(userRowId = 334755L)
|
||||
val idless = pending("send-2", "B")
|
||||
|
||||
assertTrue(!canDemoteAcceptedReceipt(withRow))
|
||||
assertTrue(canDemoteAcceptedReceipt(idless))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
|
||||
val existing =
|
||||
@@ -210,7 +233,7 @@ class ChatSendStoreTest {
|
||||
val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10)
|
||||
val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20)
|
||||
val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21)
|
||||
val receipt = pending("send-1", "repeat").copy(userRowId = 10)
|
||||
val receipt = pending("send-1", "repeat", PendingSendState.UNKNOWN).copy(userRowId = 10)
|
||||
val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true)
|
||||
|
||||
assertEquals(
|
||||
@@ -228,4 +251,21 @@ class ChatSendStoreTest {
|
||||
|
||||
assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalSendingAndAcceptedPromptsAreNotRecoveryItems() {
|
||||
val sends = PendingSendState.entries.map { pending(it.name, "prompt", it) }
|
||||
|
||||
assertEquals(
|
||||
setOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.REJECTED,
|
||||
),
|
||||
sends.filter { it.needsRecovery }.map { it.state }.toSet(),
|
||||
)
|
||||
val live = sends.filterNot { it.needsRecovery }.map { ChatMessage(it.id, MessageRole.USER, it.text) }
|
||||
assertEquals(live, messagesWithoutUnconfirmedReceipts(live, sends))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +360,14 @@ class ChatViewModelTest {
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
// Typed redirects bypass send(); keep this fixture off the real singleton transport.
|
||||
every { HermesWsClient.sendRedirect(any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-redirect-$reqCount"
|
||||
sentRequestMethods += WsMethods.SESSION_REDIRECT to id
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
every { HermesWsClient.respondToServerRequest(any(), any()) } returns true
|
||||
every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true
|
||||
|
||||
@@ -396,6 +404,73 @@ class ChatViewModelTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastedImages_areAddedOnlyToCapturedReadySessionWithoutSubmitting() =
|
||||
runTest(testDispatcher) {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||
val messagesBefore = viewModel.uiState.value.messages
|
||||
assertTrue(viewModel.addPastedAttachments(target, listOf(image)))
|
||||
runCurrent()
|
||||
assertEquals(listOf(image), viewModel.uiState.value.pendingAttachments)
|
||||
assertEquals(messagesBefore, viewModel.uiState.value.messages)
|
||||
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastedImages_rejectLateCompletionAfterSwitchAwayAndBack() =
|
||||
runTest(testDispatcher) {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||
viewModel.switchSession("other-paste-test-session")
|
||||
viewModel.switchSession(sessionId)
|
||||
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||
assertFalse(viewModel.addPastedAttachments(target, listOf(image)))
|
||||
assertTrue(
|
||||
viewModel.uiState.value.pendingAttachments
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastedImages_rejectChangedServerAndProfilesAndGeneration() =
|
||||
runTest(testDispatcher) {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||
val image = Attachment("content://test/pasted.png", "pasted.png", "image/png", 4)
|
||||
for (stale in listOf(
|
||||
target.copy(baseUrl = "http://other-paste-test.local/"),
|
||||
target.copy(connectionProfileId = "other-connection"),
|
||||
target.copy(agentProfileId = "other-agent"),
|
||||
target.copy(generation = target.generation + 1),
|
||||
)) {
|
||||
assertFalse(viewModel.addPastedAttachments(stale, listOf(image)))
|
||||
}
|
||||
assertTrue(
|
||||
viewModel.uiState.value.pendingAttachments
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastedImages_cannotCaptureTargetBeforeReady() =
|
||||
runTest(testDispatcher) {
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.captureAttachmentTarget())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pastedImages_rejectDisconnectedSessionBeforeCombinedUiStateCatchesUp() =
|
||||
runTest(testDispatcher) {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val target = checkNotNull(viewModel.captureAttachmentTarget())
|
||||
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
|
||||
assertNull(viewModel.captureAttachmentTarget())
|
||||
assertFalse(viewModel.addPastedAttachments(target, emptyList()))
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a ViewModel with the fake repo injected directly. */
|
||||
@@ -498,6 +573,32 @@ class ChatViewModelTest {
|
||||
assertEquals("op-1218", operation?.opId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResume_sendsDesktopSourceLikeSessionCreate() =
|
||||
runTest {
|
||||
// #1450: session.create declared source="desktop" but session.resume omitted it, so
|
||||
// the gateway resolved the resumed runtime from its host env ("tui") and staged a
|
||||
// bogus surface switch that also unloaded the desktop_ui toolset.
|
||||
val viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
val captured = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
val id = "req-${captured.size + 1}"
|
||||
captured.add(arg<String>(0) to (arg<Map<String, Any>>(1)))
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.switchSession("stored-session")
|
||||
advanceUntilIdle()
|
||||
|
||||
val resumeSent = captured.firstOrNull { it.first == WsMethods.SESSION_RESUME }
|
||||
assertNotNull("session.resume should be dispatched", resumeSent)
|
||||
assertEquals(JsonPrimitive("stored-session"), resumeSent!!.second["session_id"])
|
||||
assertEquals(JsonPrimitive("desktop"), resumeSent.second["source"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
|
||||
runTest {
|
||||
@@ -627,7 +728,7 @@ class ChatViewModelTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any())
|
||||
HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true)
|
||||
} returns
|
||||
CompletableDeferred(
|
||||
mapOf("type" to "exec", "output" to "**Available Commands:**\n\u2022 `/status`\n\u2022 `/new`"),
|
||||
@@ -715,7 +816,7 @@ class ChatViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.uiState.value.currentSessionId)
|
||||
assertEquals("Hermes", viewModel.uiState.value.chatTitle)
|
||||
assertEquals("AITURK", viewModel.uiState.value.chatTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1287,7 +1388,7 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("/status")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1298,7 +1399,7 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("/sessions")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1309,7 +1410,7 @@ class ChatViewModelTest {
|
||||
viewModel.sendMessage("/stats")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
verify { HermesWsClient.request(WsMethods.SLASH_EXEC, any(), any(), true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1635,6 +1736,7 @@ class ChatViewModelTest {
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-from-notification"),
|
||||
"source" to JsonPrimitive("desktop"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
@@ -5088,6 +5190,7 @@ class ChatViewModelTest {
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-456"),
|
||||
"source" to JsonPrimitive("desktop"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
@@ -5943,6 +6046,7 @@ class ChatViewModelTest {
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-456"),
|
||||
"source" to JsonPrimitive("desktop"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
@@ -10540,6 +10644,62 @@ class ChatViewModelTest {
|
||||
assertTrue(vm.uiState.value.canInterrupt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalAcceptanceKeepsPromptVisibleBeforeAndDuringItsTurn() =
|
||||
runTest {
|
||||
val store = ChatSendStore()
|
||||
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||
every { HermesWsClient.sendMessage(session, "live-prompt", any(), any()) } answers {
|
||||
arg<((String) -> Unit)?>(2)?.invoke("live-submit")
|
||||
"live-submit"
|
||||
}
|
||||
assertTrue(vm.sendMessage("live-prompt"))
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(WsEvent.RpcResult("live-submit", mapOf("status" to "streaming", "user_row_id" to 10)))
|
||||
advanceUntilIdle()
|
||||
vm.syncCurrentSession()
|
||||
advanceUntilIdle()
|
||||
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||
assertEquals(
|
||||
"live-prompt",
|
||||
vm.transcriptState.value.messages
|
||||
.single { it.role == MessageRole.USER }
|
||||
.content,
|
||||
)
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(session))
|
||||
mockEventsFlow.emit(WsEvent.MessageToken("Working", session))
|
||||
advanceUntilIdle()
|
||||
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||
assertTrue(
|
||||
vm.transcriptState.value.messages
|
||||
.any { it.content == "live-prompt" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayQueuedAcceptanceStaysVisibleThroughPreviousTurnCompletion() =
|
||||
runTest {
|
||||
val store = ChatSendStore()
|
||||
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(session))
|
||||
advanceUntilIdle()
|
||||
assertTrue(vm.sendMessage("guide-next", BusySendMode.GUIDE))
|
||||
advanceUntilIdle()
|
||||
val requestId = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "queued", "user_row_id" to 10)))
|
||||
advanceUntilIdle()
|
||||
mockEventsFlow.emit(WsEvent.MessageComplete("Previous reply", session))
|
||||
advanceUntilIdle()
|
||||
vm.syncCurrentSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||
assertTrue(
|
||||
vm.transcriptState.value.messages
|
||||
.any { it.content == "guide-next" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() =
|
||||
runTest {
|
||||
@@ -10580,6 +10740,7 @@ class ChatViewModelTest {
|
||||
.last()
|
||||
.content,
|
||||
)
|
||||
// #1427: a mismatched history row cannot retire the gateway-confirmed receipt.
|
||||
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||
assertTrue(vm.uiState.value.isSessionReady)
|
||||
assertTrue(vm.sendMessage("probe-second"))
|
||||
@@ -10593,8 +10754,9 @@ class ChatViewModelTest {
|
||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
||||
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
|
||||
// The accepted row 10 remains visible separately from the unrelated history row 20.
|
||||
assertEquals(
|
||||
1,
|
||||
2,
|
||||
vm.transcriptState.value.messages
|
||||
.count { it.content == "probe-first" },
|
||||
)
|
||||
|
||||
@@ -478,4 +478,27 @@ class FullBleedTurnsTest {
|
||||
assertEquals(setOf("u1", "a2"), matchedMessageIds(msgs, listOf(0, 2, 0)))
|
||||
assertEquals(emptySet<String>(), matchedMessageIds(msgs, listOf(9)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `react_to_message tool rows are hidden but other tools stay`() {
|
||||
val messages =
|
||||
listOf(
|
||||
msg("u1", MessageRole.USER),
|
||||
msg("react", MessageRole.TOOL).copy(toolName = "react_to_message"),
|
||||
msg("term", MessageRole.TOOL).copy(toolName = "terminal"),
|
||||
msg("a1", MessageRole.ASSISTANT),
|
||||
)
|
||||
val agent = groupIntoTurns(messages).filterIsInstance<ChatTurn.Agent>().single()
|
||||
assertEquals(
|
||||
listOf("term", "a1"),
|
||||
agent.entries.map {
|
||||
when (it) {
|
||||
is AgentEntry.Prose -> it.message.id
|
||||
is AgentEntry.ToolRow -> it.message.id
|
||||
is AgentEntry.SystemEvent -> it.message.id
|
||||
}
|
||||
},
|
||||
)
|
||||
assertEquals(emptyList<SearchMatch>(), ChatSearchController().findMatches(messages.take(2), "react").matches)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.toEntity
|
||||
import com.m57.hermescontrol.data.local.toUiModel
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #1433: gateway notices are per-session transcript rows that keep their position. */
|
||||
class GatewayNoticeTest {
|
||||
private val compactedText = "✓ Context compaction complete — continuing turn..."
|
||||
|
||||
private fun status(
|
||||
kind: String,
|
||||
text: String?,
|
||||
sessionId: String? = "s1",
|
||||
) = WsEvent.StatusUpdate(
|
||||
status = null,
|
||||
data = mapOf("kind" to kind, "text" to text),
|
||||
sessionId = sessionId,
|
||||
)
|
||||
|
||||
private fun reduce(
|
||||
state: ChatUiState,
|
||||
event: WsEvent,
|
||||
streaming: StreamingState = StreamingState(),
|
||||
) = ChatWsEventReducer.reduce(state, streaming, event, currentSessionId = "s1")
|
||||
|
||||
@Test
|
||||
fun sentinelMatchesEveryGatewayInterruptShape() {
|
||||
listOf(
|
||||
"[the reply degenerated into a repetition loop and was interrupted]",
|
||||
"Operation interrupted.",
|
||||
"Operation interrupted: waiting for the provider to recover (cycle 1/3).",
|
||||
"Operation interrupted: retrying API call after error (retry 2/5).",
|
||||
"Operation interrupted: handling API error (RateLimitError: slow down).",
|
||||
"Operation interrupted: retrying empty response from model (retry 1/2).",
|
||||
"Operation interrupted during retry (timeout, attempt 1/3).",
|
||||
" Operation interrupted.\n",
|
||||
).forEach { assertTrue(it, GatewayNotice.isInterruptSentinel(it)) }
|
||||
listOf(
|
||||
"Operation interrupted",
|
||||
"Operation interrupted. Here is what I found so far",
|
||||
"The operation was interrupted.",
|
||||
"Operation interrupted: first line.\nsecond line.",
|
||||
).forEach { assertFalse(it, GatewayNotice.isInterruptSentinel(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactedNoticeBecomesPersistedSystemRowAndClearsTailState() {
|
||||
val user = ChatMessage(id = "u", role = MessageRole.USER, content = "go")
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "s1",
|
||||
messages = listOf(user),
|
||||
isCompressing = true,
|
||||
compressionStatus = "🗜️ Compacting context",
|
||||
)
|
||||
|
||||
val result = reduce(state, status("compacted", compactedText))
|
||||
|
||||
assertFalse(result.state.isCompressing)
|
||||
assertNull(result.state.compressionStatus)
|
||||
val notice = result.state.messages.last()
|
||||
assertEquals(MessageRole.SYSTEM, notice.role)
|
||||
assertEquals(compactedText, notice.content)
|
||||
assertTrue(result.effects.any { it is ReducerEffect.PersistMessage && it.message.id == notice.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replayedNoticeInSameTurnDoesNotStack() {
|
||||
val state =
|
||||
ChatUiState(
|
||||
currentSessionId = "s1",
|
||||
messages = listOf(ChatMessage(role = MessageRole.USER, content = "go")),
|
||||
)
|
||||
val once = reduce(state, status("compacted", compactedText)).state
|
||||
val twice = reduce(once, status("compacted", compactedText)).state
|
||||
assertEquals(1, twice.messages.count { it.role == MessageRole.SYSTEM })
|
||||
|
||||
val nextTurn = twice.copy(messages = twice.messages + ChatMessage(role = MessageRole.USER, content = "again"))
|
||||
val again = reduce(nextTurn, status("compacted", compactedText)).state
|
||||
assertEquals(2, again.messages.count { it.role == MessageRole.SYSTEM })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherSessionsStatusUpdatesAreIgnored() {
|
||||
val state = ChatUiState(currentSessionId = "s1")
|
||||
listOf("compacting", "compacted", "goal", "warn").forEach { kind ->
|
||||
val result = reduce(state, status(kind, "text for $kind", sessionId = "other"))
|
||||
assertEquals(kind, state, result.state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durableKindsAppendAndTransientKindsDoNot() {
|
||||
val state = ChatUiState(currentSessionId = "s1")
|
||||
listOf("goal", "loop", "warn", "fallback").forEach { kind ->
|
||||
assertEquals(kind, 1, reduce(state, status(kind, "✓ $kind")).state.messages.size)
|
||||
}
|
||||
listOf("status", "lifecycle", "process", "heartbeat", "ready").forEach { kind ->
|
||||
assertTrue(kind, reduce(state, status(kind, "x")).state.messages.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repetitionLoopCompletionDropsLoopedPartialAndAppendsNotice() {
|
||||
val sealed = ChatMessage(id = "looped", role = MessageRole.ASSISTANT, content = "again again again")
|
||||
val state = ChatUiState(currentSessionId = "s1", messages = listOf(sealed), isAgentTyping = true)
|
||||
val event =
|
||||
WsEvent.MessageComplete(
|
||||
text = GatewayNotice.REPETITION_LOOP_INTERRUPTED,
|
||||
sessionId = "s1",
|
||||
rawPayload = mapOf("status" to "interrupted"),
|
||||
)
|
||||
|
||||
val result = reduce(state, event, StreamingState(interruptedMessage = sealed))
|
||||
|
||||
assertEquals(listOf(MessageRole.SYSTEM), result.state.messages.map { it.role })
|
||||
assertEquals(
|
||||
GatewayNotice.REPETITION_LOOP_INTERRUPTED,
|
||||
result.state.messages
|
||||
.single()
|
||||
.content,
|
||||
)
|
||||
assertFalse(result.state.isAgentTyping)
|
||||
assertTrue(result.effects.contains(ReducerEffect.DeleteLocalMessage("looped")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun operationInterruptedCompletionKeepsRealPartial() {
|
||||
val stream = ChatMessage(id = "p", role = MessageRole.ASSISTANT, content = "half an answer", isStreaming = true)
|
||||
val state = ChatUiState(currentSessionId = "s1")
|
||||
val event = WsEvent.MessageComplete(text = "Operation interrupted.", sessionId = "s1")
|
||||
|
||||
val result = reduce(state, event, StreamingState(streamingMessage = stream))
|
||||
|
||||
assertEquals(
|
||||
listOf("p", null),
|
||||
result.state.messages.map {
|
||||
if (it.role ==
|
||||
MessageRole.SYSTEM
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
it.id
|
||||
}
|
||||
},
|
||||
)
|
||||
assertFalse(
|
||||
result.state.messages
|
||||
.first()
|
||||
.isStreaming,
|
||||
)
|
||||
assertEquals(
|
||||
"Operation interrupted.",
|
||||
result.state.messages
|
||||
.last()
|
||||
.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedSentinelRowMapsToSystemNoticeAndMergesWithLiveCopy() {
|
||||
val rows =
|
||||
listOf(
|
||||
SessionMessage(id = 1, role = "user", content = JsonPrimitive("go")),
|
||||
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("Operation interrupted.")),
|
||||
SessionMessage(id = 3, role = "user", content = JsonPrimitive("next")),
|
||||
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("done")),
|
||||
)
|
||||
val page = mapServerMessages("s", rows, 0, true, emptyList())
|
||||
assertEquals(MessageRole.SYSTEM, page[1].role)
|
||||
|
||||
// Live view after the interrupted turn: the notice sits between the two turns.
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(id = "u1", role = MessageRole.USER, content = "go"),
|
||||
ChatMessage(id = "n", role = MessageRole.SYSTEM, content = "Operation interrupted."),
|
||||
ChatMessage(id = "u2", role = MessageRole.USER, content = "next"),
|
||||
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "done"),
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
|
||||
assertEquals(
|
||||
listOf("go", "Operation interrupted.", "next", "done"),
|
||||
merged.map { it.content },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveOnlyNoticeStaysInPlaceAcrossLaterTurnsAndRefresh() {
|
||||
val page =
|
||||
mapServerMessages(
|
||||
"s",
|
||||
listOf(
|
||||
SessionMessage(id = 1, role = "user", content = JsonPrimitive("first")),
|
||||
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("one")),
|
||||
SessionMessage(id = 3, role = "user", content = JsonPrimitive("second")),
|
||||
SessionMessage(id = 4, role = "assistant", content = JsonPrimitive("two")),
|
||||
),
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
)
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(id = "u1", role = MessageRole.USER, content = "first"),
|
||||
ChatMessage(id = "a1", role = MessageRole.ASSISTANT, content = "one"),
|
||||
ChatMessage(id = "c", role = MessageRole.SYSTEM, content = compactedText),
|
||||
ChatMessage(id = "u2", role = MessageRole.USER, content = "second"),
|
||||
ChatMessage(id = "a2", role = MessageRole.ASSISTANT, content = "two"),
|
||||
)
|
||||
val once = mergeTranscriptWithLive(page, live, preserveLiveIds = true)
|
||||
val twice = mergeTranscriptWithLive(page, once, preserveLiveIds = true)
|
||||
val expected = listOf("first", "one", compactedText, "second", "two")
|
||||
assertEquals(expected, once.map { it.content })
|
||||
assertEquals(expected, twice.map { it.content })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomRoundTripNormalizesLegacyAssistantSentinel() {
|
||||
val legacy = ChatMessage(id = "x", role = MessageRole.ASSISTANT, content = "Operation interrupted.")
|
||||
val restored = legacy.toEntity("s").toUiModel()
|
||||
assertEquals(MessageRole.SYSTEM, restored.role)
|
||||
val prose = ChatMessage(id = "y", role = MessageRole.ASSISTANT, content = "fine")
|
||||
assertEquals(MessageRole.ASSISTANT, prose.toEntity("s").toUiModel().role)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user