18 Commits
Author SHA1 Message Date
yilsemandClaude Opus 5.5 6f4309c746 feat(aiturk): add the connect-to-computer screen (QR scan, code, deep link)
Merge Conflict Detector / detect-and-label (push) Successful in 3s
Android CI/CD / Detect Changes (push) Successful in 6s
CodeQL Analysis / Analyze (java-kotlin) (push) Failing after 2m31s
Android CI/CD / Android Lint — Static Analysis (push) Failing after 3m57s
Android CI/CD / Build Debug APK (push) Failing after 1m56s
Android CI/CD / Instrumented Tests (push) Failing after 1m15s
Android CI/CD / ktlint — Code Style (push) Failing after 5m16s
Android CI/CD / Unit & Integration Tests (push) Failing after 4m8s
Android CI/CD / Release Compile Check (push) Failing after 14m1s
Android CI/CD / CI Summary (push) Failing after 1s
Landing now leads with QR / 8-character code pairing; manual login stays as the secondary action. aiturk://pair links open the same screen and ask for confirmation first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:06:31 +03:00
yilsemandClaude Opus 5.5 c2b08ff73e feat(aiturk): pair with AITURK IDE over LAN with a pinned certificate
Adds the phone side of the pairing contract v1: payload parsing, gateway resolve, parallel hello and a single claim, per host:port certificate pinning hooked into OkHttpProvider.base, and no dashboard token refresh for a paired IDE. Adds zxing-android-embedded and okhttp-tls (tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-04 06:06:31 +03:00
yilsemandClaude Opus 5.5 8e48a0fa1e feat(brand): disable upstream self-updater, add attribution and NOTICE
The GitHub release checker would install Hy4ri/hermes-mobile APKs; the About
screen now points to https://turkservis.online/indir and credits the original
project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:06:22 +03:00
yilsemandClaude Opus 5.5 34758975b7 feat(brand): replace launcher icon with the AITURK mark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:06:21 +03:00
yilsemandClaude Opus 5.5 e3d0a97921 feat(i18n): add Turkish translation and language picker entry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:06:20 +03:00
yilsemandClaude Opus 5.5 5f548f5a17 feat(brand): rename app to AITURK Mobil and set applicationId
Replace user-visible Hermes / Hermes Mobile texts with AITURK branding in all
locales and set applicationId to online.turkservis.aiturk.mobil. Namespace and
source packages stay com.m57.hermescontrol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-03 15:06:19 +03:00
944390bec1 chore: promote dev to main (#1455)
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
2026-10-03 00:51:11 +03:00
m57andGitHub 62fcf07e1b Merge pull request #1431 from Hy4ri/codex/release-v2.0.2
chore: promote dev for v2.0.2
2026-10-01 20:30:07 +03:00
M57 e7927f6631 chore: promote dev for v2.0.2 2026-10-01 20:17:08 +03:00
m57andGitHub 1cef9e7ebb fix(#1427): keep accepted prompts visible during live turns (#1430) 2026-10-01 20:17:05 +03:00
m57andGitHub 82cf8df238 Merge pull request #1429 from Hy4ri/codex/release-v2.0.1
chore(release): promote dev to main for v2.0.1
2026-10-01 17:08:17 +03:00
M57 ba029e83dc chore(release): merge dev for v2.0.1 2026-10-01 16:56:32 +03:00
m57GitHubLx-204AlexDev_dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nikhil NairmarciodpsThomasCrund
728daa9acd release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
2026-10-01 11:19:09 +03:00
m57GitHubLx-204AlexDev_dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nikhil NairmarciodpsThomasCrund
ed915d0816 release: promote v1.31.0-rc.2 to main (#1354)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
2026-09-28 12:39:34 +03:00
m57GitHubLx-204AlexDev_dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Nikhil Nairmarciodps
e4004decb4 release: promote dev to main for v1.30.0 (#1271)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Lx-204 <15377891490@163.com>
Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com>
Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com>
2026-09-25 23:23:12 +03:00
m57andGitHub 7386c3035b Merge pull request #1251 from Hy4ri/chore/sync-dev-to-main-1.29.6
chore: sync dev to main for 1.29.6
2026-09-24 02:53:35 +03:00
M57 15a0abc7a7 Merge branch 'dev' into chore/sync-dev-to-main-1.29.6 2026-09-24 02:51:46 +03:00
m57andGitHub ed26f54855 release: 1.29.5
release: 1.29.5
2026-09-23 21:06:45 +03:00
105 changed files with 8933 additions and 503 deletions
+26
View File
@@ -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.
+5 -1
View File
@@ -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)
+5
View File
@@ -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);
}
@@ -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() }
}
}
@@ -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++
@@ -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,
@@ -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 {
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>
+18
View File
@@ -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&amp;u=...&amp;fp=...&amp;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")
}
@@ -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>
+34 -36
View File
@@ -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>
+28 -30
View File
@@ -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
+43 -39
View File
@@ -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">没有匹配&quot;%1$s&quot;的服务器</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>
+2 -2
View File
@@ -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>
+79 -43
View File
@@ -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 &quot;%1$s&quot;</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"),
)
@@ -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)
@@ -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