Author SHA1 Message Date
yilsemandClaude Opus 5.5 c2d828b90a feat(aiturk): say when AITURK IDE is closed in the chat banner
Merge Conflict Detector / detect-and-label (push) Successful in 2s
Android CI/CD / Detect Changes (push) Successful in 4s
Android CI/CD / Build Debug APK (push) Failing after 2m50s
Android CI/CD / ktlint — Code Style (push) Failing after 3m9s
CodeQL Analysis / Analyze (java-kotlin) (push) Failing after 3m20s
Android CI/CD / Instrumented Tests (push) Failing after 3m11s
Android CI/CD / Release Compile Check (push) Failing after 1m22s
Android CI/CD / Android Lint — Static Analysis (push) Failing after 2m46s
Android CI/CD / Unit & Integration Tests (push) Failing after 2m50s
Android CI/CD / CI Summary (push) Failing after 1s
When the tunnel reports that no computer is attached (close 4503) the
reconnect banner now says that AITURK IDE is not open or Phone
connection is off, instead of an endless 'reconnecting'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:18:53 +03:00
yilsemandClaude Opus 5.5 cbe716300c feat(aiturk): reach the IDE through an end-to-end encrypted tunnel
A phone on the same Wi-Fi could not reach the computer at all (client
isolation on the router), and every failure was shown as one 'same
Wi-Fi' message. Contract v3: the phone only dials turkservis.online.

Transport: a relay pairing lives on the virtual host
<relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp
client resolves a placeholder address, opens a WebSocket byte stream to
the relay instead of a TCP connection, and runs TLS on top of it pinned
to the IDE's certificate fingerprint. The relay sees ciphertext only.
DNS, sockets, proxy choice and TLS are unchanged for every other host.

Pairing: tunnel first (hello within 10 s), LAN candidates second, one
claim only. The local-network permission is requested only when a LAN
address may be tried; denying it no longer ends the pairing.

Errors: host offline, unknown relay, busy, no internet, silent computer,
certificate mismatch and the LAN failures each get their own text plus
a short diagnostic code built from a fixed vocabulary (never a token,
pairing code or address). After pairing the screen shows the first 8
characters of the fingerprint as a security code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:18:52 +03:00
yilsemandClaude Opus 5.5 9afac6f508 feat(aiturk): accept a relay id in pairing payloads
Contract v3 (tunnel): the QR / deep link may carry r=<lowercase UUIDv4>
and no LAN address at all (the IDE's default). u is now 0..4, but a
payload with neither u nor r is rejected, and a present-but-invalid r
rejects the whole payload instead of falling back to the LAN addresses.

AiturkRelay maps a relay id to the virtual host
<relayId>.relay.aiturk.invalid that addresses a tunnel pairing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 04:08:07 +03:00
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
112 changed files with 11438 additions and 505 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 compileSdk = 37
buildToolsVersion = "37.0.0" buildToolsVersion = "37.0.0"
defaultConfig { defaultConfig {
applicationId = "com.m57.hermescontrol" applicationId = "online.turkservis.aiturk.mobil"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
// Version overrides passed from CI via -PversionName / -PversionCode // Version overrides passed from CI via -PversionName / -PversionCode
@@ -201,6 +201,9 @@ dependencies {
implementation(libs.media3.ui) implementation(libs.media3.ui)
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
implementation(libs.zxing.android.embedded)
// Encrypted storage // Encrypted storage
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
@@ -218,6 +221,7 @@ dependencies {
testRuntimeOnly(libs.junit.vintage.engine) testRuntimeOnly(libs.junit.vintage.engine)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.okhttp.tls)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk) testImplementation(libs.mockk)
testImplementation(libs.turbine) testImplementation(libs.turbine)
+5
View File
@@ -57,3 +57,8 @@
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage. # Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
-keep class * extends androidx.navigation3.runtime.NavKey { *; } -keep class * extends androidx.navigation3.runtime.NavKey { *; }
# ── AITURK IDE pairing ───────────────────────────────────────────────────
# Android looks this hostname-aware overload up by reflection (X509TrustManagerExtensions).
-keepclassmembers class com.m57.hermescontrol.data.aiturk.PinAwareTrustManager {
public java.util.List checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String, java.lang.String);
}
@@ -0,0 +1,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 package com.m57.hermescontrol.ui.chat.components
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -23,7 +26,6 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,6 +60,7 @@ class ComposerInteractionTest {
private var fileTaps = 0 private var fileTaps = 0
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
@OptIn(ExperimentalFoundationApi::class)
private fun setComposer( private fun setComposer(
reasoningLevel: String? = "medium", reasoningLevel: String? = "medium",
model: String = "openai/gpt-5.5", model: String = "openai/gpt-5.5",
@@ -67,18 +70,17 @@ class ComposerInteractionTest {
showModelProvider: Boolean = false, showModelProvider: Boolean = false,
) { ) {
composeTestRule.setContent { composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) } val input = rememberTextFieldState()
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
var currentModel by remember { mutableStateOf(model) } var currentModel by remember { mutableStateOf(model) }
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
val composer: @Composable () -> Unit = { val composer: @Composable () -> Unit = {
ChatInputBar( ChatInputBar(
inputFieldValue = input, inputState = input,
onInputChange = { input = it },
onSend = { onSend = {
sends++ sends++
input = TextFieldValue("") input.clearText()
}, },
onMicTap = { onMicTap = {
micTaps++ micTaps++
@@ -1,9 +1,8 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.compose.runtime.getValue import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertTextEquals
@@ -11,12 +10,12 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.input.TextFieldValue
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@OptIn(ExperimentalFoundationApi::class)
class ComposerReadinessTest { class ComposerReadinessTest {
@get:Rule @get:Rule
val compose = createComposeRule() val compose = createComposeRule()
@@ -26,10 +25,9 @@ class ComposerReadinessTest {
val ready = mutableStateOf(false) val ready = mutableStateOf(false)
var sends = 0 var sends = 0
compose.setContent { compose.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) } val input = rememberTextFieldState()
ChatInputBar( ChatInputBar(
inputFieldValue = input, inputState = input,
onInputChange = { input = it },
onSend = { sends++ }, onSend = { sends++ },
onMicTap = {}, onMicTap = {},
isListening = false, isListening = false,
@@ -64,10 +62,9 @@ class ComposerReadinessTest {
@Test @Test
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() { fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
compose.setContent { compose.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) } val input = rememberTextFieldState()
ChatInputBar( ChatInputBar(
inputFieldValue = input, inputState = input,
onInputChange = { input = it },
onSend = {}, onSend = {},
onMicTap = {}, onMicTap = {},
isListening = false, isListening = false,
@@ -1,27 +1,42 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.activity.ComponentActivity
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.PendingSend import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.UserBubble
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.File
import java.util.Locale
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PendingSendRecoveryTest { class PendingSendRecoveryTest {
@get:Rule @get:Rule
val compose = createComposeRule() val compose = createAndroidComposeRule<ComponentActivity>()
private val uncertain = private val uncertain =
PendingSend( PendingSend(
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
state = PendingSendState.UNKNOWN, state = PendingSendState.UNKNOWN,
) )
private fun imageAttachment(): Attachment {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = File(context.cacheDir, "photo.png")
val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160))
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
bitmap.recycle()
return Attachment(file.toURI().toString(), file.name, "image/png", file.length())
}
@Test @Test
fun uncertainRetryRequiresExplicitConfirmation() { fun uncertainRetryRequiresExplicitConfirmation() {
var retried: String? = null var retried: String? = null
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist() compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
} }
@Test
fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() {
val image = imageAttachment()
val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image))
compose.setContent {
MaterialTheme {
androidx.compose.foundation.layout.Column {
UserBubble(
ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments),
pendingSendState = accepted.state,
)
PendingSendRecovery(listOf(accepted), true, true, {}, {})
}
}
}
compose.onNodeWithText(accepted.text).assertIsDisplayed()
compose.waitUntil(5_000) {
compose
.onNodeWithContentDescription("photo.png")
.fetchSemanticsNode()
.boundsInRoot.height > 0
}
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
compose.onNodeWithContentDescription("Accepted by server").assertIsDisplayed()
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
}
@Test
fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() {
val image = imageAttachment()
val file = Attachment("file:///private/note", "note.txt", "text/plain", 4)
var openedImage: String? = null
var openedFile: String? = null
compose.setContent {
MaterialTheme {
PendingSendRecovery(
listOf(uncertain.copy(attachments = listOf(image, file))),
false,
false,
{},
{},
onOpenAttachment = { openedFile = it.name },
onImageClick = { openedImage = it.name },
)
}
}
compose.onNodeWithTag("pending_send_recovery").performClick()
compose.onNodeWithText("photo.png").assertIsDisplayed()
compose.waitUntil(5_000) {
compose
.onNodeWithContentDescription("photo.png")
.fetchSemanticsNode()
.boundsInRoot.height > 0
}
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals("photo.png", openedImage) }
// The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children.
if (!compose.onNodeWithText("note.txt").isDisplayed()) {
compose.onNode(hasScrollAction()).performTouchInput { swipeUp() }
}
compose.onNodeWithText("note.txt").assertIsDisplayed().performClick()
compose.runOnIdle { assertEquals("note.txt", openedFile) }
}
@Test
@Suppress("DEPRECATION")
fun chineseRecoveryCountAndDismissAreLocalized() {
// Native dialog windows resolve the Activity locale, not a synthetic LocalContext.
val resources = compose.activity.resources
val original = Configuration(resources.configuration)
val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) }
try {
compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) }
compose.setContent {
MaterialTheme {
PendingSendRecovery(listOf(uncertain), false, false, {}, {})
}
}
compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick()
compose
.onNodeWithText("移除")
.performScrollTo()
.assertIsDisplayed()
.performClick()
compose
.onNodeWithText(
"要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。",
).assertIsDisplayed()
} finally {
compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) }
}
}
@Test @Test
fun longUncertainMessageKeepsConfirmationActionsVisible() { fun longUncertainMessageKeepsConfirmationActionsVisible() {
compose.setContent { compose.setContent {
@@ -3,9 +3,11 @@ package com.m57.hermescontrol.ui.chat.components
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -19,6 +21,7 @@ import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@@ -26,7 +29,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.text.input.TextFieldValue
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -52,6 +54,7 @@ import java.io.File
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@MediumTest @MediumTest
@OptIn(ExperimentalFoundationApi::class)
class VoiceNoteGestureTest { class VoiceNoteGestureTest {
@get:Rule @get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>() val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@@ -88,8 +91,7 @@ class VoiceNoteGestureTest {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
ChatInputBar( ChatInputBar(
inputFieldValue = TextFieldValue(draft), inputState = rememberTextFieldState(initialText = draft),
onInputChange = {},
onSend = { sendCount++ }, onSend = { sendCount++ },
onMicTap = { micTaps++ }, onMicTap = { micTaps++ },
isListening = false, isListening = false,
@@ -275,6 +277,8 @@ class VoiceNoteGestureTest {
assertEquals("release must submit the note", 1, holdEnds) assertEquals("release must submit the note", 1, holdEnds)
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps) assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
} }
// The state-based input must retain the draft when the recording strip is removed.
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft")
} }
@Test @Test
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes Dev</string> <string name="app_name">AITURK Mobil</string>
</resources> </resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes Dev</string> <string name="app_name">AITURK Mobil</string>
</resources> </resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes Dev</string> <string name="app_name">AITURK Mobil</string>
</resources> </resources>
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Hermes Dev</string> <string name="app_name">AITURK Mobil</string>
</resources> </resources>
+18
View File
@@ -12,6 +12,9 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- AITURK IDE pairing: QR scan. Requested at runtime; typing the code works without it. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<queries> <queries>
<intent> <intent>
@@ -38,8 +41,23 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- AITURK IDE pairing link: aiturk://pair?v=1&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>
<activity
android:name=".ui.aiturkpair.AiturkQrCaptureActivity"
android:exported="false"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
<service <service
android:name=".assistant.AssistantService" android:name=".assistant.AssistantService"
android:permission="android.permission.BIND_VOICE_INTERACTION" android:permission="android.permission.BIND_VOICE_INTERACTION"
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.notification.TurnCorrelationTracker import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -28,6 +29,10 @@ class HermesControlApp :
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
val startupTimeMs = System.currentTimeMillis()
appScope.launch(Dispatchers.IO) {
cleanStalePastedImages(cacheDir, startupTimeMs)
}
AuthManager.init(this) AuthManager.init(this)
NetworkMonitor.init(this) NetworkMonitor.init(this)
SessionListCacheStore.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.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLink
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.util.LocaleContextWrapper import com.m57.hermescontrol.util.LocaleContextWrapper
@@ -62,6 +63,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
consumeNotificationIntent(intent) consumeNotificationIntent(intent)
AiturkPairDeepLink.offer(intent)
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
@@ -129,6 +131,7 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
consumeNotificationIntent(intent) consumeNotificationIntent(intent)
AiturkPairDeepLink.offer(intent)
} }
override fun onResume() { override fun onResume() {
@@ -52,6 +52,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
import com.m57.hermescontrol.ui.common.DisableDrawerGestures import com.m57.hermescontrol.ui.common.DisableDrawerGestures
import com.m57.hermescontrol.ui.common.DrawerGestureController import com.m57.hermescontrol.ui.common.DrawerGestureController
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
@@ -66,6 +67,7 @@ import com.m57.hermescontrol.ui.settings.SettingsVaultPage
import com.m57.hermescontrol.ui.settings.SettingsViewModel import com.m57.hermescontrol.ui.settings.SettingsViewModel
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairScreen as AiturkPairScreenContent
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
@@ -79,6 +81,9 @@ private fun appEntryProvider(
onAuthLogin = { onAuthLogin = {
NavigationController.navigateTo(AuthLoginScreen) NavigationController.navigateTo(AuthLoginScreen)
}, },
onAiturkPair = {
NavigationController.navigateTo(AiturkPairScreen)
},
) )
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619). // Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
DisableDrawerGestures() DisableDrawerGestures()
@@ -96,6 +101,18 @@ private fun appEntryProvider(
DisableDrawerGestures() DisableDrawerGestures()
} }
entry<AiturkPairScreen> {
AiturkPairScreenContent(
onConnected = {
NavigationController.resetTo(ChatScreen)
},
onBack = {
NavigationController.goBack()
},
)
DisableDrawerGestures()
}
ScreenRegistry.ALL_SCREENS.forEach { screen -> ScreenRegistry.ALL_SCREENS.forEach { screen ->
addEntryProvider(clazz = screen.key::class) { addEntryProvider(clazz = screen.key::class) {
screen.content(sessionId, openDrawer) screen.content(sessionId, openDrawer)
@@ -193,6 +210,7 @@ fun MainNavigation(sessionId: String? = null) {
val backStack = remember(startScreen) { NavBackStack(startScreen) } val backStack = remember(startScreen) { NavBackStack(startScreen) }
NavigationController.backStack = backStack NavigationController.backStack = backStack
AiturkPairDeepLinkEffect()
val currentScreen = backStack.lastOrNull() ?: startScreen val currentScreen = backStack.lastOrNull() ?: startScreen
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
@Serializable data object AuthLoginScreen : NavKey @Serializable data object AuthLoginScreen : NavKey
@Serializable data object AiturkPairScreen : NavKey
@Serializable data object ChatScreen : NavKey @Serializable data object ChatScreen : NavKey
@Serializable data object SettingsScreen : NavKey @Serializable data object SettingsScreen : NavKey
@@ -0,0 +1,53 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Stores a freshly claimed AITURK IDE pairing in the app's existing connection
* model: a [ConnectionProfile] named after the computer, token auth, WS `?token=`.
*/
object AiturkConnectionRegistrar {
const val FALLBACK_PC_NAME = "AITURK IDE"
suspend fun register(device: AiturkPairedDevice) {
// The address the claim was sent to: the LAN `https://ip:port/` or the
// virtual tunnel host `https://<relayId>.relay.aiturk.invalid/`.
val baseUrl = device.baseUrl
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
// Trust first: every later request to this host:port — over the LAN or
// through the tunnel — is checked against the pin, end to end.
AiturkLanTrust.pin(device.hostPort, device.fingerprint)
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
// Re-pairing the same computer reuses its profile instead of piling up duplicates.
val profiles = AuthManager.getConnectionProfiles()
val existing = profiles.firstOrNull { it.baseUrl == baseUrl }
val profile =
existing?.copy(name = name)
?: ConnectionProfile(name = name, host = "", port = 0, baseUrl = baseUrl)
AuthManager.saveConnectionProfiles(
if (existing != null) {
profiles.map { if (it.id == profile.id) profile else it }
} else {
profiles + profile
},
)
AuthManager.setSelectedProfileId(profile.id)
AuthManager.syncCookieStoreForProfile(profile.id)
// Same order as the manual token login (AuthLoginViewModel.connect).
AuthManager.setBaseUrl(baseUrl)
AuthManager.setToken(device.token)
AuthManager.setSessionCookie(null)
AuthManager.setWsAuthParam("token")
// Rebuilds Retrofit, tells chat the server changed, then re-dials the socket.
ProfileSwitchCoordinator.switchConnectionProfile(profile.id)
}
}
@@ -0,0 +1,363 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.net.Socket
import java.security.KeyStore
import java.security.MessageDigest
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/**
* TLS trust for AITURK IDE LAN pairing.
*
* The IDE's LAN proxy presents a self-signed certificate. Its DER SHA-256
* fingerprint travels in the QR code (or the gateway rendezvous record) and is
* stored here per `host:port`. For a paired `host:port` the certificate is
* accepted ONLY when its fingerprint matches (system CAs are not consulted);
* for every other address the platform's default trust manager and OkHttp's
* default hostname verifier decide exactly as before.
*/
object AiturkLanTrust {
/** Where pins are persisted. Swappable so JVM tests need no Android keystore. */
interface PinPersistence {
fun load(): Map<String, String>
fun save(pins: Map<String, String>)
}
private const val PREF_KEY = "aiturk_lan_pins"
private val IPV4_HOST_PORT = Regex("""\d{1,3}(\.\d{1,3}){3}:\d{1,5}""")
private val pins = ConcurrentHashMap<String, String>()
@Volatile
private var loaded = false
@Volatile
internal var persistence: PinPersistence = EncryptedPrefsPersistence
private val platformTrustManager: X509TrustManager by lazy { defaultTrustManager() }
private val platformHostnameVerifier: HostnameVerifier by lazy { OkHttpClient.Builder().build().hostnameVerifier }
val trustManager: X509TrustManager by lazy { PinAwareTrustManager(platformTrustManager, ::pinFor, ::pinsForHost) }
val sslSocketFactory: SSLSocketFactory by lazy { socketFactoryFor(trustManager) }
val hostnameVerifier: HostnameVerifier by lazy { PinAwareHostnameVerifier(platformHostnameVerifier, ::pinFor) }
/**
* The single hook for [com.m57.hermescontrol.data.remote.OkHttpProvider.base]:
* every REST / WebSocket / media / image client derives from that builder.
* If the TLS objects cannot be built the builder is left untouched (platform
* defaults), which only means a paired IDE cannot be reached.
*/
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder =
runCatching {
builder
.sslSocketFactory(sslSocketFactory, trustManager)
.hostnameVerifier(hostnameVerifier)
// A relay pairing lives on a virtual host: DNS, socket and proxy
// choice are special-cased for that host only (see AiturkTunnel).
.let(AiturkTunnel::applyTo)
}.getOrDefault(builder)
/**
* A client that talks ONLY to a server presenting [fingerprint] — used for
* `hello` / `claim` before the pin is stored. Nothing else is trusted on it.
*/
fun pinnedClient(
base: OkHttpClient,
fingerprint: String,
tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
): OkHttpClient {
val lookup: (String) -> String? = { fingerprint }
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
return base
.newBuilder()
.sslSocketFactory(socketFactoryFor(manager), manager)
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
.let { AiturkTunnel.applyTo(it, tunnel) }
.build()
}
fun pin(
hostPort: String,
fingerprint: String,
) {
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
// Pins are for the IDE's own TLS proxy only: its LAN `ip:port`, or the
// virtual tunnel host. A real DNS name keeps the platform's checks.
require(IPV4_HOST_PORT.matches(hostPort) || isRelayHostPort(hostPort)) {
"Only an AITURK IDE address can be pinned"
}
ensureLoaded()
// Saving on top of an unread store would drop the pins already in it.
check(loaded) { "Secure storage is not ready" }
pins[hostPort] = fingerprint
persistence.save(pins.toMap())
}
fun unpin(hostPort: String) {
ensureLoaded()
if (pins.remove(hostPort) != null) persistence.save(pins.toMap())
}
fun pinFor(hostPort: String): String? {
ensureLoaded()
return pins[hostPort]
}
/** Pins of every paired port on [host] (the Android chain-cleaner path knows no port). */
internal fun pinsForHost(host: String): Collection<String> {
ensureLoaded()
return pins.filterKeys { it.substringBeforeLast(':') == host }.values
}
/** True when [baseUrl] is an HTTPS address of a paired AITURK IDE. */
fun isPairedUrl(baseUrl: String?): Boolean {
val url = baseUrl?.toHttpUrlOrNull() ?: return false
return url.isHttps && pinFor("${url.host}:${url.port}") != null
}
internal fun resetForTest(newPersistence: PinPersistence) {
persistence = newPersistence
pins.clear()
loaded = false
}
private fun ensureLoaded() {
if (loaded) return
synchronized(this) {
if (loaded) return
// Storage not ready yet (or broken): behave as "nothing paired" for
// now and look again on the next call. load() never blocks.
val stored = runCatching { persistence.load() }.getOrNull() ?: return
stored.forEach { (key, value) -> pins.putIfAbsent(key, value) }
loaded = true
}
}
/** DER SHA-256 of [certificate] compared in constant time with a base64url pin. */
internal fun matches(
certificate: X509Certificate,
fingerprint: String,
): Boolean {
val expected = AiturkPairing.decodeFingerprint(fingerprint) ?: return false
val actual = MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
return MessageDigest.isEqual(expected, actual)
}
private fun isRelayHostPort(hostPort: String): Boolean =
hostPort.endsWith(":${AiturkRelay.PORT}") && AiturkRelay.isVirtualHost(hostPort.substringBeforeLast(':'))
// Wrapped so that TLS over a tunnel stream is layered on a socket without a
// file descriptor; for every other socket the wrapper is a pass-through.
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
AiturkTunnelSslSocketFactory(
SSLContext
.getInstance("TLS")
.apply { init(null, arrayOf(manager), null) }
.socketFactory,
)
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
/** Pins live in the app's encrypted preferences next to the connection tokens. */
private object EncryptedPrefsPersistence : PinPersistence {
private fun prefs(): android.content.SharedPreferences {
// Never wait here: this runs inside TLS handshakes. A connection to a
// paired IDE cannot start before AuthManager is ready anyway, because
// its address and token come from the same store.
check(AuthManager.initializationState.value == AuthManager.InitializationState.Ready) {
"Secure storage is not ready"
}
return runBlocking { AuthManager.awaitPrefs() }
}
override fun load(): Map<String, String> {
val prefs = prefs()
val raw = prefs.getString(PREF_KEY, null) ?: return emptyMap()
return runCatching {
Json.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content }
}.getOrDefault(emptyMap())
}
override fun save(pins: Map<String, String>) {
val prefs = prefs()
val encoded = JsonObject(pins.mapValues { JsonPrimitive(it.value) }).toString()
prefs.edit().putString(PREF_KEY, encoded).apply()
}
}
}
/**
* Checks the leaf certificate against the pin of the peer `host:port` when one
* exists; otherwise defers to [delegate] untouched.
*/
internal class PinAwareTrustManager(
private val delegate: X509TrustManager,
private val pinFor: (String) -> String?,
private val pinsForHost: (String) -> Collection<String>,
) : X509ExtendedTrustManager() {
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket?,
) {
val pin = peerOf(socket)?.let(pinFor)
when {
pin != null -> requirePinned(chain, pin)
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, socket)
else -> delegate.checkServerTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine?,
) {
val pin = engine?.let { peerKey(it.peerHost, it.peerPort) }?.let(pinFor)
when {
pin != null -> requirePinned(chain, pin)
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, engine)
else -> delegate.checkServerTrusted(chain, authType)
}
}
/** No peer information: never apply a pin, platform validation only. */
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) = delegate.checkServerTrusted(chain, authType)
/**
* Android's hostname-aware entry point (`X509TrustManagerExtensions`, used by
* OkHttp's certificate chain cleaner). Looked up reflectively by the platform.
* The port is unknown here, so any pin stored for [host] may match. This is
* not the handshake's trust decision (that is the socket/engine overload).
*/
@Suppress("unused")
fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
host: String,
): List<X509Certificate> {
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
if (pinsForHost(host).any { AiturkLanTrust.matches(leaf, it) }) return listOf(leaf)
val hostAware =
runCatching {
delegate.javaClass.getMethod(
"checkServerTrusted",
Array<X509Certificate>::class.java,
String::class.java,
String::class.java,
)
}.getOrNull()
if (hostAware == null) {
delegate.checkServerTrusted(chain, authType)
return chain.toList()
}
try {
@Suppress("UNCHECKED_CAST")
return hostAware.invoke(delegate, chain, authType, host) as? List<X509Certificate> ?: chain.toList()
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException as? CertificateException ?: CertificateException(e.targetException)
}
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) = delegate.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket?,
) = delegate.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine?,
) = delegate.checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
private fun requirePinned(
chain: Array<X509Certificate>,
pin: String,
) {
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
if (!AiturkLanTrust.matches(leaf, pin)) {
throw CertificateException("AITURK IDE certificate fingerprint mismatch")
}
}
private fun peerOf(socket: Socket?): String? {
if (socket == null) return null
// Prefer the identity the TLS layer was asked to reach (the URL's host
// and port); fall back to the connected address.
val session = (socket as? SSLSocket)?.let { runCatching { it.handshakeSession }.getOrNull() }
return session?.let { peerKey(it.peerHost, it.peerPort) }
?: peerKey(socket.inetAddress?.hostAddress, socket.port)
}
private fun peerKey(
host: String?,
port: Int,
): String? = if (host.isNullOrBlank() || port <= 0) null else "$host:$port"
}
/** Hostname check: fingerprint for a paired `host:port`, [delegate] for everything else. */
internal class PinAwareHostnameVerifier(
private val delegate: HostnameVerifier,
private val pinFor: (String) -> String?,
) : HostnameVerifier {
override fun verify(
hostname: String,
session: SSLSession,
): Boolean {
val pin = pinFor("$hostname:${session.peerPort}") ?: return delegate.verify(hostname, session)
val leaf =
runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }.getOrNull()
?: return false
return AiturkLanTrust.matches(leaf, pin)
}
}
/** Used by [AiturkLanTrust.pinnedClient]: anything that is not the pinned certificate is refused. */
private object RejectAllTrustManager : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("Not trusted")
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("Not trusted")
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
@@ -0,0 +1,673 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.remote.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import java.io.IOException
import java.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.PortUnreachableException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateException
import java.util.Locale
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
enum class AiturkPairError {
/** Gateway `resolve` 404: unknown, expired or exhausted code. */
CODE_NOT_FOUND,
/** Gateway `resolve` 429. */
RATE_LIMITED,
/** Gateway unreachable or answered something unexpected. */
GATEWAY_UNAVAILABLE,
/** No LAN candidate answered `hello` with the pinned certificate in time. */
PC_UNREACHABLE,
/** Tunnel closed with 4503: AITURK IDE is not running or Phone connection is off. */
HOST_OFFLINE,
/** Tunnel closed with 4504, or the computer stayed silent inside the tunnel. */
HOST_TIMEOUT,
/** Tunnel closed with 4429: too many streams for this relay. */
RELAY_BUSY,
/** Tunnel closed with 4404: the relay id no longer exists. */
RELAY_UNKNOWN,
/** turkservis.online could not be reached at all (no internet, DNS, timeout). */
NO_INTERNET,
/** Inside the tunnel the certificate did not match the pairing fingerprint. */
TLS_PIN,
/** The tunnel ended in a way the contract does not name. */
RELAY_ERROR,
/** Only LAN candidates were left and the local-network permission is denied. */
LAN_PERMISSION,
/** `claim` 400 `invalid_request` (or 413). */
INVALID_REQUEST,
/** `claim` 401 `wrong_code`. */
WRONG_CODE,
/** `claim` 410 `pairing_closed` (not open, expired or code already used). */
PAIRING_CLOSED,
/** `claim` 410 `pairing_locked` (5 wrong attempts). */
PAIRING_LOCKED,
/** The computer answered, but not with what the contract promises. */
UNEXPECTED_RESPONSE,
}
/**
* [baseUrl] is the address the `claim` was sent to (trailing slash): either the
* LAN `https://<ip:port>/` ([endpoint] set) or the virtual tunnel host
* `https://<relayId>.relay.aiturk.invalid/` ([endpoint] null). In both cases the
* certificate is pinned to [fingerprint] under [hostPort].
*/
data class AiturkPairedDevice(
val baseUrl: String,
val hostPort: String,
val endpoint: AiturkEndpoint?,
val fingerprint: String,
val token: String,
val deviceId: String,
val pcName: String,
val version: String,
) {
val viaRelay: Boolean get() = endpoint == null
}
sealed interface AiturkPairResult<out T> {
data class Success<T>(
val value: T,
) : AiturkPairResult<T>
/**
* [code] is a short diagnostic shown in small print (`relay:host_offline`,
* `tls:pin`, `lan:timeout`, `relay:network,lan:refused` …). It is assembled only from
* the fixed vocabulary in [AiturkDiag] plus an HTTP status number: never a
* token, pairing code, address or exception message.
*/
data class Failure(
val error: AiturkPairError,
val code: String = "",
) : AiturkPairResult<Nothing>
}
/** Fixed vocabulary of the diagnostic codes. */
object AiturkDiag {
const val TIMEOUT = "timeout"
const val DNS = "dns"
const val TLS = "tls"
const val REFUSED = "refused"
const val UNREACHABLE = "unreachable"
const val PERMISSION = "permission"
const val IO = "io"
const val BAD_RESPONSE = "bad_response"
const val NO_CANDIDATES = "no_candidates"
const val PAIRING_CLOSED = "pairing_closed"
const val PIN = "pin"
const val NETWORK = "network"
private const val MAX_CAUSE_DEPTH = 6
private val safe = Regex("[a-z]{1,8}:[a-z0-9_]{1,24}(,[a-z]{1,8}:[a-z0-9_]{1,24})*")
/** Defence in depth for the UI: anything outside the vocabulary shape is not shown. */
fun sanitize(code: String?): String = code?.takeIf { it.length <= MAX_CODE_LENGTH && safe.matches(it) }.orEmpty()
/**
* Which kind of network failure [error] is. Looks at the exception TYPE and,
* for connect failures, at the errno name the platform puts in the message
* (`ECONNREFUSED`, `EHOSTUNREACH`, …). The message itself is never returned.
*/
fun networkKind(error: Throwable): String {
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
val text = chain.joinToString(" ") { it.message.orEmpty() }.uppercase(Locale.ROOT)
fun mentions(vararg needles: String) = needles.any { it in text }
return when {
chain.any { it is SSLException || it is CertificateException } -> TLS
chain.any { it is UnknownHostException } -> DNS
mentions("EPERM", "EACCES", "OPERATION NOT PERMITTED", "PERMISSION DENIED") -> PERMISSION
chain.any { it is NoRouteToHostException || it is PortUnreachableException } -> UNREACHABLE
mentions("EHOSTUNREACH", "ENETUNREACH", "NO ROUTE TO HOST", "NETWORK IS UNREACHABLE") -> UNREACHABLE
chain.any { it is SocketTimeoutException } || mentions("ETIMEDOUT") -> TIMEOUT
chain.any { it is ConnectException } -> REFUSED
// OkHttp's callTimeout surfaces as InterruptedIOException("timeout").
chain.any { it is InterruptedIOException } -> TIMEOUT
else -> IO
}
}
/**
* The same question for a request that went through the tunnel. The relay's
* own verdict (close code) wins; a certificate that does not match the
* pairing fingerprint is [PIN]; silence inside an open tunnel is [TIMEOUT];
* everything else is the network between the phone and turkservis.online.
*/
fun tunnelKind(error: Throwable): String {
AiturkTunnelException.find(error)?.let { return it.kind }
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
return when {
chain.any { it is CertificateException || it is javax.net.ssl.SSLPeerUnverifiedException } -> PIN
chain.any { it is InterruptedIOException } -> TIMEOUT
chain.any { it is SSLException } -> TLS
else -> NETWORK
}
}
/** `relay:<kind>`, except the pin failure which the contract names `tls:pin`. */
fun relayCode(kind: String): String = if (kind == PIN) "tls:$PIN" else "relay:$kind"
private const val MAX_CODE_LENGTH = 96
}
/**
* Phone side of the AITURK IDE pairing handshake.
*
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
* Contract: v1 (`aiturk-mobil-eslestirme-spec`) plus v3 (`aiturk-mobil-tunel-spec`).
*/
class AiturkPairingClient(
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
private val baseClient: OkHttpClient = defaultBaseClient,
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
private val relayHelloTimeoutMs: Long = RELAY_HELLO_TIMEOUT_MS,
private val tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
) {
/** Code path: `resolve` the rendezvous record for a normalized code. */
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
withContext(Dispatchers.IO) {
val body =
buildJsonObject { put("lookup", AiturkPairing.lookup(normalizedCode)) }
.toString()
.toRequestBody(JSON)
val request =
Request
.Builder()
.url(under(gatewayBaseUrl, RESOLVE_PATH))
.post(body)
.build()
val client =
baseClient
.newBuilder()
.callTimeout(GATEWAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
try {
client.newCall(request).await().use { response ->
when (response.code) {
200 -> parseOffer(readJson(response))
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND, "resolve:not_found")
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED, "resolve:rate_limited")
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:http_${response.code}")
}
}
} catch (_: IOException) {
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:network")
}
}
/**
* Tunnel first, LAN second, and exactly ONE `claim`:
* 1. [AiturkPairingOffer.relay] present → `hello` to the virtual tunnel host
* (TLS inside the tunnel, pinned to the offer's fingerprint). Answered →
* the single `claim` goes through the tunnel and the LAN candidates are
* never contacted.
* 2. Relay absent or failed → `hello` to every LAN candidate in parallel
* (same pin), single `claim` to the first answer.
*
* [lanAllowed] false (Android 17+ local-network permission denied) skips step
* 2 and reports `lan:permission`. When both steps fail the relay's failure is
* the user-facing error and both codes are kept (`relay:…,lan:…`).
*/
suspend fun pair(
offer: AiturkPairingOffer,
normalizedCode: String,
deviceName: String,
lanAllowed: Boolean = true,
): AiturkPairResult<AiturkPairedDevice> =
withContext(Dispatchers.IO) {
var relayFailure: AiturkPairResult.Failure? = null
// The only trust on this client is the offer's fingerprint: on the
// LAN and inside the tunnel alike.
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint, tunnel)
// Validated again here: an offer built by hand must not widen the target.
val relayId = offer.relay?.let(AiturkRelay::parse)
if (relayId != null) {
val relayBase = AiturkRelay.baseUrl(relayId).toHttpUrl()
when (val hello = relayHello(pinned, relayBase)) {
is Hello.Ok -> {
if (!hello.pairingOpen) {
return@withContext AiturkPairResult.Failure(
AiturkPairError.PAIRING_CLOSED,
"relay:${AiturkDiag.PAIRING_CLOSED}",
)
}
return@withContext claim(
pinned,
relayBase,
AiturkRelay.hostPort(relayId),
null,
offer.fingerprint,
normalizedCode,
deviceName,
)
}
is Hello.Failed -> {
relayFailure = AiturkPairResult.Failure(relayError(hello.kind), AiturkDiag.relayCode(hello.kind))
}
}
}
val lan = pairOverLan(pinned, offer, normalizedCode, deviceName, lanAllowed)
if (lan is AiturkPairResult.Failure && relayFailure != null && lan.code.startsWith("lan:")) {
// Nothing was claimed on the LAN either: the relay is the designed
// path, so its failure is what the user needs to hear about.
return@withContext AiturkPairResult.Failure(relayFailure.error, "${relayFailure.code},${lan.code}")
}
lan
}
private suspend fun pairOverLan(
pinned: OkHttpClient,
offer: AiturkPairingOffer,
normalizedCode: String,
deviceName: String,
lanAllowed: Boolean,
): AiturkPairResult<AiturkPairedDevice> {
if (offer.endpoints.isEmpty()) {
return AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE, "lan:${AiturkDiag.NO_CANDIDATES}")
}
if (!lanAllowed) {
return AiturkPairResult.Failure(AiturkPairError.LAN_PERMISSION, "lan:${AiturkDiag.PERMISSION}")
}
val reachable =
when (val hello = firstReachable(pinned, offer.endpoints)) {
is Hello.Ok -> hello
is Hello.Failed -> return AiturkPairResult.Failure(lanError(hello.kind), "lan:${hello.kind}")
}
if (!reachable.pairingOpen) {
// The computer says pairing is not open: a claim could only be
// answered with 410 pairing_closed, so do not spend an attempt.
return AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED, "lan:${AiturkDiag.PAIRING_CLOSED}")
}
val endpoint = checkNotNull(reachable.endpoint)
return claim(
pinned,
endpoint.baseUrl.toHttpUrl(),
endpoint.hostPort,
endpoint,
offer.fingerprint,
normalizedCode,
deviceName,
)
}
private sealed interface Hello {
data class Ok(
val endpoint: AiturkEndpoint?,
val pairingOpen: Boolean,
) : Hello
/** [kind] is one of the [AiturkDiag] words or `http_<status>`. */
data class Failed(
val kind: String,
) : Hello
}
private suspend fun relayHello(
pinned: OkHttpClient,
relayBase: HttpUrl,
): Hello {
val client =
pinned
.newBuilder()
.connectTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
.build()
return withTimeoutOrNull(relayHelloTimeoutMs + HELLO_MARGIN_MS) { hello(client, relayBase, null) }
?: Hello.Failed(AiturkDiag.TIMEOUT)
}
private suspend fun firstReachable(
pinned: OkHttpClient,
endpoints: List<AiturkEndpoint>,
): Hello {
val helloClient =
pinned
.newBuilder()
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.build()
val failures = CopyOnWriteArrayList<String>()
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
val winner =
withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
coroutineScope {
val answers = Channel<Hello>(endpoints.size)
endpoints.forEach { endpoint ->
launch { answers.send(hello(helloClient, endpoint.baseUrl.toHttpUrl(), endpoint)) }
}
var ok: Hello.Ok? = null
var received = 0
while (ok == null && received < endpoints.size) {
when (val answer = answers.receive()) {
is Hello.Ok -> ok = answer
is Hello.Failed -> failures += answer.kind
}
received++
}
coroutineContext.cancelChildren()
ok
}
}
if (winner != null) return winner
// Candidates still silent when the window closed count as timeouts.
if (failures.size < endpoints.size) failures += AiturkDiag.TIMEOUT
return Hello.Failed(mostTelling(failures))
}
private suspend fun hello(
client: OkHttpClient,
base: HttpUrl,
endpoint: AiturkEndpoint?,
): Hello =
try {
val request = Request.Builder().url(under(base, HELLO_PATH)).get().build()
client.newCall(request).await().use { response ->
val json = readJson(response)
when {
response.code != 200 -> {
Hello.Failed("http_${response.code}")
}
json?.string("app") != IDE_APP_ID -> {
Hello.Failed(AiturkDiag.BAD_RESPONSE)
}
else -> {
Hello.Ok(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
}
}
}
} catch (e: IOException) {
Hello.Failed(if (endpoint == null) AiturkDiag.tunnelKind(e) else AiturkDiag.networkKind(e))
}
private suspend fun claim(
client: OkHttpClient,
base: HttpUrl,
hostPort: String,
endpoint: AiturkEndpoint?,
fingerprint: String,
normalizedCode: String,
deviceName: String,
): AiturkPairResult<AiturkPairedDevice> {
val viaRelay = endpoint == null
val body =
buildJsonObject {
put("code", normalizedCode)
put("device_name", sanitizeDeviceName(deviceName))
}.toString().toRequestBody(JSON)
val request = Request.Builder().url(under(base, CLAIM_PATH)).post(body).build()
// The code is single-use and wrong attempts are counted: never let the
// HTTP stack silently replay this POST.
val claimClient =
client
.newBuilder()
.retryOnConnectionFailure(false)
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
return try {
claimClient.newCall(request).await().use { response ->
val json = readJson(response)
val errorName = json?.string("error")
when {
response.code == 200 -> {
parseClaim(json, base, hostPort, endpoint, fingerprint)
}
else -> {
val error = claimError(response.code, errorName)
AiturkPairResult.Failure(error, "claim:${claimCode(error, response.code)}")
}
}
}
} catch (e: IOException) {
// The claim may or may not have reached the computer: it is never
// retried and never re-sent over the other path.
if (viaRelay) {
val kind = AiturkDiag.tunnelKind(e)
AiturkPairResult.Failure(relayError(kind), "claim:relay_$kind")
} else {
val kind = AiturkDiag.networkKind(e)
AiturkPairResult.Failure(lanError(kind), "claim:lan_$kind")
}
}
}
private fun parseClaim(
json: JsonObject?,
base: HttpUrl,
hostPort: String,
endpoint: AiturkEndpoint?,
fingerprint: String,
): AiturkPairResult<AiturkPairedDevice> {
val token = json?.string("token")
if (token == null || !TOKEN_PATTERN.matches(token)) {
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "claim:${AiturkDiag.BAD_RESPONSE}")
}
return AiturkPairResult.Success(
AiturkPairedDevice(
baseUrl = base.toString(),
hostPort = hostPort,
endpoint = endpoint,
fingerprint = fingerprint,
token = token,
deviceId = json.string("device_id").orEmpty(),
pcName = json.string("pc_name").orEmpty().trim().take(MAX_NAME_LENGTH),
version = json.string("version").orEmpty(),
),
)
}
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
val bad = AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:${AiturkDiag.BAD_RESPONSE}")
val fingerprint = json?.string("fp")
if (json == null || fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint)) return bad
// The rendezvous record is untrusted input: only private IPv4 targets
// (0..4) and, optionally, a lowercase UUIDv4 relay id.
val urls =
when (val raw = json["urls"]) {
null, is JsonNull -> emptyList()
is JsonArray -> raw.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
else -> return bad
}
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
if (endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) return bad
val relay =
when (val raw = json["relay"]) {
null, is JsonNull -> null
is JsonPrimitive -> raw.takeIf { it.isString }?.content?.let(AiturkRelay::parse) ?: return bad
else -> return bad
}
if (endpoints.isEmpty() && relay == null) return bad
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint, relay))
}
/** [path] under [base], keeping whatever path prefix the base carries (the relay's). */
private fun under(
base: HttpUrl,
path: String,
): HttpUrl = base.newBuilder().addPathSegments(path).build()
private fun readJson(response: Response): JsonObject? =
runCatching {
val buffer = Buffer()
response.body.source().use { source ->
while (buffer.size < MAX_BODY_BYTES && source.read(buffer, MAX_BODY_BYTES - buffer.size) != -1L) {
// keep reading up to the cap
}
}
Json.parseToJsonElement(buffer.readUtf8()) as? JsonObject
}.getOrNull()
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
companion object {
const val DEFAULT_GATEWAY = "https://turkservis.online/"
const val HELLO_TIMEOUT_MS = 2_500L
const val RELAY_HELLO_TIMEOUT_MS = 10_000L
const val MAX_NAME_LENGTH = 60
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
private const val HELLO_PATH = "aiturk/pair/hello"
private const val CLAIM_PATH = "aiturk/pair/claim"
private const val IDE_APP_ID = "aiturk-ide"
private const val HELLO_MARGIN_MS = 500L
private const val CLAIM_TIMEOUT_MS = 15_000L
private const val GATEWAY_TIMEOUT_MS = 15_000L
private const val MAX_BODY_BYTES = 16L * 1024
private val JSON = "application/json; charset=utf-8".toMediaType()
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
private val LAN_KIND_PRIORITY =
listOf(
AiturkDiag.PERMISSION,
AiturkDiag.TLS,
AiturkDiag.BAD_RESPONSE,
AiturkDiag.REFUSED,
AiturkDiag.UNREACHABLE,
AiturkDiag.TIMEOUT,
)
// A bare client on purpose: no cookie jar, no user-configured custom
// headers and no Authorization stamping reach the gateway or `claim`.
private val defaultBaseClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
}
fun sanitizeDeviceName(raw: String): String =
raw
.filterNot { it.isISOControl() }
.trim()
.take(MAX_NAME_LENGTH)
.ifBlank { "Android" }
/** Tunnel `hello`/`claim` failure kind → what the user is told (contract v3 §3). */
internal fun relayError(kind: String): AiturkPairError =
when (kind) {
AiturkTunnelException.HOST_OFFLINE -> AiturkPairError.HOST_OFFLINE
AiturkTunnelException.UNKNOWN -> AiturkPairError.RELAY_UNKNOWN
AiturkTunnelException.BUSY -> AiturkPairError.RELAY_BUSY
AiturkTunnelException.TIMEOUT -> AiturkPairError.HOST_TIMEOUT
AiturkTunnelException.NETWORK -> AiturkPairError.NO_INTERNET
AiturkDiag.PIN -> AiturkPairError.TLS_PIN
else -> AiturkPairError.RELAY_ERROR
}
/** LAN failure kind → what the user is told. */
internal fun lanError(kind: String): AiturkPairError =
if (kind == AiturkDiag.PERMISSION) AiturkPairError.LAN_PERMISSION else AiturkPairError.PC_UNREACHABLE
/** Of several failed LAN candidates, the one that says most about the cause. */
internal fun mostTelling(kinds: List<String>): String =
LAN_KIND_PRIORITY.firstOrNull { it in kinds }
?: kinds.firstOrNull { it.startsWith("http_") }
?: kinds.firstOrNull()
?: AiturkDiag.TIMEOUT
private fun claimCode(
error: AiturkPairError,
httpCode: Int,
): String =
when (error) {
AiturkPairError.INVALID_REQUEST -> "invalid_request"
AiturkPairError.WRONG_CODE -> "wrong_code"
AiturkPairError.PAIRING_CLOSED -> "pairing_closed"
AiturkPairError.PAIRING_LOCKED -> "pairing_locked"
else -> "http_$httpCode"
}
internal fun claimError(
httpCode: Int,
errorName: String?,
): AiturkPairError =
when (errorName) {
"invalid_request" -> {
AiturkPairError.INVALID_REQUEST
}
"wrong_code" -> {
AiturkPairError.WRONG_CODE
}
"pairing_closed" -> {
AiturkPairError.PAIRING_CLOSED
}
"pairing_locked" -> {
AiturkPairError.PAIRING_LOCKED
}
else -> {
when (httpCode) {
400, 413 -> AiturkPairError.INVALID_REQUEST
401 -> AiturkPairError.WRONG_CODE
410 -> AiturkPairError.PAIRING_CLOSED
else -> AiturkPairError.UNEXPECTED_RESPONSE
}
}
}
}
}
@@ -0,0 +1,256 @@
package com.m57.hermescontrol.data.aiturk
import java.net.URI
import java.net.URLDecoder
import java.security.MessageDigest
import java.util.Base64
import java.util.Locale
/**
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
*
* QR / deep-link payload:
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
*
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
* sends by default; `u` (0..4) is only present when the user allowed direct
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
*/
data class AiturkEndpoint(
val host: String,
val port: Int,
) {
/** Key used by the certificate pin store. */
val hostPort: String get() = "$host:$port"
/** Canonical dashboard base URL (always HTTPS, trailing slash). */
val baseUrl: String get() = "https://$host:$port/"
}
/**
* A pairing offer: where the IDE listens and which certificate it must present
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
*/
data class AiturkPairingOffer(
val endpoints: List<AiturkEndpoint>,
val fingerprint: String,
val relay: String? = null,
)
data class AiturkPairingPayload(
val offer: AiturkPairingOffer,
val code: String,
)
enum class AiturkPayloadError {
/** Not an `aiturk://pair` link at all. */
NOT_AITURK,
/** `v` is present but not 1: the app is too old for this QR. */
UNSUPPORTED_VERSION,
/** Missing or malformed field (`v`, `u`, `fp`, `c`). */
MALFORMED,
/** `fp` missing or not a 43-char base64url SHA-256. */
BAD_FINGERPRINT,
/** A `u` entry is not `<private IPv4>:<port>`. */
BAD_ADDRESS,
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
BAD_ADDRESS_COUNT,
/** `r` is present but is not a lowercase UUIDv4 relay id. */
BAD_RELAY,
/** `c` is not 8 characters of the pairing alphabet. */
BAD_CODE,
}
sealed interface AiturkPayloadResult {
data class Ok(
val payload: AiturkPairingPayload,
) : AiturkPayloadResult
data class Invalid(
val error: AiturkPayloadError,
) : AiturkPayloadResult
}
object AiturkPairing {
const val SCHEME = "aiturk"
const val HOST = "pair"
const val VERSION = "1"
const val MAX_ENDPOINTS = 4
const val CODE_LENGTH = 8
const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
const val FINGERPRINT_LENGTH = 43
const val LOOKUP_PREFIX = "aiturk-mobile-pair-v1:"
private val fingerprintPattern = Regex("^[A-Za-z0-9_-]{43}$")
private val endpointPattern = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$""")
/** Quick check used by the deep-link entry point. */
fun looksLikePairingLink(raw: String?): Boolean =
raw != null && raw.trim().startsWith("$SCHEME://$HOST", ignoreCase = true)
fun parse(raw: String): AiturkPayloadResult {
val uri =
runCatching { URI(raw.trim()) }.getOrNull()
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
if (!SCHEME.equals(uri.scheme, ignoreCase = true) || !HOST.equals(uri.host, ignoreCase = true)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
}
val params =
runCatching { parseQuery(uri.rawQuery.orEmpty()) }.getOrNull()
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
fun single(name: String): String? = params.filter { it.first == name }.map { it.second }.singleOrNull()
// Version first: a newer QR may carry fields this build cannot judge.
val version = single("v") ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
if (version != VERSION) return AiturkPayloadResult.Invalid(AiturkPayloadError.UNSUPPORTED_VERSION)
val fingerprint = single("fp")
if (fingerprint == null || !isValidFingerprint(fingerprint)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
}
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
// never silently dropped in favour of the LAN candidates.
val rawRelays = params.filter { it.first == "r" }.map { it.second }
val relay =
when (rawRelays.size) {
0 -> null
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
}
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
}
val endpoints =
rawEndpoints.map {
parseEndpoint(it) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS)
}
val code =
single("c")?.let(::normalizeCode)
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_CODE)
return AiturkPayloadResult.Ok(
AiturkPairingPayload(
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
code = code,
),
)
}
/**
* Upper-cases (locale-independent), strips whitespace and `-`, and returns the
* code only when it is exactly 8 characters of [CODE_ALPHABET]; null otherwise.
*/
fun normalizeCode(raw: String): String? {
val stripped = raw.uppercase(Locale.ROOT).filterNot { it.isWhitespace() || it == '-' }
return stripped.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
}
/** Keeps only alphabet characters (upper-cased), capped at 8 — for the text field. */
fun cleanCodeInput(raw: String): String =
raw
.uppercase(Locale.ROOT)
.filter { it in CODE_ALPHABET }
.take(CODE_LENGTH)
/** `hex(SHA-256("aiturk-mobile-pair-v1:" + CODE))` — the raw code never leaves the phone. */
fun lookup(normalizedCode: String): String {
val digest =
MessageDigest
.getInstance("SHA-256")
.digest((LOOKUP_PREFIX + normalizedCode).toByteArray(Charsets.US_ASCII))
return digest.joinToString("") { "%02x".format(it) }
}
fun isValidFingerprint(value: String): Boolean = fingerprintPattern.matches(value) && decodeFingerprint(value) != null
/** The 32 SHA-256 bytes behind a base64url (unpadded) fingerprint, or null. */
fun decodeFingerprint(value: String): ByteArray? =
runCatching { Base64.getUrlDecoder().decode(value) }
.getOrNull()
?.takeIf { it.size == 32 }
/** `<private IPv4>:<port>`; no DNS names, no leading zeros, no public ranges. */
fun parseEndpoint(raw: String): AiturkEndpoint? {
val match = endpointPattern.matchEntire(raw) ?: return null
val parts = match.groupValues.drop(1)
if (parts.any { it.length > 1 && it.startsWith('0') }) return null
val octets = parts.take(4).map { it.toInt() }
if (octets.any { it > 255 }) return null
val port = parts[4].toInt()
if (port !in 1..65535) return null
val (a, b) = octets
val isPrivate = a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168)
if (!isPrivate) return null
return AiturkEndpoint(octets.joinToString("."), port)
}
/** Gateway `resolve` URLs are `https://<private IPv4>:<port>` (optional trailing slash). */
fun parseEndpointUrl(raw: String): AiturkEndpoint? {
if (!raw.startsWith("https://")) return null
return parseEndpoint(raw.removePrefix("https://").removeSuffix("/"))
}
private fun parseQuery(rawQuery: String): List<Pair<String, String>> =
rawQuery
.split('&')
.filter { it.isNotEmpty() }
.map { part ->
val index = part.indexOf('=')
require(index > 0) { "Query parameter without a value" }
decode(part.substring(0, index)) to decode(part.substring(index + 1))
}
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
}
/**
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
* internet; [AiturkTunnel] turns connections to it into an end-to-end
* encrypted stream through the relay.
*/
object AiturkRelay {
const val HOST_SUFFIX = ".relay.aiturk.invalid"
const val PORT = 443
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
private val relayId = Regex(UUID4)
/**
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
* surrounding whitespace.
*/
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
/** Key used by the certificate pin store for a relay pairing. */
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
/** The saved connection base: no path prefix, trailing slash. */
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
/** The relay id behind a virtual host name, or null for any other host. */
fun relayIdOfHost(host: String?): String? =
host
?.takeIf { it.endsWith(HOST_SUFFIX) }
?.removeSuffix(HOST_SUFFIX)
?.let(::parse)
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
}
@@ -0,0 +1,714 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.Socket
import java.net.SocketAddress
import java.net.SocketException
import java.net.SocketImpl
import java.net.SocketTimeoutException
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import javax.net.SocketFactory
import javax.net.ssl.SSLSocketFactory
/**
* End-to-end encrypted tunnel to a paired AITURK IDE through turkservis.online
* (contract v3, `aiturk-mobil-tunel-spec`).
*
* The relay only carries bytes. A paired relay connection has the virtual base
* address `https://<relayId>.relay.aiturk.invalid` (no path prefix). For such a
* host — and for no other — the shared OkHttp client:
* - resolves the name to a fixed placeholder address ([AiturkTunnelDns]);
* - opens `wss://turkservis.online/v1/aiturk/mobile-relay/t/<relayId>` instead
* of a TCP connection and exposes it as a socket ([AiturkTunnelSocket]);
* - runs TLS ON TOP of that socket, pinned to the IDE's certificate
* fingerprint ([AiturkLanTrust]). turkservis.online and anything in front of
* it see ciphertext only: no device token, path, chat text or file.
*/
object AiturkTunnel {
const val DEFAULT_RELAY_WS = "wss://turkservis.online/v1/aiturk/mobile-relay/t/"
const val MAX_FRAME_BYTES = 64 * 1024
/** How the byte stream is opened. Replaced by JVM tests with a local fake relay. */
@Volatile
internal var connector: AiturkTunnelConnector = WebSocketTunnelConnector()
/**
* The single hook for an OkHttp builder. Every host that is not a virtual
* relay host keeps the platform's DNS, plain TCP sockets and proxy choice.
*/
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder = applyTo(builder) { connector }
internal fun applyTo(
builder: OkHttpClient.Builder,
connector: () -> AiturkTunnelConnector,
): OkHttpClient.Builder =
builder
.dns(AiturkTunnelDns())
.socketFactory(AiturkTunnelSocketFactory(connector))
.proxySelector(AiturkTunnelProxySelector())
}
/**
* Why a tunnel stream could not be opened or ended. [kind] is one of the fixed
* words below (it becomes the `relay:<kind>` diagnostic code).
*/
class AiturkTunnelException(
val kind: String,
val closeCode: Int? = null,
cause: Throwable? = null,
) : IOException("AITURK tunnel: $kind", cause) {
companion object {
const val HOST_OFFLINE = "host_offline"
const val UNKNOWN = "unknown"
const val BUSY = "busy"
const val TIMEOUT = "timeout"
const val OVERFLOW = "overflow"
const val NETWORK = "network"
const val REJECTED = "rejected"
const val CLOSED = "closed"
/** Relay close code → kind; null for a normal end of stream. */
fun kindForCloseCode(code: Int): String? =
when (code) {
1000 -> null
4503 -> HOST_OFFLINE
4404 -> UNKNOWN
4429 -> BUSY
4504 -> TIMEOUT
4408 -> OVERFLOW
4400, 4403 -> REJECTED
else -> CLOSED
}
fun find(error: Throwable?): AiturkTunnelException? =
generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }
.take(MAX_CAUSE_DEPTH)
.filterIsInstance<AiturkTunnelException>()
.firstOrNull()
private const val MAX_CAUSE_DEPTH = 8
}
}
/** An open byte stream to the IDE's TLS proxy. Blocking, like a socket. */
interface AiturkTunnelChannel {
val input: InputStream
val output: OutputStream
var soTimeoutMs: Int
val isClosed: Boolean
fun close()
}
fun interface AiturkTunnelConnector {
/** Blocks until the stream is open; throws [AiturkTunnelException] otherwise. */
@Throws(IOException::class)
fun open(
relayId: String,
timeoutMs: Int,
): AiturkTunnelChannel
}
/** `*.relay.aiturk.invalid` → placeholder address (hostname kept); everything else → [delegate]. */
class AiturkTunnelDns(
private val delegate: Dns = Dns.SYSTEM,
) : Dns {
override fun lookup(hostname: String): List<InetAddress> =
if (AiturkRelay.isVirtualHost(hostname)) {
listOf(InetAddress.getByAddress(hostname, PLACEHOLDER))
} else {
delegate.lookup(hostname)
}
companion object {
// TEST-NET-1 (RFC 5737): never routed, and never dialled by the tunnel socket.
internal val PLACEHOLDER = byteArrayOf(192.toByte(), 0, 2, 1)
}
}
/** Virtual relay hosts are never sent through a proxy; other hosts use the system selector. */
class AiturkTunnelProxySelector : ProxySelector() {
override fun select(uri: URI?): List<Proxy> {
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return listOf(Proxy.NO_PROXY)
return runCatching { ProxySelector.getDefault()?.select(uri) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: listOf(Proxy.NO_PROXY)
}
override fun connectFailed(
uri: URI?,
sa: SocketAddress?,
ioe: IOException?,
) {
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return
runCatching { ProxySelector.getDefault()?.connectFailed(uri, sa, ioe) }
}
}
/**
* OkHttp asks for an unconnected socket and only then names the peer, so the
* socket decides in `connect()`: a virtual relay host becomes a tunnel stream,
* any other address is an ordinary TCP connection made by [java.net.Socket]
* itself.
*/
class AiturkTunnelSocketFactory internal constructor(
private val connector: () -> AiturkTunnelConnector,
) : SocketFactory() {
override fun createSocket(): Socket = AiturkTunnelSocket(connector)
override fun createSocket(
host: String?,
port: Int,
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
host: String?,
port: Int,
localHost: InetAddress?,
localPort: Int,
): Socket = createSocket(host, port)
override fun createSocket(
host: InetAddress?,
port: Int,
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
override fun createSocket(
address: InetAddress?,
port: Int,
localAddress: InetAddress?,
localPort: Int,
): Socket = createSocket(address, port)
}
/** A plain TCP socket unless it is connected to a virtual relay host. */
class AiturkTunnelSocket internal constructor(
private val connector: () -> AiturkTunnelConnector,
) : Socket() {
@Volatile
internal var channel: AiturkTunnelChannel? = null
private set
@Volatile
internal var tunnelPeer: InetSocketAddress? = null
private set
@Volatile
private var requestedSoTimeout = 0
override fun connect(endpoint: SocketAddress?) = connect(endpoint, 0)
override fun connect(
endpoint: SocketAddress?,
timeout: Int,
) {
val peer = endpoint as? InetSocketAddress
val relayId = peer?.hostString?.let(AiturkRelay::relayIdOfHost)
if (peer == null || relayId == null) {
super.connect(endpoint, timeout)
return
}
if (channel != null) throw SocketException("Already connected")
val opened = connector().open(relayId, if (timeout > 0) timeout else DEFAULT_CONNECT_TIMEOUT_MS)
opened.soTimeoutMs = requestedSoTimeout
tunnelPeer = peer
channel = opened
}
override fun getInputStream(): InputStream = channel?.input ?: super.getInputStream()
override fun getOutputStream(): OutputStream = channel?.output ?: super.getOutputStream()
override fun setSoTimeout(timeout: Int) {
requestedSoTimeout = timeout
val tunnel = channel
if (tunnel != null) tunnel.soTimeoutMs = timeout else super.setSoTimeout(timeout)
}
override fun getSoTimeout(): Int = channel?.soTimeoutMs ?: super.getSoTimeout()
override fun setTcpNoDelay(on: Boolean) {
if (channel == null) super.setTcpNoDelay(on)
}
override fun setKeepAlive(on: Boolean) {
if (channel == null) super.setKeepAlive(on)
}
override fun isConnected(): Boolean = channel != null || super.isConnected()
override fun isClosed(): Boolean = channel?.isClosed ?: super.isClosed()
override fun isInputShutdown(): Boolean = if (channel != null) false else super.isInputShutdown()
override fun isOutputShutdown(): Boolean = if (channel != null) false else super.isOutputShutdown()
override fun getInetAddress(): InetAddress? = tunnelPeer?.address ?: super.getInetAddress()
override fun getPort(): Int = tunnelPeer?.port ?: super.getPort()
override fun getRemoteSocketAddress(): SocketAddress? = tunnelPeer ?: super.getRemoteSocketAddress()
override fun shutdownInput() {
if (channel == null) super.shutdownInput()
}
override fun shutdownOutput() {
if (channel == null) super.shutdownOutput()
}
override fun close() {
channel?.close()
super.close()
}
private companion object {
const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000
}
}
/**
* The view of a tunnel that is handed to the TLS layer: a socket WITHOUT a
* platform socket implementation (and so without a file descriptor). Android's
* TLS provider then takes its stream-based path and reads/writes through
* [getInputStream] / [getOutputStream] instead of a native descriptor.
*/
internal class AiturkTunnelStreamSocket(
private val owner: AiturkTunnelSocket,
) : Socket(null as SocketImpl?) {
private val tunnel: AiturkTunnelChannel get() = owner.channel ?: throw SocketException("Socket is not connected")
override fun getInputStream(): InputStream = tunnel.input
override fun getOutputStream(): OutputStream = tunnel.output
override fun setSoTimeout(timeout: Int) = owner.setSoTimeout(timeout)
override fun getSoTimeout(): Int = owner.soTimeout
override fun isConnected(): Boolean = true
override fun isBound(): Boolean = true
override fun isClosed(): Boolean = owner.isClosed
override fun isInputShutdown(): Boolean = false
override fun isOutputShutdown(): Boolean = false
override fun getInetAddress(): InetAddress? = owner.inetAddress
override fun getPort(): Int = owner.port
override fun getRemoteSocketAddress(): SocketAddress? = owner.remoteSocketAddress
override fun getLocalAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0))
override fun getLocalPort(): Int = 0
override fun getLocalSocketAddress(): SocketAddress = InetSocketAddress(localAddress, 0)
override fun setTcpNoDelay(on: Boolean) = Unit
override fun getTcpNoDelay(): Boolean = true
override fun setKeepAlive(on: Boolean) = Unit
override fun getKeepAlive(): Boolean = false
override fun setSoLinger(
on: Boolean,
linger: Int,
) = Unit
override fun getSoLinger(): Int = -1
override fun setSendBufferSize(size: Int) = Unit
override fun getSendBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
override fun setReceiveBufferSize(size: Int) = Unit
override fun getReceiveBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
override fun shutdownInput() = Unit
override fun shutdownOutput() = Unit
override fun close() = owner.close()
override fun toString(): String = "AiturkTunnelStreamSocket"
}
/**
* Wraps a TLS socket factory so that TLS over a tunnel is layered on the
* descriptor-less [AiturkTunnelStreamSocket]. Every other socket is passed to
* [delegate] untouched.
*/
internal class AiturkTunnelSslSocketFactory(
private val delegate: SSLSocketFactory,
) : SSLSocketFactory() {
override fun createSocket(
s: Socket?,
host: String?,
port: Int,
autoClose: Boolean,
): Socket =
if (s is AiturkTunnelSocket && s.channel != null) {
// Closing the TLS socket must always close the tunnel stream.
delegate.createSocket(AiturkTunnelStreamSocket(s), host, port, true)
} else {
delegate.createSocket(s, host, port, autoClose)
}
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
override fun getSupportedCipherSuites(): Array<String> = delegate.supportedCipherSuites
override fun createSocket(): Socket = delegate.createSocket()
override fun createSocket(
host: String?,
port: Int,
): Socket = delegate.createSocket(host, port)
override fun createSocket(
host: String?,
port: Int,
localHost: InetAddress?,
localPort: Int,
): Socket = delegate.createSocket(host, port, localHost, localPort)
override fun createSocket(
host: InetAddress?,
port: Int,
): Socket = delegate.createSocket(host, port)
override fun createSocket(
address: InetAddress?,
port: Int,
localAddress: InetAddress?,
localPort: Int,
): Socket = delegate.createSocket(address, port, localAddress, localPort)
}
/**
* Opens the stream as a WebSocket to the relay with a separate, bare OkHttp
* client: platform TLS, no cookie jar, no custom headers, no `Origin`.
* Binary frames are raw bytes in both directions.
*/
class WebSocketTunnelConnector(
private val relayWsBase: String = AiturkTunnel.DEFAULT_RELAY_WS,
client: OkHttpClient? = null,
) : AiturkTunnelConnector {
private val client: OkHttpClient by lazy {
client ?: OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.pingInterval(PING_SECONDS, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
}
override fun open(
relayId: String,
timeoutMs: Int,
): AiturkTunnelChannel {
// Only a validated relay id is ever placed in the URL.
val id = AiturkRelay.parse(relayId) ?: throw AiturkTunnelException(AiturkTunnelException.UNKNOWN)
val channel = WebSocketTunnelChannel()
val socket = client.newWebSocket(Request.Builder().url(relayWsBase + id).build(), channel.listener)
channel.attach(socket)
val opened =
try {
channel.awaitOpen(timeoutMs.toLong())
} catch (e: InterruptedException) {
socket.cancel()
Thread.currentThread().interrupt()
throw AiturkTunnelException(AiturkTunnelException.NETWORK, cause = e)
}
if (!opened) {
socket.cancel()
throw channel.failure ?: AiturkTunnelException(AiturkTunnelException.NETWORK)
}
return channel
}
private companion object {
const val PING_SECONDS = 25L
}
}
internal class WebSocketTunnelChannel : AiturkTunnelChannel {
private val incoming = LinkedBlockingQueue<ByteString>()
private val openLatch = CountDownLatch(1)
private val flow = Object()
@Volatile
private var socket: WebSocket? = null
@Volatile
private var isOpen = false
@Volatile
private var ended = false
@Volatile
private var closedLocally = false
@Volatile
internal var failure: AiturkTunnelException? = null
private set
private var queuedBytes = 0L
@Volatile
override var soTimeoutMs: Int = 0
override val isClosed: Boolean get() = closedLocally
fun attach(webSocket: WebSocket) {
socket = webSocket
}
/** True when the relay accepted the stream; false when it failed or timed out. */
fun awaitOpen(timeoutMs: Long): Boolean = openLatch.await(timeoutMs, TimeUnit.MILLISECONDS) && isOpen
val listener: WebSocketListener =
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
isOpen = true
openLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
if (bytes.size == 0) return
synchronized(flow) {
// Applies back-pressure to the relay connection instead of
// buffering without bound when the reader is slow.
while (queuedBytes > MAX_BUFFERED_BYTES && !ended && !closedLocally) flow.wait(FLOW_WAIT_MS)
queuedBytes += bytes.size
}
incoming.put(bytes)
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
// The stream is binary only.
end(AiturkTunnelException(AiturkTunnelException.REJECTED))
webSocket.cancel()
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
webSocket.close(NORMAL_CLOSE, null)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) = end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
// An HTTP answer instead of the upgrade means the relay refused
// the stream; anything else is the network.
val kind = if (response != null) AiturkTunnelException.REJECTED else AiturkTunnelException.NETWORK
end(AiturkTunnelException(kind, cause = t))
}
}
private fun end(error: AiturkTunnelException?) {
synchronized(flow) {
if (ended) return
if (error != null && !closedLocally) failure = error
ended = true
flow.notifyAll()
}
incoming.offer(END)
openLatch.countDown()
}
override val input: InputStream =
object : InputStream() {
private var current: ByteString? = null
private var position = 0
override fun read(): Int {
val one = ByteArray(1)
return if (read(one, 0, 1) == -1) -1 else one[0].toInt() and BYTE_MASK
}
override fun read(
b: ByteArray,
off: Int,
len: Int,
): Int {
if (len == 0) return 0
var chunk = current
if (chunk == null) {
chunk = next() ?: return -1
current = chunk
position = 0
}
val count = minOf(len, chunk.size - position)
chunk.copyInto(position, b, off, count)
position += count
if (position == chunk.size) current = null
synchronized(flow) {
queuedBytes -= count
flow.notifyAll()
}
return count
}
override fun available(): Int = current?.let { it.size - position } ?: 0
override fun close() = this@WebSocketTunnelChannel.close()
/** The next chunk, or null at a clean end of stream. */
private fun next(): ByteString? {
if (closedLocally) throw SocketException("Socket closed")
val timeout = soTimeoutMs
val chunk =
try {
if (timeout > 0) incoming.poll(timeout.toLong(), TimeUnit.MILLISECONDS) else incoming.take()
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw java.io.InterruptedIOException("interrupted")
} ?: throw SocketTimeoutException("Read timed out")
if (chunk !== END) return chunk
incoming.offer(END)
if (closedLocally) throw SocketException("Socket closed")
failure?.let { throw AiturkTunnelException(it.kind, it.closeCode, it) }
return null
}
}
override val output: OutputStream =
object : OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(
b: ByteArray,
off: Int,
len: Int,
) {
var offset = off
var remaining = len
while (remaining > 0) {
val count = minOf(remaining, AiturkTunnel.MAX_FRAME_BYTES)
send(b.toByteString(offset, count))
offset += count
remaining -= count
}
}
override fun close() = this@WebSocketTunnelChannel.close()
}
private fun send(frame: ByteString) {
val webSocket = socket ?: throw SocketException("Socket is not connected")
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WRITE_STALL_MS)
// Keep the outbound queue small: the relay drops a stream whose
// pending data grows past its limit.
while (webSocket.queueSize() > MAX_PENDING_WRITE_BYTES) {
checkWritable()
if (System.nanoTime() > deadline) throw SocketTimeoutException("Write timed out")
try {
Thread.sleep(WRITE_POLL_MS)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw java.io.InterruptedIOException("interrupted")
}
}
checkWritable()
if (!webSocket.send(frame)) {
checkWritable()
throw AiturkTunnelException(AiturkTunnelException.CLOSED)
}
}
private fun checkWritable() {
if (closedLocally) throw SocketException("Socket closed")
if (ended) throw failure?.let { AiturkTunnelException(it.kind, it.closeCode, it) } ?: SocketException("Connection closed by peer")
}
override fun close() {
synchronized(flow) {
if (closedLocally) return
closedLocally = true
flow.notifyAll()
}
val webSocket = socket
if (webSocket != null && !webSocket.close(NORMAL_CLOSE, null)) webSocket.cancel()
incoming.offer(END)
openLatch.countDown()
}
private companion object {
val END: ByteString = ByteString.of(0)
const val NORMAL_CLOSE = 1000
const val BYTE_MASK = 0xFF
const val MAX_BUFFERED_BYTES = 1L * 1024 * 1024
const val MAX_PENDING_WRITE_BYTES = 256L * 1024
const val FLOW_WAIT_MS = 50L
const val WRITE_POLL_MS = 2L
const val WRITE_STALL_MS = 30_000L
}
}
/**
* What the tunnel last said about the computer behind a paired connection, for
* the chat's connection banner: "reconnecting…" alone does not tell the user
* that AITURK IDE is closed.
*/
object AiturkRelayStatus {
private val _hostOffline = MutableStateFlow(false)
val hostOffline: StateFlow<Boolean> = _hostOffline.asStateFlow()
/** [error] is what ended (or prevented) the app's WebSocket; null clears the note. */
fun onSocketEnded(error: Throwable?) {
_hostOffline.value = AiturkTunnelException.find(error)?.kind == AiturkTunnelException.HOST_OFFLINE
}
fun onSocketOpened() {
_hostOffline.value = false
}
}
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageProvenance import com.m57.hermescontrol.ui.chat.MessageProvenance
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
/** /**
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage]. * Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
messageProvenance = messageProvenance =
MessageProvenance.entries.firstOrNull { it.name == messageProvenance } MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
?: MessageProvenance.UNKNOWN, ?: MessageProvenance.UNKNOWN,
) ).normalizedGatewayNotice()
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity = fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
ChatMessageEntity( ChatMessageEntity(
@@ -76,7 +76,7 @@ data class MessagingPlatformUpdateResponse(
@Serializable @Serializable
data class TelegramOnboardingStartRequest( data class TelegramOnboardingStartRequest(
@SerialName("bot_name") val botName: String = "Hermes Agent", @SerialName("bot_name") val botName: String = "AITURK",
) )
@Serializable @Serializable
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.intOrNull
@@ -100,20 +101,10 @@ data class SessionMessage(
} }
val contentText: String val contentText: String
get() = get() = content?.transcriptText().orEmpty()
when (content) {
is JsonPrimitive -> content.content
null -> ""
else -> content.toString()
}
val displayContentText: String? val displayContentText: String?
get() = get() = display_content?.transcriptText()
when (display_content) {
is JsonPrimitive -> display_content.content
null -> null
else -> display_content.toString()
}
val reasoningText: String val reasoningText: String
get() = get() =
@@ -160,4 +151,25 @@ data class SessionMessage(
get() = tool_call_id.orEmpty() get() = tool_call_id.orEmpty()
} }
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
private fun JsonElement.transcriptText(): String =
when (this) {
is JsonPrimitive -> {
content
}
is JsonArray -> {
mapNotNull { part ->
val block = part as? JsonObject ?: return@mapNotNull null
val type = block["type"]
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
}.joinToString("\n")
}
else -> {
toString()
}
}
private val WHITESPACE = Regex("\\s+") private val WHITESPACE = Regex("\\s+")
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
@Synchronized @Synchronized
fun refresh(): String? = fun refresh(): String? =
try { try {
if (isAiturkPaired()) return null
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
suspend fun refreshAsync(): String? = suspend fun refreshAsync(): String? =
refreshMutex.withLock { refreshMutex.withLock {
try { try {
if (isAiturkPaired()) return@withLock null
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
@@ -37,6 +40,11 @@ internal object DashboardSessionTokenRefresher {
} }
} }
// AITURK: a paired IDE issues a long-lived device token and its LAN proxy
// answers 404 on `/` (the dashboard page must never leave the computer), so
// there is nothing to refresh and the stored token must stay as it is.
private fun isAiturkPaired(): Boolean = AiturkLanTrust.isPairedUrl(AuthManager.baseUrl())
internal suspend fun fetch( internal suspend fun fetch(
baseUrl: String, baseUrl: String,
client: OkHttpClient, client: OkHttpClient,
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -39,6 +40,9 @@ object OkHttpProvider {
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
// AITURK: a paired IDE's host:port is verified by certificate
// fingerprint; every other address keeps the platform TLS checks.
.let(AiturkLanTrust::applyTo)
.build() .build()
} }
@@ -37,7 +37,10 @@ object UpdateNoticeManager {
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never * disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
* hit the GitHub API or nag about updates. Tests flip this to true. * hit the GitHub API or nag about updates. Tests flip this to true.
*/ */
var enabled: Boolean = !BuildConfig.DEBUG // AITURK: off in every build. The upstream checker installs Hy4ri/hermes-mobile
// release APKs (different package id and signing key); AITURK Mobil builds are
// published at https://turkservis.online/indir.
var enabled: Boolean = false
internal set internal set
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */ /** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
@@ -279,7 +279,7 @@ object EventParser {
"status.update" -> { "status.update" -> {
val status = payload?.get("status") as? String val status = payload?.get("status") as? String
WsEvent.StatusUpdate(status, payload) WsEvent.StatusUpdate(status, payload, sessionId)
} }
"error" -> { "error" -> {
@@ -205,6 +205,7 @@ sealed class WsEvent {
data class StatusUpdate( data class StatusUpdate(
val status: String?, val status: String?,
val data: Map<String, Any?>?, val data: Map<String, Any?>?,
val sessionId: String? = null,
) : WsEvent() ) : WsEvent()
data class SessionUpdated( data class SessionUpdated(
@@ -730,6 +730,8 @@ internal class WsTransport(
// Do NOT log [reason] — it may carry server-side context. The // Do NOT log [reason] — it may carry server-side context. The
// reason is still inspected internally to detect auth failures. // reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code") Log.i(TAG, "WebSocket closed: $code")
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
.onSocketEnded(null)
connected.set(false) connected.set(false)
ActiveSessionHolder.clear() ActiveSessionHolder.clear()
connectionHealth.stop() connectionHealth.stop()
@@ -764,6 +766,10 @@ internal class WsTransport(
connectionHealth.stop() connectionHealth.stop()
rpcChannel.clearCapabilityRequests() rpcChannel.clearCapabilityRequests()
val code = response?.code ?: 0 val code = response?.code ?: 0
// AITURK: lets the chat banner say "AITURK IDE is not open on the
// computer" when the tunnel reports that no host is attached.
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
.onSocketEnded(t)
if (code == 401 || code == 4401 || code == 4403 || if (code == 401 || code == 4401 || code == 4403 ||
t.message?.contains("401") == true || t.message?.contains("401") == true ||
t.message?.contains("4401") == true || t.message?.contains("4401") == true ||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
@SerialName("hidden") val hidden: Boolean? = null, @SerialName("hidden") val hidden: Boolean? = null,
) )
/**
* The `source` this client declares for every session it mints or resumes.
*
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
* `session.resume`. The gateway resolves an omitted `source` from the host environment
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
* create and resume call sites cannot drift apart again.
*/
const val DESKTOP_SESSION_SOURCE = "desktop"
@Serializable @Serializable
data class SessionResumeParams( data class SessionResumeParams(
@SerialName("session_id") val sessionId: String, @SerialName("session_id") val sessionId: String,
@SerialName("source") val source: String? = null,
@SerialName("omit_messages") val omitMessages: Boolean? = null, @SerialName("omit_messages") val omitMessages: Boolean? = null,
@SerialName("profile") val profile: String? = null, @SerialName("profile") val profile: String? = null,
) )
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
@@ -61,7 +62,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
NotificationCompat NotificationCompat
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID) .Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Hermes") .setContentTitle("AITURK")
.setContentText("Replied") .setContentText("Replied")
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val params = val params =
SessionResumeParams( SessionResumeParams(
sessionId = storedSessionId, sessionId = storedSessionId,
source = DESKTOP_SESSION_SOURCE,
omitMessages = true, omitMessages = true,
profile = profile, profile = profile,
) )
@@ -0,0 +1,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,316 @@
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)
}
// The connection is already saved; the dialog only shows the security code
// (the same 8 characters AITURK IDE shows under the QR code) before leaving.
if (state.connectionSuccess) {
val securityCode = state.securityCode
if (securityCode == null) {
LaunchedEffect(Unit) { onConnected() }
} else {
AlertDialog(
onDismissRequest = onConnected,
title = { Text(stringResource(R.string.aiturk_pair_security_title)) },
text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) },
confirmButton = {
TextButton(onClick = onConnected) {
Text(stringResource(R.string.aiturk_pair_security_ok))
}
},
)
}
}
val deepLink by AiturkPairDeepLink.pending.collectAsState()
LaunchedEffect(deepLink) {
if (deepLink != null) AiturkPairDeepLink.take()?.let(viewModel::onDeepLink)
}
// The ViewModel is Activity-scoped: drop one-shot flags when the screen leaves.
DisposableEffect(Unit) {
onDispose { viewModel.clearTransientState() }
}
state.confirmAddress?.let { address ->
AlertDialog(
onDismissRequest = viewModel::dismissDeepLink,
title = { Text(stringResource(R.string.aiturk_pair_confirm_title)) },
text = { Text(stringResource(R.string.aiturk_pair_confirm_message, address)) },
confirmButton = {
TextButton(onClick = { viewModel.confirmDeepLink(lanPermissionGranted(context)) }) {
Text(stringResource(R.string.aiturk_pair_connect))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeepLink) {
Text(stringResource(R.string.aiturk_pair_cancel))
}
},
)
}
Box(
modifier = modifier.fillMaxSize().imePadding(),
contentAlignment = Alignment.Center,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Filled.Computer,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp),
)
Text(
text = stringResource(R.string.aiturk_pair_title),
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.aiturk_pair_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (hasPermission(context, Manifest.permission.CAMERA)) {
launchScanner()
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Icon(Icons.Filled.QrCodeScanner, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.aiturk_pair_scan_qr), style = MaterialTheme.typography.titleMedium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
HorizontalDivider(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.aiturk_pair_or),
modifier = Modifier.padding(horizontal = 12.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
label = { Text(stringResource(R.string.aiturk_pair_code_label)) },
placeholder = { Text(stringResource(R.string.aiturk_pair_code_placeholder)) },
singleLine = true,
enabled = !state.busy,
isError = state.errorRes != null,
visualTransformation = PairingCodeTransformation,
textStyle =
MaterialTheme.typography.headlineSmall.copy(
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
),
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.Characters,
autoCorrectEnabled = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { viewModel.submitCode(lanPermissionGranted(context)) }),
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { viewModel.submitCode(lanPermissionGranted(context)) },
enabled = !state.busy && state.code.length == AiturkPairing.CODE_LENGTH,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text(stringResource(R.string.aiturk_pair_connect), style = MaterialTheme.typography.titleMedium)
}
if (state.busy) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
Text(
text =
stringResource(
if (state.step == AiturkPairStep.RESOLVING) {
R.string.aiturk_pair_progress_resolve
} else {
R.string.aiturk_pair_progress_connect
},
),
style = MaterialTheme.typography.bodyMedium,
)
}
}
state.errorRes?.let { res ->
Text(
text = stringResource(res),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
// Diagnostic code for support: fixed words only, never a token, code or address.
if (state.errorCode.isNotEmpty()) {
Text(
text = stringResource(R.string.aiturk_pair_error_code, state.errorCode),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
textAlign = TextAlign.Center,
)
}
}
TextButton(onClick = onBack, enabled = !state.busy) {
Text(stringResource(R.string.aiturk_pair_back))
}
}
}
}
private fun hasPermission(
context: Context,
permission: String,
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
/** Android 17+ gates LAN hosts behind ACCESS_LOCAL_NETWORK; older releases need nothing. */
private fun lanPermissionGranted(context: Context): Boolean =
!requiresLocalNetworkPermission(Build.VERSION.SDK_INT, LAN_PROBE_URL) ||
hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
/** Shows `ABCDEFGH` as `ABCD-EFGH` without storing the dash. */
private object PairingCodeTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val raw = text.text
val shown = if (raw.length > CODE_GROUP) raw.substring(0, CODE_GROUP) + "-" + raw.substring(CODE_GROUP) else raw
val mapping =
object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int = if (offset > CODE_GROUP) offset + 1 else offset
override fun transformedToOriginal(offset: Int): Int = if (offset > CODE_GROUP) offset - 1 else offset
}
return TransformedText(AnnotatedString(shown), mapping)
}
}
@@ -0,0 +1,322 @@
package com.m57.hermescontrol.ui.aiturkpair
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
import com.m57.hermescontrol.data.aiturk.AiturkDiag
import com.m57.hermescontrol.data.aiturk.AiturkPairError
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
import com.m57.hermescontrol.data.aiturk.AiturkPairing
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class AiturkPairStep { IDLE, RESOLVING, CONNECTING }
data class AiturkPairUiState(
val code: String = "",
val step: AiturkPairStep = AiturkPairStep.IDLE,
@param:StringRes val errorRes: Int? = null,
/** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */
val errorCode: String = "",
/** Address of a deep-link pairing waiting for the user's confirmation. */
val confirmAddress: String? = null,
val lanPermissionNeeded: Boolean = false,
val connectionSuccess: Boolean = false,
/** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */
val securityCode: String? = null,
) {
val busy: Boolean get() = step != AiturkPairStep.IDLE
}
class AiturkPairViewModel(
private val client: AiturkPairingClient = AiturkPairingClient(),
private val register: suspend (AiturkPairedDevice) -> Unit = AiturkConnectionRegistrar::register,
private val deviceName: String = defaultDeviceName(),
) : ViewModel() {
private sealed interface Request {
data class Code(
val code: String,
) : Request
data class Payload(
val payload: AiturkPairingPayload,
) : Request
}
private val _uiState = MutableStateFlow(AiturkPairUiState())
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
private var pending: AiturkPairingPayload? = null
private var confirmPayload: AiturkPairingPayload? = null
fun onCodeChange(raw: String) {
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") }
}
fun submitCode(lanPermissionGranted: Boolean) {
val code = AiturkPairing.normalizeCode(_uiState.value.code)
if (code == null) {
showError(R.string.aiturk_pair_error_code_invalid)
return
}
start(Request.Code(code), lanPermissionGranted)
}
/** [raw] is null when the user backed out of the scanner. */
fun onQrScanned(
raw: String?,
lanPermissionGranted: Boolean,
) {
if (raw == null) return
when (val result = AiturkPairing.parse(raw)) {
is AiturkPayloadResult.Ok -> start(Request.Payload(result.payload), lanPermissionGranted)
is AiturkPayloadResult.Invalid -> showError(payloadErrorRes(result.error))
}
}
/** A link from outside the app is never acted on without an explicit confirmation. */
fun onDeepLink(raw: String) {
if (_uiState.value.busy) return
when (val result = AiturkPairing.parse(raw)) {
is AiturkPayloadResult.Ok -> {
confirmPayload = result.payload
// A relay-only link (the default) has no LAN address to show.
val address =
result.payload.offer.endpoints
.firstOrNull()
?.hostPort ?: RELAY_DISPLAY_NAME
_uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") }
}
is AiturkPayloadResult.Invalid -> {
showError(payloadErrorRes(result.error))
}
}
}
fun confirmDeepLink(lanPermissionGranted: Boolean) {
val payload = confirmPayload ?: return
dismissDeepLink()
start(Request.Payload(payload), lanPermissionGranted)
}
fun dismissDeepLink() {
confirmPayload = null
_uiState.update { it.copy(confirmAddress = null) }
}
fun onCameraPermissionDenied() = showError(R.string.aiturk_pair_error_camera_denied)
fun onLanPermissionResult(granted: Boolean) {
_uiState.update { it.copy(lanPermissionNeeded = false) }
val payload = pending
pending = null
// A denied permission no longer stops the pairing: the tunnel needs no
// local-network access. Only the LAN candidates are skipped, and if
// nothing else works the result says so (`lan:permission`).
if (payload != null) run(payload, lanAllowed = granted)
}
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
fun clearTransientState() {
pending = null
confirmPayload = null
_uiState.update {
it.copy(
errorRes = null,
errorCode = "",
confirmAddress = null,
lanPermissionNeeded = false,
connectionSuccess = false,
securityCode = null,
)
}
}
private fun start(
request: Request,
lanPermissionGranted: Boolean,
) {
if (_uiState.value.busy) return
when (request) {
is Request.Payload -> {
connect(request.payload, lanPermissionGranted)
}
is Request.Code -> {
_uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") }
viewModelScope.launch {
val resolved =
try {
client.resolve(request.code)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
}
_uiState.update { it.copy(step = AiturkPairStep.IDLE) }
when (resolved) {
is AiturkPairResult.Success -> {
connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted)
}
is AiturkPairResult.Failure -> {
showFailure(resolved)
}
}
}
}
}
}
/**
* The local-network permission is asked for only when a LAN candidate may
* actually be tried. A pairing that carries only a relay id (the default)
* goes through the tunnel and never asks.
*/
private fun connect(
payload: AiturkPairingPayload,
lanPermissionGranted: Boolean,
) {
if (_uiState.value.busy) return
if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) {
pending = payload
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") }
return
}
run(payload, lanAllowed = lanPermissionGranted)
}
private fun run(
payload: AiturkPairingPayload,
lanAllowed: Boolean,
) {
if (_uiState.value.busy) return
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") }
viewModelScope.launch {
val outcome =
try {
client.pair(payload.offer, payload.code, deviceName, lanAllowed)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
}
when (outcome) {
is AiturkPairResult.Success -> {
val saved = runCatching { register(outcome.value) }.isSuccess
_uiState.update {
it.copy(
step = AiturkPairStep.IDLE,
code = "",
connectionSuccess = saved,
securityCode = if (saved) securityCode(outcome.value.fingerprint) else null,
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
errorCode = if (saved) "" else "app:save_failed",
)
}
}
is AiturkPairResult.Failure -> {
showFailure(outcome)
}
}
}
}
private fun showFailure(failure: AiturkPairResult.Failure) {
_uiState.update {
it.copy(
step = AiturkPairStep.IDLE,
errorRes = pairErrorRes(failure.error),
errorCode = AiturkDiag.sanitize(failure.code),
)
}
}
private fun showError(
@StringRes res: Int,
) {
_uiState.update { it.copy(errorRes = res, errorCode = "") }
}
companion object {
const val SECURITY_CODE_LENGTH = 8
private const val RELAY_DISPLAY_NAME = "turkservis.online"
/** What both screens show so the user can compare them. */
fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH)
@StringRes
fun payloadErrorRes(error: AiturkPayloadError): Int =
when (error) {
AiturkPayloadError.NOT_AITURK -> R.string.aiturk_pair_error_qr_not_aiturk
AiturkPayloadError.UNSUPPORTED_VERSION -> R.string.aiturk_pair_error_update_app
AiturkPayloadError.MALFORMED,
AiturkPayloadError.BAD_FINGERPRINT,
AiturkPayloadError.BAD_ADDRESS,
AiturkPayloadError.BAD_ADDRESS_COUNT,
AiturkPayloadError.BAD_RELAY,
AiturkPayloadError.BAD_CODE,
-> R.string.aiturk_pair_error_qr_invalid
}
@StringRes
fun pairErrorRes(error: AiturkPairError): Int =
when (error) {
AiturkPairError.CODE_NOT_FOUND -> R.string.aiturk_pair_error_code_not_found
AiturkPairError.RATE_LIMITED -> R.string.aiturk_pair_error_rate_limited
AiturkPairError.GATEWAY_UNAVAILABLE -> R.string.aiturk_pair_error_gateway
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline
AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout
AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy
AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown
AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin
AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet
AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay
AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
AiturkPairError.PAIRING_LOCKED -> R.string.aiturk_pair_error_pairing_locked
AiturkPairError.INVALID_REQUEST,
AiturkPairError.UNEXPECTED_RESPONSE,
-> R.string.aiturk_pair_error_unexpected
}
private fun defaultDeviceName(): String {
val maker = android.os.Build.MANUFACTURER.orEmpty()
val model = android.os.Build.MODEL.orEmpty()
val name = if (model.startsWith(maker, ignoreCase = true)) model else "$maker $model"
return AiturkPairingClient.sanitizeDeviceName(name)
}
}
}
@@ -0,0 +1,9 @@
package com.m57.hermescontrol.ui.aiturkpair
import com.journeyapps.barcodescanner.CaptureActivity
/**
* The ZXing capture screen under our own manifest entry, so it follows the
* device orientation instead of the library default (landscape only).
*/
class AiturkQrCaptureActivity : CaptureActivity()
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
@@ -564,7 +565,7 @@ class GroupChatViewModel(
method = RpcMethods.SESSION_CREATE, method = RpcMethods.SESSION_CREATE,
params = params =
SessionCreateParams( SessionCreateParams(
source = "desktop", source = DESKTOP_SESSION_SOURCE,
profile = bot.name, profile = bot.name,
title = title, title = title,
hidden = true, hidden = true,
@@ -322,7 +322,7 @@ class ChannelsViewModel :
val result = val result =
safeApiCall { safeApiCall {
ApiClient.hermesApi.startTelegramOnboarding( ApiClient.hermesApi.startTelegramOnboarding(
TelegramOnboardingStartRequest(botName = "Hermes Agent"), TelegramOnboardingStartRequest(botName = "AITURK"),
) )
} }
_uiState.update { _uiState.update {
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.ui.chat
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
internal data class ChatAttachmentTarget(
val sessionId: String,
val generation: Long,
val baseUrl: String,
val connectionProfileId: String?,
val agentProfileId: String?,
)
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
/** /**
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a * Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
toolCount = 0 toolCount = 0
} }
message.role == MessageRole.TOOL -> { message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
toolCount += 1 toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[message.id] = toolCount milestones[message.id] = toolCount
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
toolCount = 0 toolCount = 0
} }
message.role == MessageRole.TOOL -> { message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
toolCount += 1 toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) { if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount milestones[index] = toolCount
@@ -0,0 +1,175 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import com.m57.hermescontrol.data.model.Attachment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.UUID
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
/**
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
* never run during a live session where an old attachment may still be uploading.
*/
internal fun cleanStalePastedImages(
cacheDir: File,
nowMs: Long = System.currentTimeMillis(),
) {
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
for (file in files) {
runCatching {
// Do not traverse subdirectories or touch any other cache namespace.
if (file.isFile && file.lastModified() < cutoff) file.delete()
}
}
}
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
class PastedImage(
val attachment: Attachment,
val file: File,
) {
/** Call if the import cannot be queued (for example, after a session switch). */
fun discard() {
file.delete()
}
}
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
class ChatPastedImageStore(
private val context: Context,
) {
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
suspend fun importImage(
uri: Uri,
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
): PastedImage? {
var pending: PastedImage? = null
var delivered = false
try {
val result =
withContext(Dispatchers.IO) {
if (uri.scheme != "content") return@withContext null
val resolver = context.contentResolver
val mimeType =
resolver
.getType(uri)
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
?: return@withContext null
val extension =
MimeTypeMap
.getSingleton()
.getExtensionFromMimeType(mimeType)
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
?: "img"
val input = resolver.openInputStream(uri) ?: return@withContext null
val activeContext = currentCoroutineContext()
val file =
stagePastedImage(
input = input,
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
extension = extension,
maxBytes = maxBytes,
checkActive = { activeContext.ensureActive() },
) ?: return@withContext null
try {
activeContext.ensureActive()
val privateUri =
FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
PastedImage(
attachment =
Attachment(
uri = privateUri.toString(),
name = file.name,
mimeType = mimeType,
size = file.length(),
),
file = file,
).also { pending = it }
} catch (failure: Throwable) {
file.delete()
throw failure
}
}
delivered = true
return result
} finally {
// withContext may discard its completed IO result if the parent was cancelled
// during dispatch back; in that case ownership never reached the caller.
if (!delivered) pending?.discard()
}
}
}
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
internal fun stagePastedImage(
input: InputStream,
directory: File,
extension: String,
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
checkActive: () -> Unit = {},
newId: () -> String = { UUID.randomUUID().toString() },
): File? {
require(maxBytes > 0) { "maxBytes must be positive" }
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
var owned: File? = null
var keep = false
try {
return input
.use source@{ source ->
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
throw IOException("Cannot create pasted image cache directory")
}
val file = File(directory, "${UUID.fromString(newId())}.$extension")
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
owned = file
var total = 0L
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
FileOutputStream(file).use { output ->
while (true) {
checkActive()
// Probe one byte beyond the limit without trusting provider-reported size.
val remaining = effectiveMaxBytes - total
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
val count = source.read(buffer, 0, requested)
if (count < 0) break
if (count > remaining) throw PastedImageTooLargeException()
if (count == 0) {
val byte = source.read()
if (byte < 0) break
if (remaining == 0L) throw PastedImageTooLargeException()
output.write(byte)
total++
continue
}
output.write(buffer, 0, count)
total += count
}
checkActive()
}
if (total == 0L) null else file
}.also { keep = it != null }
} finally {
if (!keep) owned?.delete()
}
}
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -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.SpeechText
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
import com.m57.hermescontrol.ui.chat.components.tailContentKey import com.m57.hermescontrol.ui.chat.components.tailContentKey
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
@@ -338,20 +342,18 @@ fun ChatScreen(
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty()) !timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
} }
} }
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { val inputState = rememberTextFieldState()
mutableStateOf(TextFieldValue(""))
}
LaunchedEffect(state.pendingPrefillText) { LaunchedEffect(state.pendingPrefillText) {
val prefill = state.pendingPrefillText val prefill = state.pendingPrefillText
if (prefill != null) { if (prefill != null) {
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill) inputState.replaceComposerDraft(prefill)
viewModel.consumePendingPrefill() viewModel.consumePendingPrefill()
} }
} }
LaunchedEffect(state.composerTextToRestore) { LaunchedEffect(state.composerTextToRestore) {
state.composerTextToRestore?.let { text -> state.composerTextToRestore?.let { text ->
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue) inputState.restoreRejectedComposerDraft(text)
viewModel.consumeComposerTextRestore() viewModel.consumeComposerTextRestore()
} }
} }
@@ -364,6 +366,10 @@ fun ChatScreen(
} }
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) } var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val imagePaste =
rememberChatImagePaste(viewModel) { message ->
scrollScope.launch { snackbarHostState.showSnackbar(message) }
}
val launchExternalActivity: (() -> Unit) -> Unit = { launch -> val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity( ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -411,8 +417,7 @@ fun ChatScreen(
val mediaLaunchers = val mediaLaunchers =
rememberChatMediaLaunchers( rememberChatMediaLaunchers(
inputFieldValue = inputFieldValue, inputState = inputState,
onInputFieldValueChange = { inputFieldValue = it },
onAddAttachment = { uri, name, mimeType, size -> onAddAttachment = { uri, name, mimeType, size ->
viewModel.addAttachment(uri, name, mimeType, size) viewModel.addAttachment(uri, name, mimeType, size)
}, },
@@ -785,7 +790,7 @@ fun ChatScreen(
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) } scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
}, },
onShare = { details -> onShare = { details ->
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details" val report = "AITURK Mobil ${BuildConfig.VERSION_NAME}\n\n$details"
val intent = val intent =
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
@@ -859,15 +864,18 @@ fun ChatScreen(
mainTurnBusy = state.isMainTurnBusy, mainTurnBusy = state.isMainTurnBusy,
onSendAgain = viewModel::sendQueuedNow, onSendAgain = viewModel::sendQueuedNow,
onDiscard = viewModel::discardPendingSend, onDiscard = viewModel::discardPendingSend,
onOpenAttachment = viewModel::openAttachment,
onImageClick = { viewingImage = it },
) )
} }
ChatInputBar( ChatInputBar(
inputFieldValue = inputFieldValue, inputState = inputState,
onInputChange = { inputFieldValue = it }, receiveContentListener = imagePaste,
isReceivingContent = imagePaste.isReceiving,
onSend = { onSend = {
if (viewModel.sendMessage(inputFieldValue.text)) { if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
inputFieldValue = TextFieldValue("") inputState.clearText()
// Jump only after an accepted send. A readiness race keeps the draft intact. // Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true) scrollController.jumpToBottom(animated = true)
} }
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
@@ -100,7 +101,7 @@ class ChatSearchController {
} }
MessageRole.TOOL -> { MessageRole.TOOL -> {
scan(idx, searchableToolText(message), SearchTarget.TOOL) if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
} }
MessageRole.SYSTEM -> { MessageRole.SYSTEM -> {
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
.toSet() .toSet()
} }
/**
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
* page containing it proves delivery even when the merge could not alias the local bubble.
*/
internal fun pendingSendIdsConfirmedByRowIds(
pageRowIds: Set<Long>,
pending: List<PendingSend>,
): Set<String> =
pending
.asSequence()
.filter {
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
.map { it.id }
.toSet()
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */ /** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
class ChatSendStore( class ChatSendStore(
private val prefs: SharedPreferences? = null, private val prefs: SharedPreferences? = null,
@@ -158,9 +177,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
val recoveryIds = val recoveryIds =
pending pending
.filter { .filter {
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
it.state == PendingSendState.REJECTED
}.mapTo(mutableSetOf()) { it.id } }.mapTo(mutableSetOf()) { it.id }
if (recoveryIds.isEmpty()) return messages if (recoveryIds.isEmpty()) return messages
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null } return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
} }
/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */
internal val PendingSend.needsRecovery: Boolean
get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
rawContent rawContent
} }
var attachments: List<Attachment>? = null var attachments: List<Attachment>? = null
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) { if (role == MessageRole.USER) {
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
val items = HostMediaExtractor.extract(rawContent) val items = HostMediaExtractor.extract(rawContent)
if (items.isNotEmpty()) { if (items.isNotEmpty()) {
finalContent = HostMediaExtractor.strip(rawContent) finalContent = HostMediaExtractor.strip(rawContent)
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
completionId = completionId, completionId = completionId,
serverRowId = msg.id?.toLong()?.takeIf { it > 0L }, serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
reactions = msg.reactions, reactions = msg.reactions,
), ).normalizedGatewayNotice(),
) )
} }
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
match.id to match.id to
rich.copy( rich.copy(
id = match.id, id = match.id,
attachments =
if (rich.role == MessageRole.USER) {
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
} else {
rich.attachments
},
content = preservedContent ?: rich.content, content = preservedContent ?: rich.content,
restId = match.canonicalRestId ?: message.canonicalRestId, restId = match.canonicalRestId ?: message.canonicalRestId,
serverRowId = match.serverRowId ?: message.serverRowId, serverRowId = match.serverRowId ?: message.serverRowId,
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
when { when {
match?.role == MessageRole.USER -> { match?.role == MessageRole.USER -> {
match.copy( match.copy(
// #1432: cached user rows lack attachment metadata; hydrate from REST,
// while preserving richer optimistic/local attachments when present.
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
content =
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
message.content
} else {
match.content
},
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id }, restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
serverRowId = message.serverRowId ?: match.serverRowId, serverRowId = message.serverRowId ?: match.serverRowId,
reactions = message.reactions.ifEmpty { match.reactions }, reactions = message.reactions.ifEmpty { match.reactions },
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
.mapNotNull { index -> .mapNotNull { index ->
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } } matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
}.toMap() }.toMap()
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true) return dedupeCachedMessages(
.reconcileReasoningRows() transcript.inTranscriptOrder(
previous = current,
resolvedOrders = resolvedOrders,
observedSuccessorAnchors = current,
),
confirmedOnly = true,
).reconcileReasoningRows()
} }
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */ /** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
private fun List<ChatMessage>.inTranscriptOrder( private fun List<ChatMessage>.inTranscriptOrder(
previous: List<ChatMessage>, previous: List<ChatMessage>,
resolvedOrders: Map<String, Long>, resolvedOrders: Map<String, Long>,
observedSuccessorAnchors: List<ChatMessage>? = null,
): List<ChatMessage> { ): List<ChatMessage> {
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
// Session-start markers use -1 and must remain before unresolved cached history. // Session-start markers use -1 and must remain before unresolved cached history.
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
var pendingLocalOrder: Long? = null var pendingLocalOrder: Long? = null
val localAnchors = mutableMapOf<String, Long>() val localAnchors = mutableMapOf<String, Long>()
val pendingOrderByLocal = mutableMapOf<String, Long>() val pendingOrderByLocal = mutableMapOf<String, Long>()
// #1451: an observed live block must stay before its later confirmed USER successor,
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
// observed current lists (never cache concatenations) and only from later USER prompts.
val nextCanonicalOrder = mutableMapOf<String, Long>()
val anchorSource = observedSuccessorAnchors ?: emptyList()
var followingUserCanonical: Long? = null
for (i in anchorSource.indices.reversed()) {
val message = anchorSource[i]
val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null && message.role == MessageRole.USER) {
followingUserCanonical = order
} else if (followingUserCanonical != null) {
nextCanonicalOrder[message.id] = followingUserCanonical
}
}
previous.forEach { message -> previous.forEach { message ->
val order = resolvedOrders[message.id] ?: message.canonicalOrder val order = resolvedOrders[message.id] ?: message.canonicalOrder
if (order != null) { if (order != null) {
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
} else if (message.isPermanentlyLocal()) { } else if (message.isPermanentlyLocal()) {
localAnchors[message.id] = localAnchors[message.id] =
if (hasPendingPredecessor) { if (hasPendingPredecessor) {
Long.MAX_VALUE nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
} else { } else {
precedingCanonical?.takeIf { it >= 0L } precedingCanonical?.takeIf { it >= 0L }
?: if (message.role == MessageRole.USER) { ?: if (message.role == MessageRole.USER) {
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
} else { } else {
hasPendingPredecessor = true hasPendingPredecessor = true
pendingLocalOrder = message.localOrder pendingLocalOrder = message.localOrder
nextCanonicalOrder[message.id]?.let { successor ->
localAnchors[message.id] = successor - 1L
}
} }
} }
val previousIndices = previous.withIndex().associate { it.value.id to it.index } val previousIndices = previous.withIndex().associate { it.value.id to it.index }
@@ -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.CommandsCatalogParams
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
import com.m57.hermescontrol.data.ws.contract.FileAttachParams import com.m57.hermescontrol.data.ws.contract.FileAttachParams
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
@@ -165,7 +166,7 @@ data class ChatUiState(
val messages: List<ChatMessage> = emptyList(), val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null, val currentSessionId: String? = null,
val sessions: List<SessionUi> = emptyList(), val sessions: List<SessionUi> = emptyList(),
val chatTitle: String = "Hermes", val chatTitle: String = "AITURK",
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED, val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
val isAgentTyping: Boolean = false, val isAgentTyping: Boolean = false,
val isMainTurnBusy: Boolean = false, val isMainTurnBusy: Boolean = false,
@@ -543,6 +544,7 @@ class ChatViewModel(
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>() private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>() private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
private val outgoingRequestById = ConcurrentHashMap<String, String>() private val outgoingRequestById = ConcurrentHashMap<String, String>()
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>() private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>() private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
private val pendingSendReservationLock = Any() private val pendingSendReservationLock = Any()
@@ -918,7 +920,7 @@ class ChatViewModel(
.collect { _ -> .collect { _ ->
pendingGoneSessionNotice = false pendingGoneSessionNotice = false
sessionHasServerPresence = false sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true) resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
} }
} }
// Same wipe when the CONNECTION profile changes (different server): // Same wipe when the CONNECTION profile changes (different server):
@@ -930,7 +932,7 @@ class ChatViewModel(
.collect { _ -> .collect { _ ->
pendingGoneSessionNotice = false pendingGoneSessionNotice = false
sessionHasServerPresence = false sessionHasServerPresence = false
resetSessionState(sessionId = null, title = "Hermes", isLoading = true) resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
} }
} }
// Slash-command usage ranking (issue #865): mirror the local usage // Slash-command usage ranking (issue #865): mirror the local usage
@@ -1005,7 +1007,7 @@ class ChatViewModel(
// even when the route looks already active). // even when the route looks already active).
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) } _uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
cancelResumeRetry() cancelResumeRetry()
addSystemMessage("Connected to Hermes") addSystemMessage("Connected to AITURK")
loadSessions() loadSessions()
fetchCommandCatalog() fetchCommandCatalog()
modelSwitchDelegate.preloadModelOptions() modelSwitchDelegate.preloadModelOptions()
@@ -1510,6 +1512,10 @@ class ChatViewModel(
viewModelScope.launch { fetchContextUsage() } viewModelScope.launch { fetchContextUsage() }
} }
is ReducerEffect.DeleteLocalMessage -> {
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
}
is ReducerEffect.AttachHostMedia -> { is ReducerEffect.AttachHostMedia -> {
// Issue #724: turn host-path MEDIA: directives into real // Issue #724: turn host-path MEDIA: directives into real
// attachments (images inline, every other file tappable) // attachments (images inline, every other file tappable)
@@ -1702,6 +1708,17 @@ class ChatViewModel(
} }
when { when {
accepted -> { accepted -> {
// #1427: a gateway-queued prompt belongs to a future turn, not the
// completion currently on screen. Keep it live until that turn is verified.
val receipt = sendStore.all().firstOrNull { it.id == messageId }
acceptedTurnEpochById[messageId] =
if (status == "queued" ||
(!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt))
) {
mainTurnEpoch + 1
} else {
mainTurnEpoch
}
// #1285: the submit ack names the row written for THIS input. Absent = unproven. // #1285: the submit ack names the row written for THIS input. Absent = unproven.
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId -> positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
sendStore.update(messageId) { it.copy(userRowId = rowId) } sendStore.update(messageId) { it.copy(userRowId = rowId) }
@@ -1786,12 +1803,14 @@ class ChatViewModel(
isSessionReady = true, isSessionReady = true,
isLoading = false, isLoading = false,
messages = if (pendingInitialPrompt != null) it.messages else emptyList(), messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
chatTitle = "Hermes", chatTitle = "AITURK",
usedContextTokens = null, usedContextTokens = null,
fullContextTokens = null, fullContextTokens = null,
contextBreakdown = null, contextBreakdown = null,
compressionCount = null, compressionCount = null,
sessionUsage = null, sessionUsage = null,
isCompressing = false,
compressionStatus = null,
) )
} }
publishPendingSends() publishPendingSends()
@@ -1853,7 +1872,7 @@ class ChatViewModel(
val generation = val generation =
resetSessionState( resetSessionState(
sessionId = storageId, sessionId = storageId,
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "Hermes", title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "AITURK",
isLoading = false, isLoading = false,
) )
runtimeSessionId = runtimeId runtimeSessionId = runtimeId
@@ -2631,12 +2650,14 @@ class ChatViewModel(
state: PendingSendState, state: PendingSendState,
) { ) {
sendStore.update(messageId) { it.copy(state = state) } sendStore.update(messageId) { it.copy(state = state) }
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
publishPendingSends() publishPendingSends()
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends. // #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
if (state != PendingSendState.SENDING) drainPendingQueue() if (state != PendingSendState.SENDING) drainPendingQueue()
} }
private fun removePendingSend(messageId: String) { private fun removePendingSend(messageId: String) {
acceptedTurnEpochById.remove(messageId)
sendStore.remove(messageId) sendStore.remove(messageId)
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) } viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
publishPendingSends() publishPendingSends()
@@ -3662,6 +3683,34 @@ class ChatViewModel(
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments) fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
val state = _uiState.value
val sessionId = state.currentSessionId ?: return null
// Connection status is combined into public uiState, not written to _uiState.
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
!state.isSessionReady || _timelineState.value.isHistorical
) {
return null
}
return ChatAttachmentTarget(
sessionId = sessionId,
generation = sessionGeneration,
baseUrl = AuthManager.getBaseUrl(),
connectionProfileId = AuthManager.getSelectedProfileId(),
agentProfileId = AuthManager.activeProfileId.value,
)
}
internal fun addPastedAttachments(
target: ChatAttachmentTarget,
attachments: List<Attachment>,
): Boolean {
if (captureAttachmentTarget() != target) return false
attachmentsDelegate.addAttachments(attachments)
return true
}
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index) fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment) fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
@@ -4113,44 +4162,46 @@ class ChatViewModel(
val arg = parts.getOrElse(1) { "" } val arg = parts.getOrElse(1) { "" }
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
try { try {
// Primary path: command.dispatch handles quick/plugin/bundle/ // Desktop parity: slash.exec owns generic commands. Handle errors here,
// skill commands + a few hardcoded ones. It returns a hard 4018 // not again through the shared RpcError banner (/usage regression).
// "not a ... command" for everything that lives only in the TUI val result =
// slash worker (the 29 commands that 4018'd on mobile — issue wsClient.call(
// #576). For those we fall back to slash.exec, which runs the RpcMethods.SLASH_EXEC,
// full COMMAND_REGISTRY through the worker. SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
suppressErrorEvent = true,
)
val map = result.toAny() as? Map<*, *>
if (map?.get("type") is String) {
handleDispatchResult(map)
} else {
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 = val result =
wsClient.call( wsClient.call(
RpcMethods.COMMAND_DISPATCH, RpcMethods.COMMAND_DISPATCH,
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId), CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
suppressErrorEvent = true,
) )
handleDispatchResult(result.toAny()) val map = result.toAny() as? Map<*, *>
} catch (e: HermesWsClient.HermesRpcException) { if (map?.get("type") is String) {
val msg = e.message.orEmpty() handleDispatchResult(map)
// Registry miss on command.dispatch: the backend emits exactly
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
// server.py L12408). Match that precise phrase so unrelated
// errors can't accidentally trigger the slash.exec fallback.
if (msg.contains("not a quick/plugin/bundle/skill command")) {
// Registry miss on command.dispatch -> retry via slash.exec,
// which routes the full CLI command set through the worker.
try {
val result =
wsClient.call(
RpcMethods.SLASH_EXEC,
SlashExecParams(
sessionId = sessionId,
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
),
)
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
if (!output.isNullOrBlank()) addAssistantMessage(output)
} catch (e2: HermesWsClient.HermesRpcException) {
addAssistantMessage("/$name: ${e2.message}")
}
} else { } else {
// Legit error from command.dispatch (busy, no history, etc.) addAssistantMessage("/$name: invalid response: command.dispatch")
addAssistantMessage("/$name: ${e.message}") }
} 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) { fun createNewSession(setLoading: Boolean = true) {
// A fresh create has no persisted row until the first prompt. // A fresh create has no persisted row until the first prompt.
sessionHasServerPresence = false sessionHasServerPresence = false
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading) val generation = resetSessionState(sessionId = null, title = "AITURK", isLoading = setLoading)
viewModelScope.launch(ioDispatcher) { viewModelScope.launch(ioDispatcher) {
wsClient.send( wsClient.send(
RpcMethods.SESSION_CREATE, RpcMethods.SESSION_CREATE,
SessionCreateParams(source = "desktop"), SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) }, onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
) )
} }
@@ -4904,7 +4955,7 @@ class ChatViewModel(
val title = val title =
_uiState.value.sessions _uiState.value.sessions
.find { it.id == sessionId } .find { it.id == sessionId }
?.title ?: "Hermes" ?.title ?: "AITURK"
val generation = resetSessionState(sessionId, title, isLoading = true) val generation = resetSessionState(sessionId, title, isLoading = true)
AuthManager.setLastOpenedSessionId(sessionId) AuthManager.setLastOpenedSessionId(sessionId)
viewModelScope.launch { viewModelScope.launch {
@@ -5056,7 +5107,22 @@ class ChatViewModel(
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) } val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
val valid = { isCurrent() && cacheCursor == before } val valid = { isCurrent() && cacheCursor == before }
if (!valid()) return false if (!valid()) return false
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false mergeHistoryPage(valid, cached = true) {
// #1432: Room retains host references, not connection-bound download URLs.
page.messages.map { message ->
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
message.copy(
attachments =
userImageAttachments(
message.content,
::gatewayMediaUrl,
).takeIf { it.isNotEmpty() },
)
} else {
message
}
}
} ?: return false
if (!valid()) return false if (!valid()) return false
cacheCursor = page.cursor cacheCursor = page.cursor
cacheHasOlder = page.hasOlder cacheHasOlder = page.hasOlder
@@ -5202,7 +5268,11 @@ class ChatViewModel(
val scope = sendScope() val scope = sendScope()
val scopedPending = val scopedPending =
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId } sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend) val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
(
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
).forEach(::removePendingSend)
publishPendingSends() publishPendingSends()
drainPendingQueue() drainPendingQueue()
} }
@@ -5310,6 +5380,9 @@ class ChatViewModel(
contextBreakdown = null, contextBreakdown = null,
compressionCount = null, compressionCount = null,
sessionUsage = null, sessionUsage = null,
// #1433: compaction state belongs to the session being left.
isCompressing = false,
compressionStatus = null,
pendingAttachments = emptyList(), pendingAttachments = emptyList(),
composerTextToRestore = null, composerTextToRestore = null,
reactionKind = null, reactionKind = null,
@@ -5338,6 +5411,7 @@ class ChatViewModel(
val params = val params =
SessionResumeParams( SessionResumeParams(
sessionId = sessionId, sessionId = sessionId,
source = DESKTOP_SESSION_SOURCE,
omitMessages = true, omitMessages = true,
profile = profile?.takeIf { it.isNotBlank() }, profile = profile?.takeIf { it.isNotBlank() },
) )
@@ -5648,6 +5722,17 @@ class ChatViewModel(
val requestSequence = activeHydrationRequestSequence val requestSequence = activeHydrationRequestSequence
val valid = { isCurrentHydration(sessionId, generation, requestSequence) } val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
val useLatest = latestPaging val useLatest = latestPaging
// #1427: acceptance alone is normal live UX. Only a completed turn followed by
// successful history verification can move its still-unmatched receipts into recovery.
// Capture the rows before suspending so a later prompt/retry cannot be quarantined.
val completionAt = lastMainCompletionAt
val completedTurnEpoch = mainTurnEpoch
val unverifiedAccepted =
sendStore.all().filter {
it.scope == sendScope() && it.sessionId == sessionId &&
it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt &&
acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true
}
val nextOffset = val nextOffset =
if (useLatest) { if (useLatest) {
0 0
@@ -5682,6 +5767,14 @@ class ChatViewModel(
) )
} ?: return@launch } ?: return@launch
persistHistoryPage(page, sessionId) persistHistoryPage(page, sessionId)
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
unverifiedAccepted.forEach { receipt ->
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
}
}
}
// Never derive the older cursor from displayed rows or reset it to this latest page. // Never derive the older cursor from displayed rows or reset it to this latest page.
// Append-only growth shifts from-end offsets toward newer rows: the next older // Append-only growth shifts from-end offsets toward newer rows: the next older
// request may overlap, but stable IDs remove echoes without skipping any history. // request may overlap, but stable IDs remove echoes without skipping any history.
@@ -136,6 +136,7 @@ object ChatWsEventReducer {
is WsEvent.VaultSaveLoginExpire -> event.sessionId is WsEvent.VaultSaveLoginExpire -> event.sessionId
is WsEvent.VaultCodeRequest -> event.sessionId is WsEvent.VaultCodeRequest -> event.sessionId
is WsEvent.VaultCodeExpire -> event.sessionId is WsEvent.VaultCodeExpire -> event.sessionId
is WsEvent.StatusUpdate -> event.sessionId
else -> null else -> null
} }
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) { if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
@@ -457,6 +458,7 @@ object ChatWsEventReducer {
): ReducerResult { ): ReducerResult {
val failure = replyFailureFromPayload(event.rawPayload, event.text) val failure = replyFailureFromPayload(event.rawPayload, event.text)
if (failure != null) return onReplyFailure(state, streamingState, event, failure) if (failure != null) return onReplyFailure(state, streamingState, event, failure)
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot) val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline) val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
@@ -1052,36 +1054,107 @@ object ChatWsEventReducer {
event: WsEvent.StatusUpdate, event: WsEvent.StatusUpdate,
): ReducerResult { ): ReducerResult {
val kind = event.data?.get("kind") as? String val kind = event.data?.get("kind") as? String
val text = event.data?.get("text") as? String val text = (event.data?.get("text") as? String)?.trim()
return when (kind) { return when (kind) {
// In-progress compaction is live state: the tail indicator shows only while it runs.
"compressing", "compacting" -> { "compressing", "compacting" -> {
ReducerResult( ReducerResult(
state = state =
state.copy( state.copy(
isCompressing = true, isCompressing = true,
compressionStatus = text ?: "⏳ Compressing context...", compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
), ),
streamingState = streamingState, streamingState = streamingState,
) )
} }
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
"compacted" -> { "compacted" -> {
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
}
"ready" -> {
ReducerResult( ReducerResult(
state = state = state.copy(isCompressing = false, compressionStatus = null),
state.copy(
isCompressing = false,
compressionStatus = text,
),
streamingState = streamingState, streamingState = streamingState,
) )
} }
// Durable notices the desktop/TUI also paint into the transcript.
"goal", "loop", "warn", "fallback" -> {
appendGatewayNotice(state, streamingState, text)
}
else -> { else -> {
ReducerResult(state = state, streamingState = streamingState) ReducerResult(state = state, streamingState = streamingState)
} }
} }
} }
/**
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
* skip it rather than stacking a duplicate.
*/
private fun appendGatewayNotice(
state: ChatUiState,
streamingState: StreamingState,
text: String?,
extraEffects: List<ReducerEffect> = emptyList(),
): ReducerResult {
if (text.isNullOrBlank()) {
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
}
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
val duplicate =
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
val sid = state.currentSessionId
return ReducerResult(
state = state.copy(messages = state.messages + notice),
streamingState = streamingState,
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
)
}
/**
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
*/
private fun onInterruptNotice(
state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageComplete,
): ReducerResult {
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
val text = event.text.trim()
val effects = mutableListOf<ReducerEffect>()
var messages = usageState.messages
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
if (looped.isNotEmpty()) {
messages = messages.filterNot { it.id in looped }
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
}
} else {
// Other exits keep any real partial the user already saw.
streamingState.streamingMessage
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
?.let { partial ->
messages = messages.upsertById(partial)
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
}
}
effects += ReducerEffect.RefreshSessions
effects += ReducerEffect.RefreshContextUsage
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
return appendGatewayNotice(settled, StreamingState(), text, effects)
}
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */ /** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
private fun onMessageReaction( private fun onMessageReaction(
state: ChatUiState, state: ChatUiState,
@@ -1293,6 +1366,11 @@ sealed class ReducerEffect {
data object RefreshContextUsage : ReducerEffect() data object RefreshContextUsage : ReducerEffect()
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
data class DeleteLocalMessage(
val messageId: String,
) : ReducerEffect()
/** /**
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the * Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
* just-finalized assistant message into real [Attachment]s (images inline, * just-finalized assistant message into real [Attachment]s (images inline,
@@ -0,0 +1,32 @@
package com.m57.hermescontrol.ui.chat
/**
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
*
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
*/
internal object GatewayNotice {
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
fun isInterruptSentinel(text: String): Boolean {
val trimmed = text.trim()
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
}
}
/**
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
*/
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
} else {
this
}
@@ -98,7 +98,7 @@ object MediaExportHelper {
if (length != null && length > 0) { if (length != null && length > 0) {
put(MediaStore.MediaColumns.SIZE, length) put(MediaStore.MediaColumns.SIZE, length)
} }
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes") put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/AITURK")
put(MediaStore.MediaColumns.IS_PENDING, 1) put(MediaStore.MediaColumns.IS_PENDING, 1)
} }
@@ -28,7 +28,7 @@ object MediaImageStore {
val safe = sanitizeName(displayName) val safe = sanitizeName(displayName)
val (collection, relativePath) = val (collection, relativePath) =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/Hermes" MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/AITURK"
} else { } else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null
} }
@@ -106,7 +106,7 @@ sealed class SlashResult {
/** Create a new session (client-side immediate). */ /** Create a new session (client-side immediate). */
data object NewSession : SlashResult() data object NewSession : SlashResult()
/** Forward to command.dispatch via WebSocket. */ /** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
data object RpcDispatch : SlashResult() data object RpcDispatch : SlashResult()
/** Fork the active conversation via the session.branch WebSocket RPC. */ /** Fork the active conversation via the session.branch WebSocket RPC. */
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.LightOnSurface import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -81,6 +84,7 @@ fun UserBubble(
messageStatsEnabled: Boolean = false, messageStatsEnabled: Boolean = false,
showUserMessageTokens: Boolean = true, showUserMessageTokens: Boolean = true,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pendingSendState: PendingSendState? = null,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val maxBubbleWidth = screenWidth * 0.80f val maxBubbleWidth = screenWidth * 0.80f
@@ -99,18 +103,36 @@ fun UserBubble(
var copied by rememberCopyFeedback() var copied by rememberCopyFeedback()
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val deliveryState =
when {
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
pendingSendState == PendingSendState.ACCEPTED ||
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
else -> null
}
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
// Keep the original content for Copy; only the layout input is bounded.
val displayContent =
remember(message.content, message.attachments) {
// #1432: the path is only hidden when its image renders as an attachment below.
val visible =
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
ToolJson.clampForDisplay(visible)
}
val highlightedText = val highlightedText =
remember(message.content, searchQuery, isCurrentMatch, statusColors) { remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) { if (searchQuery.isNotBlank()) {
buildHighlightedString( buildHighlightedString(
message.content, displayContent,
searchQuery, searchQuery,
isCurrentMatch, isCurrentMatch,
statusColors, statusColors,
) )
} else { } else {
AnnotatedString(message.content) AnnotatedString(displayContent)
} }
} }
Box( Box(
@@ -152,7 +174,7 @@ fun UserBubble(
color = Color.Transparent, color = Color.Transparent,
tonalElevation = 0.dp, tonalElevation = 0.dp,
) { ) {
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) } val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) { CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
@@ -218,6 +240,11 @@ fun UserBubble(
color = userBubbleTextColor.copy(alpha = 0.6f), color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
DeliveryChecks(
state = deliveryState,
tint = userBubbleTextColor,
messageId = message.id,
)
if (messageStatsEnabled && showUserMessageTokens && if (messageStatsEnabled && showUserMessageTokens &&
message.tokenCount != null && message.tokenCount > 0 message.tokenCount != null && message.tokenCount > 0
) { ) {
@@ -250,3 +277,24 @@ fun UserBubble(
} }
} }
} }
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
private enum class DeliveryState { SENT, DELIVERED }
@Composable
private fun DeliveryChecks(
state: DeliveryState?,
tint: Color,
messageId: String,
) {
if (state == null) return
val delivered = state == DeliveryState.DELIVERED
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
contentDescription =
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
)
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource
/**
* Parses user-authored or server-persisted `@image:` directives from message content into
* gateway-backed [Attachment]s.
*
* Requirements:
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
* - Ignores empty or relative paths (must start with '/').
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
*/
internal fun userImageAttachments(
content: String,
mediaUrl: (String) -> String?,
): List<Attachment> {
if (!content.contains("@image:")) return emptyList()
val seenPaths = mutableSetOf<String>()
val attachments = mutableListOf<Attachment>()
for (rawLine in content.lines()) {
val line = rawLine.trim()
if (!line.startsWith("@image:")) continue
val path = line.removePrefix("@image:").trim()
if (!path.startsWith("/")) continue
if (!seenPaths.add(path)) continue
val url = mediaUrl(path) ?: continue
attachments.add(
Attachment(
uri = url,
name = mediaNameFromPath(path),
mimeType = mediaMimeForPath(path),
size = 0,
gatewayUrl = url,
source = AttachmentSource.GATEWAY,
),
)
}
return attachments
}
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
internal fun hideImageRefLines(content: String): String {
if (!content.contains("@image:")) return content
return content
.lines()
.filterNot { line ->
line.trim().let {
it.startsWith("@image:") &&
it.removePrefix("@image:").trim().startsWith("/")
}
}.joinToString("\n")
.trim()
}
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.contentReceiver
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
* controls row (attach, model/reasoning pill, mic, send) inside it below. * controls row (attach, model/reasoning pill, mic, send) inside it below.
*/ */
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class)
fun ChatInputBar( fun ChatInputBar(
inputFieldValue: TextFieldValue, inputState: TextFieldState,
onInputChange: (TextFieldValue) -> Unit,
onSend: () -> Unit, onSend: () -> Unit,
onMicTap: () -> Unit, onMicTap: () -> Unit,
isListening: Boolean, isListening: Boolean,
@@ -127,15 +135,18 @@ fun ChatInputBar(
isRecordingVoice: Boolean = false, isRecordingVoice: Boolean = false,
amplitudeProvider: () -> Float = { 0f }, amplitudeProvider: () -> Float = { 0f },
onStopGeneration: () -> Unit = {}, onStopGeneration: () -> Unit = {},
receiveContentListener: ReceiveContentListener? = null,
isReceivingContent: Boolean = false,
) { ) {
val inputText = inputState.text.toString()
// Allow sending while the agent is mid-turn or awaiting approval: the // Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn // gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never // (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too. // dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = val canSend =
pendingReasoningLevel == null && !isReceivingContent && pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady) ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty() val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
// Attachment tray state // Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) } var showAttachmentTray by remember { mutableStateOf(false) }
@@ -232,6 +243,16 @@ fun ChatInputBar(
border = BorderStroke(width = 1.dp, color = palette.cardBorder), border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) { ) {
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
if (isReceivingContent) {
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
LinearProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.testTag("chat_image_paste_progress")
.semantics { contentDescription = loadingLabel },
)
}
// Commands hidden from the suggestion menu — desktop/CLI-only and // Commands hidden from the suggestion menu — desktop/CLI-only and
// TUI-only commands that don't function on mobile (issue #574). // TUI-only commands that don't function on mobile (issue #574).
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is // Single source of truth: CommandBlocklist.UNSUPPORTED, which is
@@ -245,13 +266,13 @@ fun ChatInputBar(
.filter { it.lowercase() !in hiddenSlashDisplay } .filter { it.lowercase() !in hiddenSlashDisplay }
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "), visible = inputText.startsWith("/") && !inputText.contains(" "),
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) { ) {
val filteredCommands = val filteredCommands =
ChatInputPolicy.sortSlashSuggestions( ChatInputPolicy.sortSlashSuggestions(
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
slashUsageCounts, slashUsageCounts,
) )
if (filteredCommands.isNotEmpty()) { if (filteredCommands.isNotEmpty()) {
@@ -280,7 +301,7 @@ fun ChatInputBar(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
}, },
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) }, onClick = { inputState.replaceComposerDraft(cmd) },
) )
} }
} }
@@ -341,7 +362,7 @@ fun ChatInputBar(
stringResource(R.string.chat_input_placeholder_not_connected) stringResource(R.string.chat_input_placeholder_not_connected)
} }
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> { ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
stringResource(R.string.chat_input_placeholder_queue) stringResource(R.string.chat_input_placeholder_queue)
} }
@@ -356,9 +377,9 @@ fun ChatInputBar(
val ambientLayoutDirection = LocalLayoutDirection.current val ambientLayoutDirection = LocalLayoutDirection.current
val inputLayoutDirection = val inputLayoutDirection =
remember(inputFieldValue.text, ambientLayoutDirection) { remember(inputText, ambientLayoutDirection) {
BidiUtils.resolveLayoutDirection( BidiUtils.resolveLayoutDirection(
inputFieldValue.text, inputText,
fallback = ambientLayoutDirection, fallback = ambientLayoutDirection,
) )
} }
@@ -381,8 +402,7 @@ fun ChatInputBar(
.heightIn(min = 42.dp), .heightIn(min = 42.dp),
) { ) {
BasicTextField( BasicTextField(
value = inputFieldValue, state = inputState,
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -392,18 +412,28 @@ fun ChatInputBar(
.focusRequester(inputFocusRequester) .focusRequester(inputFocusRequester)
.onFocusChanged { focusState -> .onFocusChanged { focusState ->
inputFocused = focusState.isFocused inputFocused = focusState.isFocused
}.testTag("chat_input"), }.testTag("chat_input")
.then(
if (receiveContentListener != null) {
Modifier.contentReceiver(receiveContentListener)
} else {
Modifier
},
),
enabled = isConnected, enabled = isConnected,
inputTransformation =
InputTransformation {
if (isRecordingVoice) revertAllChanges()
},
textStyle = textStyle =
MaterialTheme.typography.bodyLarge.copy( MaterialTheme.typography.bodyLarge.copy(
color = palette.text, color = palette.text,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
), ),
singleLine = false, lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
maxLines = 8,
cursorBrush = SolidColor(palette.text), cursorBrush = SolidColor(palette.text),
decorationBox = { innerTextField -> decorator = { innerTextField ->
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = contentAlignment =
@@ -416,7 +446,7 @@ fun ChatInputBar(
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides inputLayoutDirection, LocalLayoutDirection provides inputLayoutDirection,
) { ) {
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) { if (inputText.isEmpty() && !isRecordingVoice) {
Text( Text(
text = placeholderText, text = placeholderText,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -0,0 +1,31 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.text.input.TextFieldValue
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
internal fun TextFieldState.replaceComposerDraft(text: String) {
val replacement = ChatInputPolicy.commandFieldValue(text)
edit {
replace(0, length, replacement.text)
selection = replacement.selection
}
}
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
val current = TextFieldValue(text.toString(), selection)
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
edit {
replace(0, length, restored.text)
selection = restored.selection
}
}
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
if (spokenText.isBlank()) return
val current = text.toString()
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
}
@@ -17,6 +17,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -70,13 +72,20 @@ fun ChatConnectionBanner(
color = LocalHermesStatusColors.current.error, color = LocalHermesStatusColors.current.error,
) )
} }
// AITURK: over the relay, say why the reconnect keeps failing.
val aiturkHostOffline by com.m57.hermescontrol.data.aiturk.AiturkRelayStatus.hostOffline
.collectAsState()
val hostOffline = aiturkHostOffline && connectionStatus == ConnectionStatus.RECONNECTING
Text( Text(
modifier = Modifier.weight(1f, fill = false), modifier = Modifier.weight(1f, fill = false),
maxLines = 1, maxLines = if (hostOffline) 3 else 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
text = text =
when (connectionStatus) { when (connectionStatus) {
ConnectionStatus.RECONNECTING -> stringResource(R.string.chat_status_reconnecting) ConnectionStatus.RECONNECTING ->
stringResource(
if (hostOffline) R.string.aiturk_status_host_offline else R.string.chat_status_reconnecting,
)
ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected) ConnectionStatus.DISCONNECTED -> stringResource(R.string.chat_status_disconnected)
ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network) ConnectionStatus.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired) ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
@@ -0,0 +1,195 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.content.MediaType
import androidx.compose.foundation.content.ReceiveContentListener
import androidx.compose.foundation.content.TransferableContent
import androidx.compose.foundation.content.consume
import androidx.compose.foundation.content.hasMediaType
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
import com.m57.hermescontrol.ui.chat.PastedImage
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
internal const val MAX_PASTED_IMAGE_COUNT = 8
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
@OptIn(ExperimentalFoundationApi::class)
internal class ChatImagePasteController(
private val scope: CoroutineScope,
private val importImage: suspend (Uri, Long) -> PastedImage?,
private val captureTarget: () -> ChatAttachmentTarget?,
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
private val onError: (Int) -> Unit,
private val isImageUri: (Uri) -> Boolean = { false },
) : ReceiveContentListener {
var isReceiving by mutableStateOf(false)
private set
private var closed = false
private var job: Job? = null
private val retainedPayloads = mutableSetOf<Any>()
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
if (transferableContent.source != TransferableContent.Source.Clipboard &&
transferableContent.source != TransferableContent.Source.Keyboard
) {
return transferableContent
}
val clip = transferableContent.clipEntry.clipData
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
for (index in 0 until clip.itemCount) {
val uri = clip.getItemAt(index).uri
if (uri?.scheme == "content" && isImageUri(uri)) {
uris.add(uri)
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
}
}
if (!receiveImages(uris, transferableContent)) return transferableContent
// Only accepted image candidates are consumed. Text and non-image URIs
// remain available to the ordinary text field/other receivers.
val accepted = uris.toSet()
// An item can carry both image URI and text. Keep that item for the text
// field after importing its image; consuming it would drop its text.
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
}
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
internal fun receiveImages(
uris: List<Uri>,
payload: Any,
): Boolean {
if (closed || !scope.isActive || uris.isEmpty()) return false
if (isReceiving) {
onError(R.string.chat_image_paste_busy)
return true
}
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
onError(R.string.chat_image_paste_batch_limit)
return true
}
val target = captureTarget()
if (target == null) {
onError(R.string.chat_session_not_ready)
return true
}
isReceiving = true
retainedPayloads.add(payload)
job =
scope.launch {
val images = mutableListOf<PastedImage>()
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
var committed = false
try {
for (uri in uris) {
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
images += image
remainingBytes -= image.file.length()
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
}
currentCoroutineContext().ensureActive()
if (!closed && captureTarget() == target) {
committed = commit(target, images.map { it.attachment })
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
if (!closed && captureTarget() == target) {
onError(
if (failure is PastedImageBatchLimitException ||
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
) {
R.string.chat_image_paste_batch_limit
} else if (failure is PastedImageTooLargeException) {
R.string.chat_image_paste_too_large
} else {
R.string.chat_image_paste_failed
},
)
}
} finally {
if (!committed) {
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
}
retainedPayloads.remove(payload)
isReceiving = false
}
}
// Covers a scope cancelled before the launched coroutine ever starts.
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
return true
}
fun close() {
closed = true
job?.cancel()
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
internal fun rememberChatImagePaste(
viewModel: ChatViewModel,
onShowMessage: (String) -> Unit,
): ChatImagePasteController {
val context = LocalContext.current
val resources by rememberUpdatedState(LocalResources.current)
val scope = rememberCoroutineScope()
val showMessage by rememberUpdatedState(onShowMessage)
val controller =
remember(viewModel, context, scope) {
val store = ChatPastedImageStore(context.applicationContext)
ChatImagePasteController(
scope = scope,
importImage = store::importImage,
isImageUri = { uri ->
try {
context.contentResolver.getType(uri)?.let { type ->
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
} == true
} catch (_: RuntimeException) {
// Provider failures must never consume an unclassified URI.
false
}
},
captureTarget = viewModel::captureAttachmentTarget,
commit = viewModel::addPastedAttachments,
onError = { showMessage(resources.getString(it)) },
)
}
DisposableEffect(controller) { onDispose { controller.close() } }
return controller
}
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
import android.util.Log import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.m57.hermescontrol.ExternalActivityLifecycleGuard import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
import com.m57.hermescontrol.ui.chat.SpeechInputHelper import com.m57.hermescontrol.ui.chat.SpeechInputHelper
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
@Composable @Composable
fun rememberChatMediaLaunchers( fun rememberChatMediaLaunchers(
inputFieldValue: TextFieldValue, inputState: TextFieldState,
onInputFieldValueChange: (TextFieldValue) -> Unit,
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit, onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
onAddAttachments: (List<Attachment>) -> Unit, onAddAttachments: (List<Attachment>) -> Unit,
onVoiceNoteRecorded: (file: File) -> Unit, onVoiceNoteRecorded: (file: File) -> Unit,
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
var isListening by remember { mutableStateOf(false) } var isListening by remember { mutableStateOf(false) }
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) } var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
val currentInputFieldValue by rememberUpdatedState(inputFieldValue) val currentInputState by rememberUpdatedState(inputState)
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment) val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments) val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded) val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
?.firstOrNull() ?.firstOrNull()
.orEmpty() .orEmpty()
if (spokenText.isNotBlank()) { if (spokenText.isNotBlank()) {
val currentText = currentInputFieldValue.text currentInputState.appendSpeechComposerDraft(spokenText)
val merged =
if (currentText.isBlank()) {
spokenText
} else {
"$currentText $spokenText"
}
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
} }
} }
} }
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.InlineAttachment
import com.m57.hermescontrol.ui.chat.PendingSend import com.m57.hermescontrol.ui.chat.PendingSend
import com.m57.hermescontrol.ui.chat.PendingSendState import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.needsRecovery
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */ /** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
mainTurnBusy: Boolean, mainTurnBusy: Boolean,
onSendAgain: (String) -> Unit, onSendAgain: (String) -> Unit,
onDiscard: (String) -> Unit, onDiscard: (String) -> Unit,
onOpenAttachment: (Attachment) -> Unit = {},
onImageClick: (ImageViewerModel) -> Unit = {},
) { ) {
if (sends.isEmpty()) return val recoverySends = sends.filter { it.needsRecovery }
if (recoverySends.isEmpty()) return
var open by remember { mutableStateOf(false) } var open by remember { mutableStateOf(false) }
var retryId by remember { mutableStateOf<String?>(null) } var retryId by remember { mutableStateOf<String?>(null) }
var discardId by remember { mutableStateOf<String?>(null) } var discardId by remember { mutableStateOf<String?>(null) }
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
}, },
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"), modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
) { ) {
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size)) Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
} }
if (open) { if (open) {
ModalBottomSheet(onDismissRequest = { open = false }) { ModalBottomSheet(onDismissRequest = { open = false }) {
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) { LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
items(sends, key = { it.id }) { send -> items(recoverySends, key = { it.id }) { send ->
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (send.attachments.isNotEmpty()) { send.attachments.forEach { attachment ->
Text(stringResource(R.string.chat_pending_attachments)) if (attachment.isImage || attachment.isAudio || attachment.isVideo) {
Text(attachment.name, style = MaterialTheme.typography.bodyMedium)
}
InlineAttachment(
attachment = attachment,
textColor = MaterialTheme.colorScheme.onSurface,
onOpen = onOpenAttachment,
onSave = {},
savingPath = null,
openingPath = null,
canSave = false,
onImageClick = onImageClick,
)
} }
Row { Row {
TextButton( TextButton(
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
} }
} }
} }
sends.firstOrNull { it.id == retryId }?.let { send -> recoverySends.firstOrNull { it.id == retryId }?.let { send ->
AlertDialog( AlertDialog(
onDismissRequest = { retryId = null }, onDismissRequest = { retryId = null },
title = { Text(stringResource(R.string.chat_pending_send_again)) }, title = { Text(stringResource(R.string.chat_pending_send_again)) },
@@ -151,7 +170,7 @@ fun PendingSendRecovery(
}, },
) )
} }
sends.firstOrNull { it.id == discardId }?.let { send -> recoverySends.firstOrNull { it.id == discardId }?.let { send ->
AlertDialog( AlertDialog(
onDismissRequest = { discardId = null }, onDismissRequest = { discardId = null },
title = { Text(stringResource(R.string.chat_pending_discard)) }, title = { Text(stringResource(R.string.chat_pending_discard)) },
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.SearchTarget import com.m57.hermescontrol.ui.chat.SearchTarget
import com.m57.hermescontrol.ui.chat.ToolCallDivider import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.UserBubble import com.m57.hermescontrol.ui.chat.UserBubble
@@ -57,6 +58,23 @@ private object FullBleedContentType {
const val SYSTEM_EVENT: String = "system_event" const val SYSTEM_EVENT: String = "system_event"
} }
/** Only show the placeholder when there are no messages or renderable live tail items. */
internal fun shouldShowChatEmptyState(
transcript: TranscriptUiState,
hasReplyError: Boolean,
): Boolean =
transcript.messages.isEmpty() &&
transcript.streamingState.streamingMessage == null &&
!transcript.isLoading &&
!transcript.isAgentTyping &&
!hasReplyError &&
transcript.clarifyRequest == null &&
transcript.vaultUnlockPrompt == null &&
transcript.vaultSaveLoginPrompt == null &&
transcript.vaultCodePrompt == null &&
!transcript.isCompressing &&
transcript.compressionStatus == null
/** /**
* The chat message list for FULL-BLEED style (issue #866) — the single chat * The chat message list for FULL-BLEED style (issue #866) — the single chat
* surface since the bubble renderer was removed. User messages keep their * surface since the bubble renderer was removed. User messages keep their
@@ -104,7 +122,7 @@ fun FullBleedChatList(
val isCompressing = transcript.isCompressing val isCompressing = transcript.isCompressing
val compressionStatus = transcript.compressionStatus val compressionStatus = transcript.compressionStatus
val speakingMessageId = transcript.speakingMessageId val speakingMessageId = transcript.speakingMessageId
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) { if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -311,6 +329,7 @@ fun FullBleedChatList(
onImageClick = actions.onImageClick, onImageClick = actions.onImageClick,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens, showUserMessageTokens = showUserMessageTokens,
pendingSendState = transcript.pendingSendStates[userMessage.id],
) )
if (userMessage.reactions.isNotEmpty()) { if (userMessage.reactions.isNotEmpty()) {
MessageReactionChips( MessageReactionChips(
@@ -561,6 +580,7 @@ private fun renderUserBubble(
onImageClick: (ImageViewerModel) -> Unit, onImageClick: (ImageViewerModel) -> Unit,
messageStatsEnabled: Boolean, messageStatsEnabled: Boolean,
showUserMessageTokens: Boolean, showUserMessageTokens: Boolean,
pendingSendState: PendingSendState?,
) { ) {
UserBubble( UserBubble(
message = message, message = message,
@@ -574,5 +594,6 @@ private fun renderUserBubble(
onImageClick = onImageClick, onImageClick = onImageClick,
messageStatsEnabled = messageStatsEnabled, messageStatsEnabled = messageStatsEnabled,
showUserMessageTokens = showUserMessageTokens, showUserMessageTokens = showUserMessageTokens,
pendingSendState = pendingSendState,
) )
} }
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null && displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER) content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
private const val HIDDEN_TOOL_NAME = "react_to_message"
/** Tool rows that never get a transcript bubble. */
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
/** /**
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
agentEntries += AgentEntry.Prose(message) agentEntries += AgentEntry.Prose(message)
} }
message.isHiddenTool() -> {
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
}
MessageRole.TOOL == message.role -> { MessageRole.TOOL == message.role -> {
agentEntries += AgentEntry.ToolRow(message) agentEntries += AgentEntry.ToolRow(message)
} }
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.PendingSendState
import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
@@ -37,6 +38,7 @@ data class TranscriptUiState(
val isCompressing: Boolean, val isCompressing: Boolean,
val compressionStatus: String?, val compressionStatus: String?,
val speakingMessageId: String?, val speakingMessageId: String?,
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
) { ) {
companion object { companion object {
/** Resolve historical versus live mode at the state boundary, not in the screen call. */ /** Resolve historical versus live mode at the state boundary, not in the screen call. */
@@ -76,6 +78,8 @@ data class TranscriptUiState(
isCompressing = !historical && chat.isCompressing, isCompressing = !historical && chat.isCompressing,
compressionStatus = chat.compressionStatus.takeUnless { historical }, compressionStatus = chat.compressionStatus.takeUnless { historical },
speakingMessageId = speakingMessageId, speakingMessageId = speakingMessageId,
pendingSendStates =
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
) )
} }
} }
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -31,6 +32,7 @@ import com.m57.hermescontrol.R
fun LandingScreen( fun LandingScreen(
onAuthLogin: () -> Unit, onAuthLogin: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onAiturkPair: () -> Unit = {},
) { ) {
Box( Box(
modifier = modifier =
@@ -88,6 +90,16 @@ fun LandingScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Button( Button(
onClick = onAiturkPair,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.landing_action_aiturk_pair),
style = MaterialTheme.typography.titleMedium,
)
}
OutlinedButton(
onClick = onAuthLogin, onClick = onAuthLogin,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { ) {
@@ -24,9 +24,12 @@ import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.update.AppUpdateState import com.m57.hermescontrol.data.update.AppUpdateState
import com.m57.hermescontrol.data.update.UpdateNoticeManager
import com.m57.hermescontrol.ui.settings.InfoRow import com.m57.hermescontrol.ui.settings.InfoRow
import com.m57.hermescontrol.ui.settings.SectionCard import com.m57.hermescontrol.ui.settings.SectionCard
private const val AITURK_DOWNLOAD_URL = "https://turkservis.online/indir"
@Composable @Composable
internal fun AboutSection( internal fun AboutSection(
updateState: AppUpdateState = AppUpdateState.Idle, updateState: AppUpdateState = AppUpdateState.Idle,
@@ -55,6 +58,10 @@ internal fun AboutSection(
label = stringResource(R.string.settings_about_version), label = stringResource(R.string.settings_about_version),
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})", value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
) )
// 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( UpdateRow(
state = updateState, state = updateState,
onCheckUpdate = onCheckUpdate, onCheckUpdate = onCheckUpdate,
@@ -70,6 +77,12 @@ internal fun AboutSection(
updateState !is AppUpdateState.Installing, updateState !is AppUpdateState.Installing,
onCheckedChange = onCheckReleaseCandidateUpdatesChange, onCheckedChange = onCheckReleaseCandidateUpdatesChange,
) )
} else {
InfoRow(
label = stringResource(R.string.settings_about_download),
value = AITURK_DOWNLOAD_URL,
)
}
InfoRow( InfoRow(
label = stringResource(R.string.settings_about_build), label = stringResource(R.string.settings_about_build),
value = value =
@@ -87,6 +100,14 @@ internal fun AboutSection(
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.settings_about_attribution),
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "https://github.com/Hy4ri/hermes-mobile", text = "https://github.com/Hy4ri/hermes-mobile",
style = style =
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
listOf( listOf(
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system), LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english), "en" to stringResource(R.string.language_english),
"tr" to stringResource(R.string.language_turkish),
"ar" to stringResource(R.string.language_arabic), "ar" to stringResource(R.string.language_arabic),
"zh" to stringResource(R.string.language_chinese), "zh" to stringResource(R.string.language_chinese),
"ko" to stringResource(R.string.language_korean), "ko" to stringResource(R.string.language_korean),
@@ -3,8 +3,8 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Solid brand background --> <!-- Solid AITURK brand background -->
<path <path
android:fillColor="#1B1B2F" android:fillColor="#081525"
android:pathData="M0,0h108v108h-108z" /> android:pathData="M0,0h108v108h-108z" />
</vector> </vector>
@@ -3,23 +3,18 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- Left pillar of H --> <!-- AITURK "A" mark (source: AITURK IDE aiturk_icon, 256 viewport), scaled
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 <path
android:fillColor="#FFFFFF" 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" /> android:pathData="M128,58L148,96H108L128,58Z" />
</group>
<!-- 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" />
</vector> </vector>
+34 -36
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="app_name">Hermes Mobile</string> <string name="app_name">AITURK Mobil</string>
<string name="chat_send_desc">إرسال</string> <string name="chat_send_desc">إرسال</string>
<string name="chat_mic_listening">جارٍ الاستماع…</string> <string name="chat_mic_listening">جارٍ الاستماع…</string>
<string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string> <string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string>
@@ -57,7 +57,7 @@
<string name="billing_plan">الخطة</string> <string name="billing_plan">الخطة</string>
<string name="billing_topup">شحن الرصيد</string> <string name="billing_topup">شحن الرصيد</string>
<string name="press_back_again_to_exit">اضغط رجوع مرة أخرى للخروج</string> <string name="press_back_again_to_exit">اضغط رجوع مرة أخرى للخروج</string>
<string name="nav_drawer_title">Hermes</string> <string name="nav_drawer_title">AITURK</string>
<string name="nav_drawer_subtitle">التحكم في وكيل الذكاء الاصطناعي</string> <string name="nav_drawer_subtitle">التحكم في وكيل الذكاء الاصطناعي</string>
<string name="nav_drawer_section_converse">محادثة</string> <string name="nav_drawer_section_converse">محادثة</string>
<string name="nav_drawer_section_automate">أتمتة</string> <string name="nav_drawer_section_automate">أتمتة</string>
@@ -127,27 +127,27 @@
<string name="screen_analytics">التحليلات</string> <string name="screen_analytics">التحليلات</string>
<string name="screen_billing">الفواتير والاشتراكات</string> <string name="screen_billing">الفواتير والاشتراكات</string>
<string name="screen_files">الملفات</string> <string name="screen_files">الملفات</string>
<string name="notif_title">Hermes</string> <string name="notif_title">AITURK</string>
<string name="notif_waiting_replies">بانتظار ردود Hermes</string> <string name="notif_waiting_replies">بانتظار ردود AITURK</string>
<string name="notif_connected_in_background">متصل في الخلفية</string> <string name="notif_connected_in_background">متصل في الخلفية</string>
<string name="notif_action_turn_off">إيقاف</string> <string name="notif_action_turn_off">إيقاف</string>
<string name="notif_connecting">جارٍ الاتصال…</string> <string name="notif_connecting">جارٍ الاتصال…</string>
<string name="notif_waiting_network">في انتظار الشبكة</string> <string name="notif_waiting_network">في انتظار الشبكة</string>
<string name="notif_reconnecting">جارٍ إعادة الاتصال</string> <string name="notif_reconnecting">جارٍ إعادة الاتصال</string>
<string name="notif_new_message">رسالة جديدة</string> <string name="notif_new_message">رسالة جديدة</string>
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string> <string name="notif_clarification_needed">يحتاج AITURK إلى توضيح</string>
<string name="notif_reply_placeholder">اكتب ردك\u2026</string> <string name="notif_reply_placeholder">اكتب ردك\u2026</string>
<string name="notif_channel_service_name">خدمة Hermes في الخلفية</string> <string name="notif_channel_service_name">خدمة AITURK في الخلفية</string>
<string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string> <string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string>
<string name="notif_channel_chat_name">ردود محادثة Hermes</string> <string name="notif_channel_chat_name">ردود محادثة AITURK</string>
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من Hermes</string> <string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من AITURK</string>
<string name="action_reply">رد</string> <string name="action_reply">رد</string>
<string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string> <string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string>
<string name="chat_action_search">بحث</string> <string name="chat_action_search">بحث</string>
<string name="chat_action_close_search">إغلاق البحث</string> <string name="chat_action_close_search">إغلاق البحث</string>
<string name="chat_action_reconnect">إعادة الاتصال</string> <string name="chat_action_reconnect">إعادة الاتصال</string>
<string name="chat_action_relogin">إعادة تسجيل الدخول</string> <string name="chat_action_relogin">إعادة تسجيل الدخول</string>
<string name="chat_relogin_title">إعادة تسجيل الدخول إلى Hermes</string> <string name="chat_relogin_title">إعادة تسجيل الدخول إلى AITURK</string>
<string name="chat_relogin_username">اسم المستخدم</string> <string name="chat_relogin_username">اسم المستخدم</string>
<string name="chat_relogin_password">كلمة المرور</string> <string name="chat_relogin_password">كلمة المرور</string>
<string name="chat_relogin_submit">تسجيل الدخول</string> <string name="chat_relogin_submit">تسجيل الدخول</string>
@@ -155,7 +155,7 @@
<string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string> <string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string>
<string name="chat_empty_title">جاهز للدردشة</string> <string name="chat_empty_title">جاهز للدردشة</string>
<string name="chat_empty_subtitle">أرسل رسالة لبدء محادثة</string> <string name="chat_empty_subtitle">أرسل رسالة لبدء محادثة</string>
<string name="chat_status_connecting">جارٍ الاتصال بـ Hermes\u2026</string> <string name="chat_status_connecting">جارٍ الاتصال بـ AITURK\u2026</string>
<string name="chat_resume_error_title">تعذر الاتصال بهذه الجلسة</string> <string name="chat_resume_error_title">تعذر الاتصال بهذه الجلسة</string>
<string name="chat_status_reconnecting">جارٍ إعادة الاتصال\u2026</string> <string name="chat_status_reconnecting">جارٍ إعادة الاتصال\u2026</string>
<string name="chat_status_disconnected">تم قطع الاتصال</string> <string name="chat_status_disconnected">تم قطع الاتصال</string>
@@ -227,9 +227,7 @@
<string name="settings_action_test_connection">اختبار الاتصال</string> <string name="settings_action_test_connection">اختبار الاتصال</string>
<string name="settings_sec_about">حول التطبيق</string> <string name="settings_sec_about">حول التطبيق</string>
<string name="settings_sec_language">اللغة</string> <string name="settings_sec_language">اللغة</string>
<string name="settings_about_app_name">Hermes Mobile</string> <string name="settings_about_app_name">AITURK Mobil</string>
<string name="settings_support_title">ادعم Hermes Mobile</string>
<string name="settings_support_message">إذا وفّر لك Hermes Mobile الوقت، يمكنك دعم تطويره.</string>
<string name="settings_support_action">ادعمني على Ko-fi</string> <string name="settings_support_action">ادعمني على Ko-fi</string>
<string name="settings_about_version">الإصدار</string> <string name="settings_about_version">الإصدار</string>
<string name="settings_about_build">رقم البنية</string> <string name="settings_about_build">رقم البنية</string>
@@ -289,7 +287,7 @@
<string name="connect_selected_profile">الملف التعريفي: %1$s</string> <string name="connect_selected_profile">الملف التعريفي: %1$s</string>
<string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</string> <string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</string>
<string name="connect_health_title">حالة الخادم الخلفي</string> <string name="connect_health_title">حالة الخادم الخلفي</string>
<string name="connect_health_body">Hermes %1$s · %2$s</string> <string name="connect_health_body">AITURK %1$s · %2$s</string>
<string name="connect_health_auth_gated">محمي (OAuth)</string> <string name="connect_health_auth_gated">محمي (OAuth)</string>
<string name="connect_health_auth_token">Token</string> <string name="connect_health_auth_token">Token</string>
<string name="connect_error_token_required">رمز Token مطلوب</string> <string name="connect_error_token_required">رمز Token مطلوب</string>
@@ -297,7 +295,7 @@
<string name="connect_error_403">تم رفض الوصول (403 Forbidden)</string> <string name="connect_error_403">تم رفض الوصول (403 Forbidden)</string>
<string name="connect_error_http_code">استجاب الخادم برمز HTTP %1$d</string> <string name="connect_error_http_code">استجاب الخادم برمز HTTP %1$d</string>
<string name="connect_error_timeout">انتهت مهلة الاتصال</string> <string name="connect_error_timeout">انتهت مهلة الاتصال</string>
<string name="connect_error_refused">تم رفض الاتصال – هل خادم Hermes قيد التشغيل؟</string> <string name="connect_error_refused">تم رفض الاتصال – هل خادم AITURK قيد التشغيل؟</string>
<string name="connect_error_resolve">تعذر حل اسم المضيف</string> <string name="connect_error_resolve">تعذر حل اسم المضيف</string>
<string name="connect_error_connection_failed">فشل الاتصال: %1$s</string> <string name="connect_error_connection_failed">فشل الاتصال: %1$s</string>
<string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string> <string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string>
@@ -500,7 +498,7 @@
<string name="skills_hub_source_offline">غير متصل</string> <string name="skills_hub_source_offline">غير متصل</string>
<string name="skills_hub_source_via_index">عبر الفهرس</string> <string name="skills_hub_source_via_index">عبر الفهرس</string>
<string name="skills_hub_featured_title">مهارات مميزة</string> <string name="skills_hub_featured_title">مهارات مميزة</string>
<string name="skills_hub_featured_subtitle">من فهرس Hermes — ابحث أعلاه للمزيد</string> <string name="skills_hub_featured_subtitle">من فهرس AITURK — ابحث أعلاه للمزيد</string>
<string name="skills_hub_landing_hint">ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة</string> <string name="skills_hub_landing_hint">ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة</string>
<string name="skills_hub_scan">فحص</string> <string name="skills_hub_scan">فحص</string>
<string name="skills_hub_scan_verdict_label">النتيجة: %1$s</string> <string name="skills_hub_scan_verdict_label">النتيجة: %1$s</string>
@@ -529,7 +527,7 @@
<string name="content_desc_update_skills_hub">تحديث المهارات من المركز</string> <string name="content_desc_update_skills_hub">تحديث المهارات من المركز</string>
<string name="content_desc_save_changes">حفظ التغييرات</string> <string name="content_desc_save_changes">حفظ التغييرات</string>
<string name="logs_empty_title">لا توجد سجلات متاحة</string> <string name="logs_empty_title">لا توجد سجلات متاحة</string>
<string name="logs_empty_desc">ستظهر مخرجات السجل من Hermes هنا.</string> <string name="logs_empty_desc">ستظهر مخرجات السجل من AITURK هنا.</string>
<string name="logs_action_pause">إيقاف مؤقت</string> <string name="logs_action_pause">إيقاف مؤقت</string>
<string name="logs_action_resume">استئناف</string> <string name="logs_action_resume">استئناف</string>
<string name="logs_action_export">مشاركة</string> <string name="logs_action_export">مشاركة</string>
@@ -552,7 +550,7 @@
<string name="system_label_arch">المعمارية</string> <string name="system_label_arch">المعمارية</string>
<string name="system_label_hostname">اسم المضيف</string> <string name="system_label_hostname">اسم المضيف</string>
<string name="system_label_python">Python</string> <string name="system_label_python">Python</string>
<string name="system_label_hermes_version">Hermes</string> <string name="system_label_hermes_version">AITURK</string>
<string name="system_label_cpu">وحدة المعالجة المركزية (CPU)</string> <string name="system_label_cpu">وحدة المعالجة المركزية (CPU)</string>
<string name="system_label_memory">الذاكرة (RAM)</string> <string name="system_label_memory">الذاكرة (RAM)</string>
<string name="system_label_disk">القرص</string> <string name="system_label_disk">القرص</string>
@@ -566,7 +564,7 @@
<string name="system_version_behind">متأخر بمقدار %1$d</string> <string name="system_version_behind">متأخر بمقدار %1$d</string>
<string name="system_action_check_updates">التحقق من وجود تحديثات</string> <string name="system_action_check_updates">التحقق من وجود تحديثات</string>
<string name="system_action_update_now">تحديث الآن</string> <string name="system_action_update_now">تحديث الآن</string>
<string name="system_update_confirm_title">هل تريد تحديث Hermes؟</string> <string name="system_update_confirm_title">هل تريد تحديث AITURK؟</string>
<string name="system_update_confirm_desc">سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة.</string> <string name="system_update_confirm_desc">سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة.</string>
<string name="system_update_progress_title">تحديث البرنامج</string> <string name="system_update_progress_title">تحديث البرنامج</string>
<string name="system_sec_portal">بوابة Nous Portal</string> <string name="system_sec_portal">بوابة Nous Portal</string>
@@ -642,10 +640,10 @@
<string name="system_sec_restore">استعادة من ملف تم رفعه</string> <string name="system_sec_restore">استعادة من ملف تم رفعه</string>
<string name="system_op_restore_upload">رفع واستعادة</string> <string name="system_op_restore_upload">رفع واستعادة</string>
<string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string> <string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string>
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ Hermes؟</string> <string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ AITURK؟</string>
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات Hermes الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string> <string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات AITURK الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
<string name="system_sec_debug_share">مشاركة بيانات تصحيح الأخطاء</string> <string name="system_sec_debug_share">مشاركة بيانات تصحيح الأخطاء</string>
<string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق Hermes. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string> <string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق AITURK. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string>
<string name="system_debug_share_generate">إنشاء رابط المشاركة</string> <string name="system_debug_share_generate">إنشاء رابط المشاركة</string>
<string name="system_debug_share_uploading">جارٍ الرفع…</string> <string name="system_debug_share_uploading">جارٍ الرفع…</string>
<string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string> <string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string>
@@ -704,7 +702,7 @@
<item quantity="other">%1$d نموذج</item> <item quantity="other">%1$d نموذج</item>
</plurals> </plurals>
<string name="history_empty_title">لا يوجد سجل</string> <string name="history_empty_title">لا يوجد سجل</string>
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string> <string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من AITURK هنا.</string>
<string name="history_untitled">بلا عنوان</string> <string name="history_untitled">بلا عنوان</string>
<string name="history_message_count">%1$d رسالة</string> <string name="history_message_count">%1$d رسالة</string>
<plurals name="sessions_card_message_count"> <plurals name="sessions_card_message_count">
@@ -849,7 +847,7 @@
<string name="config_action_save">حفظ</string> <string name="config_action_save">حفظ</string>
<string name="config_action_save_yaml">حفظ YAML</string> <string name="config_action_save_yaml">حفظ YAML</string>
<string name="profiles_empty_title">لا توجد ملفات تعريف متاحة</string> <string name="profiles_empty_title">لا توجد ملفات تعريف متاحة</string>
<string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من Hermes هنا.</string> <string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من AITURK هنا.</string>
<string name="profiles_label_model">النموذج: %1$s</string> <string name="profiles_label_model">النموذج: %1$s</string>
<string name="profiles_label_provider">المزود: %1$s</string> <string name="profiles_label_provider">المزود: %1$s</string>
<string name="profiles_label_skills">%1$d مهارات</string> <string name="profiles_label_skills">%1$d مهارات</string>
@@ -895,7 +893,7 @@
<string name="profiles_delete_warning">سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا.</string> <string name="profiles_delete_warning">سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا.</string>
<string name="profiles_delete_path">المسار: %1$s</string> <string name="profiles_delete_path">المسار: %1$s</string>
<string name="profiles_delete_gateway_warning">يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها.</string> <string name="profiles_delete_gateway_warning">يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها.</string>
<string name="profiles_setup_command_hint">شغّل هذا الأمر في Hermes CLI لتهيئة هذا الملف الشخصي.</string> <string name="profiles_setup_command_hint">شغّل هذا الأمر في AITURK CLI لتهيئة هذا الملف الشخصي.</string>
<string name="profiles_builder_step_indicator">الخطوة %1$d من 5</string> <string name="profiles_builder_step_indicator">الخطوة %1$d من 5</string>
<string name="profiles_builder_step_identity">الهوية</string> <string name="profiles_builder_step_identity">الهوية</string>
<string name="profiles_builder_step_model">تهيئة النموذج</string> <string name="profiles_builder_step_model">تهيئة النموذج</string>
@@ -913,7 +911,7 @@
<string name="profiles_builder_skills_default_desc">تضمين أدوات النظام الشائعة تلقائيًا</string> <string name="profiles_builder_skills_default_desc">تضمين أدوات النظام الشائعة تلقائيًا</string>
<string name="profiles_builder_skills_local">تحديد المهارات المحلية</string> <string name="profiles_builder_skills_local">تحديد المهارات المحلية</string>
<string name="profiles_builder_skills_search_hub">البحث في الـ Hub</string> <string name="profiles_builder_skills_search_hub">البحث في الـ Hub</string>
<string name="profiles_builder_skills_hub_title">إضافة مهارات من Hermes Hub</string> <string name="profiles_builder_skills_hub_title">إضافة مهارات من AITURK Hub</string>
<string name="profiles_builder_skills_added">المهارات المضافة:</string> <string name="profiles_builder_skills_added">المهارات المضافة:</string>
<string name="profiles_builder_skills_search_results">نتائج البحث:</string> <string name="profiles_builder_skills_search_results">نتائج البحث:</string>
<string name="profiles_builder_mcp_title">خوادم MCP المهيأة</string> <string name="profiles_builder_mcp_title">خوادم MCP المهيأة</string>
@@ -1049,7 +1047,7 @@
<string name="pressure_banner_memory_oom">أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة</string> <string name="pressure_banner_memory_oom">أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة</string>
<string name="pressure_banner_memory_elevated">ذاكرة الوكيل منخفضة</string> <string name="pressure_banner_memory_elevated">ذاكرة الوكيل منخفضة</string>
<string name="cron_empty_title">لا توجد مهام cron</string> <string name="cron_empty_title">لا توجد مهام cron</string>
<string name="cron_empty_desc">ستظهر المهام المجدولة من Hermes هنا.</string> <string name="cron_empty_desc">ستظهر المهام المجدولة من AITURK هنا.</string>
<string name="cron_status_active">نشط</string> <string name="cron_status_active">نشط</string>
<string name="cron_status_paused">متوقف مؤقتاً</string> <string name="cron_status_paused">متوقف مؤقتاً</string>
<string name="cron_action_pause">إيقاف مؤقت</string> <string name="cron_action_pause">إيقاف مؤقت</string>
@@ -1124,7 +1122,7 @@
<string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string> <string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string>
<string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</string> <string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</string>
<string name="keys_empty_title">لا توجد مفاتيح بيئة مُبلّغ عنها</string> <string name="keys_empty_title">لا توجد مفاتيح بيئة مُبلّغ عنها</string>
<string name="keys_empty_desc">ستظهر مفاتيح البيئة من Hermes هنا.</string> <string name="keys_empty_desc">ستظهر مفاتيح البيئة من AITURK هنا.</string>
<string name="keys_label_not_configured">غير مهيأ</string> <string name="keys_label_not_configured">غير مهيأ</string>
<string name="keys_action_toggle_visibility">تبديل مستوى الرؤية</string> <string name="keys_action_toggle_visibility">تبديل مستوى الرؤية</string>
<string name="keys_search_placeholder">بحث في المفاتيح…</string> <string name="keys_search_placeholder">بحث في المفاتيح…</string>
@@ -1165,7 +1163,7 @@
<string name="files_dialog_delete_title">هل تريد الحذف؟</string> <string name="files_dialog_delete_title">هل تريد الحذف؟</string>
<string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</string> <string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</string>
<string name="channels_empty_title">لا توجد قنوات مهيأة</string> <string name="channels_empty_title">لا توجد قنوات مهيأة</string>
<string name="channels_empty_desc">أضف منصات المراسلة في Hermes لعرضها هنا.</string> <string name="channels_empty_desc">أضف منصات المراسلة في AITURK لعرضها هنا.</string>
<string name="channels_action_configure">تهيئة</string> <string name="channels_action_configure">تهيئة</string>
<string name="channels_sec_settings">إعدادات التهيئة</string> <string name="channels_sec_settings">إعدادات التهيئة</string>
<string name="channels_action_save_settings">حفظ الإعدادات</string> <string name="channels_action_save_settings">حفظ الإعدادات</string>
@@ -1188,7 +1186,7 @@
<string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string> <string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string>
<string name="settings_logout">تسجيل الخروج</string> <string name="settings_logout">تسجيل الخروج</string>
<string name="auth_login_title">تسجيل الدخول والمصادقة</string> <string name="auth_login_title">تسجيل الدخول والمصادقة</string>
<string name="auth_login_desc">الاتصال بلوحة تحكم Hermes</string> <string name="auth_login_desc">الاتصال بلوحة تحكم AITURK</string>
<string name="auth_login_base_url_label">رابط الخادم (URL)</string> <string name="auth_login_base_url_label">رابط الخادم (URL)</string>
<string name="auth_login_token_label">رمز الجلسة (Session Token)</string> <string name="auth_login_token_label">رمز الجلسة (Session Token)</string>
<string name="auth_login_username_label">اسم المستخدم</string> <string name="auth_login_username_label">اسم المستخدم</string>
@@ -1201,11 +1199,11 @@
<string name="auth_login_error_password_required">كلمة المرور مطلوبة</string> <string name="auth_login_error_password_required">كلمة المرور مطلوبة</string>
<string name="auth_login_error_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</string> <string name="auth_login_error_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</string>
<string name="auth_login_error_oauth_unsupported">تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#)</string> <string name="auth_login_error_oauth_unsupported">تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#)</string>
<string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في Hermes Mobile.</string> <string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في AITURK Mobil.</string>
<string name="auth_login_existing_profiles_title">استخدام ملف تعريف مسجل دخوله مسبقاً:</string> <string name="auth_login_existing_profiles_title">استخدام ملف تعريف مسجل دخوله مسبقاً:</string>
<string name="auth_login_use_profile">الدخول باسم %1$s</string> <string name="auth_login_use_profile">الدخول باسم %1$s</string>
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">AITURK Mobil</string>
<string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string> <string name="landing_subtitle">لوحة التحكم لـ AITURK</string>
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string> <string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string> <string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
<string name="providers_empty_title">لا يوجد موفرو OAuth</string> <string name="providers_empty_title">لا يوجد موفرو OAuth</string>
@@ -1226,7 +1224,7 @@
<string name="action_dismiss">تجاهل</string> <string name="action_dismiss">تجاهل</string>
<string name="action_done">تم</string> <string name="action_done">تم</string>
<string name="action_close">إغلاق</string> <string name="action_close">إغلاق</string>
<string name="update_notice_banner_text">يتوفر إصدار جديد من Hermes Mobile %1$s</string> <string name="update_notice_banner_text">يتوفر إصدار جديد من AITURK Mobil %1$s</string>
<string name="update_notice_banner_action">تحديث</string> <string name="update_notice_banner_action">تحديث</string>
<string name="update_dialog_title">التحديث إلى %1$s</string> <string name="update_dialog_title">التحديث إلى %1$s</string>
<string name="update_dialog_changelog_header">ما الجديد</string> <string name="update_dialog_changelog_header">ما الجديد</string>
@@ -1316,7 +1314,7 @@
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string> <string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
<string name="gif_play">تشغيل GIF</string> <string name="gif_play">تشغيل GIF</string>
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string> <string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
<string name="logs_clipboard_label">سجلات Hermes</string> <string name="logs_clipboard_label">سجلات AITURK</string>
<string name="logs_search_placeholder">البحث في السجلات...</string> <string name="logs_search_placeholder">البحث في السجلات...</string>
<string name="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string> <string name="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string>
<string name="mcp_search_placeholder">البحث في خوادم MCP...</string> <string name="mcp_search_placeholder">البحث في خوادم MCP...</string>
@@ -1554,7 +1552,7 @@
<string name="connection_setup_open_browser">فتح في المتصفح</string> <string name="connection_setup_open_browser">فتح في المتصفح</string>
<string name="connection_setup_required">%1$s مطلوب</string> <string name="connection_setup_required">%1$s مطلوب</string>
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string> <string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
<string name="connection_setup_pending">بانتظار Hermes…</string> <string name="connection_setup_pending">بانتظار AITURK…</string>
<string name="connection_catalog_title">تثبيت %1$s</string> <string name="connection_catalog_title">تثبيت %1$s</string>
<string name="connection_catalog_kind_plugin">إضافة</string> <string name="connection_catalog_kind_plugin">إضافة</string>
<string name="connection_catalog_kind_skill">مهارة</string> <string name="connection_catalog_kind_skill">مهارة</string>
+28 -30
View File
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">헤르메스</string> <string name="app_name">AITURK Mobil</string>
<!-- Chat --> <!-- Chat -->
<string name="chat_send_desc">전송</string> <string name="chat_send_desc">전송</string>
@@ -51,7 +51,7 @@
<!-- Navigation & Drawer --> <!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string> <string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
<string name="nav_drawer_title">헤르메스</string> <string name="nav_drawer_title">AITURK</string>
<string name="nav_drawer_subtitle">AI 에이전트 제어</string> <string name="nav_drawer_subtitle">AI 에이전트 제어</string>
<string name="nav_drawer_section_converse">대화</string> <string name="nav_drawer_section_converse">대화</string>
<string name="nav_drawer_section_automate">자동화</string> <string name="nav_drawer_section_automate">자동화</string>
@@ -124,20 +124,20 @@
<string name="screen_files">파일</string> <string name="screen_files">파일</string>
<!-- Notifications --> <!-- Notifications -->
<string name="notif_title">헤르메스</string> <string name="notif_title">AITURK</string>
<string name="notif_waiting_replies">헤르메스 응답 대기 중</string> <string name="notif_waiting_replies">AITURK 응답 대기 중</string>
<string name="notif_connected_in_background">백그라운드에서 연결됨</string> <string name="notif_connected_in_background">백그라운드에서 연결됨</string>
<string name="notif_action_turn_off">끄기</string> <string name="notif_action_turn_off">끄기</string>
<string name="notif_connecting">연결 중…</string> <string name="notif_connecting">연결 중…</string>
<string name="notif_waiting_network">네트워크 대기 중</string> <string name="notif_waiting_network">네트워크 대기 중</string>
<string name="notif_reconnecting">재연결 중</string> <string name="notif_reconnecting">재연결 중</string>
<string name="notif_new_message">새 메시지</string> <string name="notif_new_message">새 메시지</string>
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string> <string name="notif_clarification_needed">AITURK의 확인이 필요합니다</string>
<string name="notif_reply_placeholder">답변 입력…</string> <string name="notif_reply_placeholder">답변 입력…</string>
<string name="notif_channel_service_name">헤르메스 백그라운드 서비스</string> <string name="notif_channel_service_name">AITURK 백그라운드 서비스</string>
<string name="notif_channel_service_desc">백그라운드에서 연결 유지</string> <string name="notif_channel_service_desc">백그라운드에서 연결 유지</string>
<string name="notif_channel_chat_name">헤르메스 채팅 응답</string> <string name="notif_channel_chat_name">AITURK 채팅 응답</string>
<string name="notif_channel_chat_desc">헤르메스의 새 응답 알림</string> <string name="notif_channel_chat_desc">AITURK의 새 응답 알림</string>
<string name="action_reply">답장</string> <string name="action_reply">답장</string>
<!-- Chat Screen & Bubble --> <!-- Chat Screen & Bubble -->
@@ -146,7 +146,7 @@
<string name="chat_action_close_search">검색 닫기</string> <string name="chat_action_close_search">검색 닫기</string>
<string name="chat_action_reconnect">재접속</string> <string name="chat_action_reconnect">재접속</string>
<string name="chat_action_relogin">재로그인</string> <string name="chat_action_relogin">재로그인</string>
<string name="chat_relogin_title">헤르메스 재로그인</string> <string name="chat_relogin_title">AITURK 재로그인</string>
<string name="chat_relogin_username">사용자명</string> <string name="chat_relogin_username">사용자명</string>
<string name="chat_relogin_password">비밀번호</string> <string name="chat_relogin_password">비밀번호</string>
<string name="chat_relogin_submit">로그인</string> <string name="chat_relogin_submit">로그인</string>
@@ -154,7 +154,7 @@
<string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string> <string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string>
<string name="chat_empty_title">채팅 준비 완료</string> <string name="chat_empty_title">채팅 준비 완료</string>
<string name="chat_empty_subtitle">메시지를 보내 대화를 시작하세요</string> <string name="chat_empty_subtitle">메시지를 보내 대화를 시작하세요</string>
<string name="chat_status_connecting">헤르메스에 연결 중…</string> <string name="chat_status_connecting">AITURK에 연결 중…</string>
<string name="chat_status_reconnecting">재연결 중…</string> <string name="chat_status_reconnecting">재연결 중…</string>
<string name="chat_status_disconnected">연결 끊김</string> <string name="chat_status_disconnected">연결 끊김</string>
<string name="chat_status_no_network">네트워크 없음</string> <string name="chat_status_no_network">네트워크 없음</string>
@@ -188,7 +188,7 @@
<string name="settings_sec_connection">연결</string> <string name="settings_sec_connection">연결</string>
<string name="settings_field_token">인증 토큰</string> <string name="settings_field_token">인증 토큰</string>
<string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string> <string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string>
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string> <string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 AITURK 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
<string name="settings_sec_behavior">동작</string> <string name="settings_sec_behavior">동작</string>
<string name="settings_item_auto_reconnect">자동 재연결</string> <string name="settings_item_auto_reconnect">자동 재연결</string>
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string> <string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
@@ -229,9 +229,7 @@
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string> <string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
<string name="settings_action_test_connection">연결 테스트</string> <string name="settings_action_test_connection">연결 테스트</string>
<string name="settings_sec_about">정보</string> <string name="settings_sec_about">정보</string>
<string name="settings_about_app_name">헤르메스 모바일</string> <string name="settings_about_app_name">AITURK Mobil</string>
<string name="settings_support_title">Hermes Mobile 후원</string>
<string name="settings_support_message">Hermes Mobile이 시간을 절약해 준다면 개발을 후원해 주세요.</string>
<string name="settings_support_action">Ko-fi에서 후원하기</string> <string name="settings_support_action">Ko-fi에서 후원하기</string>
<string name="settings_about_version">버전</string> <string name="settings_about_version">버전</string>
<string name="settings_about_build">빌드</string> <string name="settings_about_build">빌드</string>
@@ -286,7 +284,7 @@
<string name="connect_selected_profile">프로필: %1$s</string> <string name="connect_selected_profile">프로필: %1$s</string>
<string name="connect_action_select_profile">저장된 프로필 선택</string> <string name="connect_action_select_profile">저장된 프로필 선택</string>
<string name="connect_health_title">백엔드 상태</string> <string name="connect_health_title">백엔드 상태</string>
<string name="connect_health_body">Hermes %1$s · %2$s</string> <string name="connect_health_body">AITURK %1$s · %2$s</string>
<string name="connect_health_auth_gated">게이트(OAuth)</string> <string name="connect_health_auth_gated">게이트(OAuth)</string>
<string name="connect_health_auth_token">토큰</string> <string name="connect_health_auth_token">토큰</string>
@@ -296,13 +294,13 @@
<string name="connect_error_403">접근 거부 (403 Forbidden)</string> <string name="connect_error_403">접근 거부 (403 Forbidden)</string>
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</string> <string name="connect_error_http_code">서버가 HTTP %1$d 반환</string>
<string name="connect_error_timeout">연결 시간 초과</string> <string name="connect_error_timeout">연결 시간 초과</string>
<string name="connect_error_refused">연결 거부됨 – Hermes가 실행 중인가요?</string> <string name="connect_error_refused">연결 거부됨 – AITURK가 실행 중인가요?</string>
<string name="connect_error_resolve">호스트를 찾을 수 없음</string> <string name="connect_error_resolve">호스트를 찾을 수 없음</string>
<string name="connect_error_connection_failed">연결 실패: %1$s</string> <string name="connect_error_connection_failed">연결 실패: %1$s</string>
<string name="connect_save_profile">연결 프로필 저장</string> <string name="connect_save_profile">연결 프로필 저장</string>
<string name="connect_profile_name">프로필 이름</string> <string name="connect_profile_name">프로필 이름</string>
<string name="connect_placeholder_profile_name">예: 집 헤르메스, 사무실 서버</string> <string name="connect_placeholder_profile_name">예: 집 AITURK, 사무실 서버</string>
<string name="connect_action_hide_manual">수동 입력 숨기기</string> <string name="connect_action_hide_manual">수동 입력 숨기기</string>
<string name="connect_action_show_manual">수동으로 입력</string> <string name="connect_action_show_manual">수동으로 입력</string>
<string name="connect_placeholder_token">API 토큰</string> <string name="connect_placeholder_token">API 토큰</string>
@@ -512,7 +510,7 @@
<!-- Logs Screen --> <!-- Logs Screen -->
<string name="logs_empty_title">로그 없음</string> <string name="logs_empty_title">로그 없음</string>
<string name="logs_empty_desc">Hermes의 로그 출력이 여기에 표시됩니다.</string> <string name="logs_empty_desc">AITURK의 로그 출력이 여기에 표시됩니다.</string>
<string name="logs_action_pause">일시정지</string> <string name="logs_action_pause">일시정지</string>
<string name="logs_action_resume">재개</string> <string name="logs_action_resume">재개</string>
<string name="logs_action_export">공유</string> <string name="logs_action_export">공유</string>
@@ -539,7 +537,7 @@
<string name="system_label_arch">아키텍처</string> <string name="system_label_arch">아키텍처</string>
<string name="system_label_hostname">호스트명</string> <string name="system_label_hostname">호스트명</string>
<string name="system_label_python">파이썬</string> <string name="system_label_python">파이썬</string>
<string name="system_label_hermes_version">헤르메스</string> <string name="system_label_hermes_version">AITURK</string>
<string name="system_label_cpu">CPU</string> <string name="system_label_cpu">CPU</string>
<string name="system_label_memory">메모리</string> <string name="system_label_memory">메모리</string>
<string name="system_label_disk">디스크</string> <string name="system_label_disk">디스크</string>
@@ -553,8 +551,8 @@
<string name="system_version_behind">%1$d 버전 뒤짐</string> <string name="system_version_behind">%1$d 버전 뒤짐</string>
<string name="system_action_check_updates">업데이트 확인</string> <string name="system_action_check_updates">업데이트 확인</string>
<string name="system_action_update_now">지금 업데이트</string> <string name="system_action_update_now">지금 업데이트</string>
<string name="system_update_confirm_title">Hermes 업데이트?</string> <string name="system_update_confirm_title">AITURK 업데이트?</string>
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string> <string name="system_update_confirm_desc">AITURK 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
<string name="system_migration_title">게이트웨이 마이그레이션</string> <string name="system_migration_title">게이트웨이 마이그레이션</string>
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string> <string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
<string name="system_migration_profiles">프로필</string> <string name="system_migration_profiles">프로필</string>
@@ -703,7 +701,7 @@
<!-- Profiles Screen --> <!-- Profiles Screen -->
<string name="profiles_empty_title">프로필이 없습니다</string> <string name="profiles_empty_title">프로필이 없습니다</string>
<string name="profiles_empty_desc">Hermes의 연결 프로필이 여기에 표시됩니다.</string> <string name="profiles_empty_desc">AITURK의 연결 프로필이 여기에 표시됩니다.</string>
<string name="profiles_label_model">모델: %1$s</string> <string name="profiles_label_model">모델: %1$s</string>
<string name="profiles_label_provider">제공자: %1$s</string> <string name="profiles_label_provider">제공자: %1$s</string>
<string name="profiles_label_skills">%1$d개 스킬</string> <string name="profiles_label_skills">%1$d개 스킬</string>
@@ -745,7 +743,7 @@
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string> <string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
<string name="profiles_delete_path">경로: %1$s</string> <string name="profiles_delete_path">경로: %1$s</string>
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string> <string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
<string name="profiles_setup_command_hint">Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string> <string name="profiles_setup_command_hint">AITURK CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string> <string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
<string name="profiles_builder_step_identity">아이덴티티</string> <string name="profiles_builder_step_identity">아이덴티티</string>
<string name="profiles_builder_step_model">모델 설정</string> <string name="profiles_builder_step_model">모델 설정</string>
@@ -763,7 +761,7 @@
<string name="profiles_builder_skills_default_desc">공통 시스템 도구 자동 포함</string> <string name="profiles_builder_skills_default_desc">공통 시스템 도구 자동 포함</string>
<string name="profiles_builder_skills_local">로컬 스킬 선택</string> <string name="profiles_builder_skills_local">로컬 스킬 선택</string>
<string name="profiles_builder_skills_search_hub">허브 검색</string> <string name="profiles_builder_skills_search_hub">허브 검색</string>
<string name="profiles_builder_skills_hub_title">Hermes 허브에서 스킬 추가</string> <string name="profiles_builder_skills_hub_title">AITURK 허브에서 스킬 추가</string>
<string name="profiles_builder_skills_added">추가된 스킬:</string> <string name="profiles_builder_skills_added">추가된 스킬:</string>
<string name="profiles_builder_skills_search_results">검색 결과:</string> <string name="profiles_builder_skills_search_results">검색 결과:</string>
<string name="profiles_builder_mcp_title">설정된 MCP 서버</string> <string name="profiles_builder_mcp_title">설정된 MCP 서버</string>
@@ -963,7 +961,7 @@
<!-- Keys Screen --> <!-- Keys Screen -->
<string name="keys_empty_title">환경 변수 키가 없습니다</string> <string name="keys_empty_title">환경 변수 키가 없습니다</string>
<string name="keys_empty_desc">Hermes의 환경 변수 키가 여기에 표시됩니다.</string> <string name="keys_empty_desc">AITURK의 환경 변수 키가 여기에 표시됩니다.</string>
<string name="keys_label_not_configured">설정 안 됨</string> <string name="keys_label_not_configured">설정 안 됨</string>
<string name="keys_action_toggle_visibility">표시 전환</string> <string name="keys_action_toggle_visibility">표시 전환</string>
<string name="keys_search_placeholder">키 검색…</string> <string name="keys_search_placeholder">키 검색…</string>
@@ -978,7 +976,7 @@
<!-- Channels Screen --> <!-- Channels Screen -->
<string name="channels_empty_title">설정된 채널이 없습니다</string> <string name="channels_empty_title">설정된 채널이 없습니다</string>
<string name="channels_empty_desc">Hermes에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string> <string name="channels_empty_desc">AITURK에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string>
<string name="channels_action_configure">설정</string> <string name="channels_action_configure">설정</string>
<string name="channels_sec_settings">설정</string> <string name="channels_sec_settings">설정</string>
<string name="channels_action_save_settings">설정 저장</string> <string name="channels_action_save_settings">설정 저장</string>
@@ -1007,7 +1005,7 @@
<!-- Auth Login Screen --> <!-- Auth Login Screen -->
<string name="auth_login_title">인증 로그인</string> <string name="auth_login_title">인증 로그인</string>
<string name="auth_login_desc">Hermes 대시보드에 연결</string> <string name="auth_login_desc">AITURK 대시보드에 연결</string>
<string name="auth_login_token_label">세션 토큰</string> <string name="auth_login_token_label">세션 토큰</string>
<string name="auth_login_username_label">사용자명</string> <string name="auth_login_username_label">사용자명</string>
<string name="auth_login_password_label">비밀번호</string> <string name="auth_login_password_label">비밀번호</string>
@@ -1022,8 +1020,8 @@
<string name="auth_login_use_profile">%1$s(으)로 접속</string> <string name="auth_login_use_profile">%1$s(으)로 접속</string>
<!-- Landing Screen --> <!-- Landing Screen -->
<string name="landing_title">헤르메스 모바일</string> <string name="landing_title">AITURK Mobil</string>
<string name="landing_subtitle">Hermes Agent 제어판</string> <string name="landing_subtitle">AITURK 제어판</string>
<string name="landing_action_auth_login">인증 로그인</string> <string name="landing_action_auth_login">인증 로그인</string>
<!-- Loading / Startup State (issue #1171) --> <!-- Loading / Startup State (issue #1171) -->
@@ -1210,7 +1208,7 @@
<string name="connection_setup_open_browser">브라우저에서 열기</string> <string name="connection_setup_open_browser">브라우저에서 열기</string>
<string name="connection_setup_required">%1$s은(는) 필수입니다</string> <string name="connection_setup_required">%1$s은(는) 필수입니다</string>
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string> <string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
<string name="connection_setup_pending">Hermes를 기다리는 중…</string> <string name="connection_setup_pending">AITURK를 기다리는 중…</string>
<string name="connection_catalog_title">%1$s 설치</string> <string name="connection_catalog_title">%1$s 설치</string>
<string name="connection_catalog_kind_plugin">플러그인</string> <string name="connection_catalog_kind_plugin">플러그인</string>
<string name="connection_catalog_kind_skill">스킬</string> <string name="connection_catalog_kind_skill">스킬</string>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_image_paste_loading">Yapıştırılan görseller ekleniyor…</string>
<string name="chat_image_paste_failed">Görsel yapıştırılamadı. Erişimi kontrol edip yeniden deneyin.</string>
<string name="chat_image_paste_too_large">Görsel 10 MB ek sınırını aşıyor.</string>
<string name="chat_image_paste_batch_limit">Toplamı 20 MB\'ı geçmeyen en fazla 8 görsel yapıştırılabilir.</string>
<string name="chat_image_paste_busy">Yeniden yapıştırmadan önce görsellerin yüklenmesini bekleyin.</string>
</resources>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_reply_failed_title">AITURK bu yanıtı tamamlayamadı</string>
<string name="chat_reply_failed_description">AITURK yanıt verirken bir sorun oluştu. Ne olduğunu anlamak için ayrıntıları ya da günlükleri açın.</string>
<string name="chat_reply_failed_details">Ayrıntılar</string>
<string name="chat_reply_failed_details_collapsed">Daraltıldı</string>
<string name="chat_reply_failed_details_expanded">Genişletildi</string>
<string name="chat_reply_failed_copy">Hata ayrıntılarını kopyala</string>
<string name="chat_reply_failed_logs">Günlükleri aç</string>
<string name="chat_reply_failed_share">Tanılama bilgisini paylaş</string>
<string name="chat_reply_failed_share_note">Paylaşmadan önce ayrıntıları gözden geçirin. Tanılama bilgisi bu hatayı ve uygulama sürümünü içerir; konuşmanızı ya da eklerinizi içermez.</string>
<string name="chat_reply_failed_dismiss">Hatayı kapat</string>
<string name="chat_reply_failed_copied">Hata ayrıntıları kopyalandı</string>
<string name="chat_reply_failed_share_unavailable">Tanılama bilgisini paylaşacak uygulama yok</string>
</resources>
File diff suppressed because it is too large Load Diff
+43 -39
View File
@@ -1,6 +1,6 @@
<resources> <resources>
<!-- Simplified Chinese (zh) translation --> <!-- Simplified Chinese (zh) translation -->
<string name="app_name">Hermes Mobile</string> <string name="app_name">AITURK Mobil</string>
<string name="chat_send_desc">发送</string> <string name="chat_send_desc">发送</string>
<string name="chat_mic_listening">正在聆听…</string> <string name="chat_mic_listening">正在聆听…</string>
<string name="stt_permission_denied">语音输入需要麦克风权限</string> <string name="stt_permission_denied">语音输入需要麦克风权限</string>
@@ -57,7 +57,7 @@
<string name="billing_plan">套餐</string> <string name="billing_plan">套餐</string>
<string name="billing_topup">充值</string> <string name="billing_topup">充值</string>
<string name="press_back_again_to_exit">再次返回以退出应用</string> <string name="press_back_again_to_exit">再次返回以退出应用</string>
<string name="nav_drawer_title">Hermes</string> <string name="nav_drawer_title">AITURK</string>
<string name="nav_drawer_subtitle">AI Agent 控制台</string> <string name="nav_drawer_subtitle">AI Agent 控制台</string>
<string name="nav_drawer_section_converse">对话</string> <string name="nav_drawer_section_converse">对话</string>
<string name="nav_drawer_section_automate">自动化</string> <string name="nav_drawer_section_automate">自动化</string>
@@ -127,27 +127,27 @@
<string name="screen_analytics">分析</string> <string name="screen_analytics">分析</string>
<string name="screen_billing">计费</string> <string name="screen_billing">计费</string>
<string name="screen_files">文件</string> <string name="screen_files">文件</string>
<string name="notif_title">Hermes</string> <string name="notif_title">AITURK</string>
<string name="notif_waiting_replies">等待 Hermes 回复</string> <string name="notif_waiting_replies">等待 AITURK 回复</string>
<string name="notif_connected_in_background">在后台保持连接</string> <string name="notif_connected_in_background">在后台保持连接</string>
<string name="notif_action_turn_off">关闭</string> <string name="notif_action_turn_off">关闭</string>
<string name="notif_connecting">正在连接…</string> <string name="notif_connecting">正在连接…</string>
<string name="notif_waiting_network">等待网络连接</string> <string name="notif_waiting_network">等待网络连接</string>
<string name="notif_reconnecting">正在重新连接</string> <string name="notif_reconnecting">正在重新连接</string>
<string name="notif_new_message">新消息</string> <string name="notif_new_message">新消息</string>
<string name="notif_clarification_needed">Hermes 需要确认</string> <string name="notif_clarification_needed">AITURK 需要确认</string>
<string name="notif_reply_placeholder">输入回复…</string> <string name="notif_reply_placeholder">输入回复…</string>
<string name="notif_channel_service_name">Hermes 后台服务</string> <string name="notif_channel_service_name">AITURK 后台服务</string>
<string name="notif_channel_service_desc">在后台保持连接</string> <string name="notif_channel_service_desc">在后台保持连接</string>
<string name="notif_channel_chat_name">Hermes 对话回复</string> <string name="notif_channel_chat_name">AITURK 对话回复</string>
<string name="notif_channel_chat_desc">Hermes 新回复提醒</string> <string name="notif_channel_chat_desc">AITURK 新回复提醒</string>
<string name="action_reply">回复</string> <string name="action_reply">回复</string>
<string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string> <string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string>
<string name="chat_action_search">搜索</string> <string name="chat_action_search">搜索</string>
<string name="chat_action_close_search">关闭搜索</string> <string name="chat_action_close_search">关闭搜索</string>
<string name="chat_action_reconnect">重新连接</string> <string name="chat_action_reconnect">重新连接</string>
<string name="chat_action_relogin">重新登录</string> <string name="chat_action_relogin">重新登录</string>
<string name="chat_relogin_title">重新登录 Hermes</string> <string name="chat_relogin_title">重新登录 AITURK</string>
<string name="chat_relogin_username">用户名</string> <string name="chat_relogin_username">用户名</string>
<string name="chat_relogin_password">密码</string> <string name="chat_relogin_password">密码</string>
<string name="chat_relogin_submit">登录</string> <string name="chat_relogin_submit">登录</string>
@@ -155,7 +155,7 @@
<string name="chat_relogin_error_empty">用户名和密码不能为空</string> <string name="chat_relogin_error_empty">用户名和密码不能为空</string>
<string name="chat_empty_title">准备就绪</string> <string name="chat_empty_title">准备就绪</string>
<string name="chat_empty_subtitle">发送消息开始对话</string> <string name="chat_empty_subtitle">发送消息开始对话</string>
<string name="chat_status_connecting">正在连接 Hermes…</string> <string name="chat_status_connecting">正在连接 AITURK…</string>
<string name="chat_resume_error_title">无法连接此会话</string> <string name="chat_resume_error_title">无法连接此会话</string>
<string name="chat_status_reconnecting">重新连接中…</string> <string name="chat_status_reconnecting">重新连接中…</string>
<string name="chat_status_disconnected">已断开</string> <string name="chat_status_disconnected">已断开</string>
@@ -185,7 +185,7 @@
<string name="settings_sec_connection">连接</string> <string name="settings_sec_connection">连接</string>
<string name="settings_field_token">认证令牌</string> <string name="settings_field_token">认证令牌</string>
<string name="settings_keep_connected_in_background_title">在后台保持连接</string> <string name="settings_keep_connected_in_background_title">在后台保持连接</string>
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。</string> <string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 AITURK 连接。会显示常驻通知,并可能消耗更多电量。</string>
<string name="settings_sec_behavior">行为</string> <string name="settings_sec_behavior">行为</string>
<string name="settings_item_auto_reconnect">自动重连</string> <string name="settings_item_auto_reconnect">自动重连</string>
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string> <string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
@@ -227,9 +227,7 @@
<string name="settings_action_test_connection">测试连接</string> <string name="settings_action_test_connection">测试连接</string>
<string name="settings_sec_about">关于</string> <string name="settings_sec_about">关于</string>
<string name="settings_sec_language">语言</string> <string name="settings_sec_language">语言</string>
<string name="settings_about_app_name">Hermes Mobile</string> <string name="settings_about_app_name">AITURK Mobil</string>
<string name="settings_support_title">支持 Hermes Mobile</string>
<string name="settings_support_message">如果 Hermes Mobile 为你节省了时间,欢迎支持它的开发。</string>
<string name="settings_support_action">在 Ko-fi 上支持我</string> <string name="settings_support_action">在 Ko-fi 上支持我</string>
<string name="settings_about_version">版本</string> <string name="settings_about_version">版本</string>
<string name="settings_about_build">构建</string> <string name="settings_about_build">构建</string>
@@ -289,7 +287,7 @@
<string name="connect_selected_profile">配置:%1$s</string> <string name="connect_selected_profile">配置:%1$s</string>
<string name="connect_action_select_profile">选择已保存配置</string> <string name="connect_action_select_profile">选择已保存配置</string>
<string name="connect_health_title">后端健康状态</string> <string name="connect_health_title">后端健康状态</string>
<string name="connect_health_body">Hermes %1$s · %2$s</string> <string name="connect_health_body">AITURK %1$s · %2$s</string>
<string name="connect_health_auth_gated">需登录(OAuth)</string> <string name="connect_health_auth_gated">需登录(OAuth)</string>
<string name="connect_health_auth_token">令牌</string> <string name="connect_health_auth_token">令牌</string>
<string name="connect_error_token_required">需要填写令牌</string> <string name="connect_error_token_required">需要填写令牌</string>
@@ -297,7 +295,7 @@
<string name="connect_error_403">访问被拒绝(403 禁止访问)</string> <string name="connect_error_403">访问被拒绝(403 禁止访问)</string>
<string name="connect_error_http_code">服务器返回 HTTP %1$d</string> <string name="connect_error_http_code">服务器返回 HTTP %1$d</string>
<string name="connect_error_timeout">连接超时</string> <string name="connect_error_timeout">连接超时</string>
<string name="connect_error_refused">连接被拒绝——Hermes 是否在运行?</string> <string name="connect_error_refused">连接被拒绝——AITURK 是否在运行?</string>
<string name="connect_error_resolve">无法解析主机名</string> <string name="connect_error_resolve">无法解析主机名</string>
<string name="connect_error_connection_failed">连接失败:%1$s</string> <string name="connect_error_connection_failed">连接失败:%1$s</string>
<string name="connect_save_profile">保存连接配置</string> <string name="connect_save_profile">保存连接配置</string>
@@ -499,7 +497,7 @@
<string name="skills_hub_source_offline">离线</string> <string name="skills_hub_source_offline">离线</string>
<string name="skills_hub_source_via_index">通过索引</string> <string name="skills_hub_source_via_index">通过索引</string>
<string name="skills_hub_featured_title">精选技能</string> <string name="skills_hub_featured_title">精选技能</string>
<string name="skills_hub_featured_subtitle">来自 Hermes 索引——搜索获取更多</string> <string name="skills_hub_featured_subtitle">来自 AITURK 索引——搜索获取更多</string>
<string name="skills_hub_landing_hint">在上方搜索可安装的技能</string> <string name="skills_hub_landing_hint">在上方搜索可安装的技能</string>
<string name="skills_hub_scan">扫描</string> <string name="skills_hub_scan">扫描</string>
<string name="skills_hub_scan_verdict_label">判定:%1$s</string> <string name="skills_hub_scan_verdict_label">判定:%1$s</string>
@@ -520,7 +518,7 @@
<string name="content_desc_update_skills_hub">从中心更新技能</string> <string name="content_desc_update_skills_hub">从中心更新技能</string>
<string name="content_desc_save_changes">保存更改</string> <string name="content_desc_save_changes">保存更改</string>
<string name="logs_empty_title">暂无日志</string> <string name="logs_empty_title">暂无日志</string>
<string name="logs_empty_desc">Hermes 的日志输出将显示在这里。</string> <string name="logs_empty_desc">AITURK 的日志输出将显示在这里。</string>
<string name="logs_action_pause">暂停</string> <string name="logs_action_pause">暂停</string>
<string name="logs_action_resume">继续</string> <string name="logs_action_resume">继续</string>
<string name="logs_action_export">分享</string> <string name="logs_action_export">分享</string>
@@ -543,7 +541,7 @@
<string name="system_label_arch">架构</string> <string name="system_label_arch">架构</string>
<string name="system_label_hostname">主机名</string> <string name="system_label_hostname">主机名</string>
<string name="system_label_python">Python</string> <string name="system_label_python">Python</string>
<string name="system_label_hermes_version">Hermes</string> <string name="system_label_hermes_version">AITURK</string>
<string name="system_label_cpu">CPU</string> <string name="system_label_cpu">CPU</string>
<string name="system_label_memory">内存</string> <string name="system_label_memory">内存</string>
<string name="system_label_disk">磁盘</string> <string name="system_label_disk">磁盘</string>
@@ -557,7 +555,7 @@
<string name="system_version_behind">落后 %1$d 个版本</string> <string name="system_version_behind">落后 %1$d 个版本</string>
<string name="system_action_check_updates">检查更新</string> <string name="system_action_check_updates">检查更新</string>
<string name="system_action_update_now">立即更新</string> <string name="system_action_update_now">立即更新</string>
<string name="system_update_confirm_title">更新 Hermes?</string> <string name="system_update_confirm_title">更新 AITURK?</string>
<string name="system_update_confirm_desc">这将运行 hermes update 并重启网关。</string> <string name="system_update_confirm_desc">这将运行 hermes update 并重启网关。</string>
<string name="system_update_progress_title">软件更新</string> <string name="system_update_progress_title">软件更新</string>
<string name="system_sec_portal">Nous Portal</string> <string name="system_sec_portal">Nous Portal</string>
@@ -633,10 +631,10 @@
<string name="system_sec_restore">从上传恢复</string> <string name="system_sec_restore">从上传恢复</string>
<string name="system_op_restore_upload">上传恢复包</string> <string name="system_op_restore_upload">上传恢复包</string>
<string name="system_op_restore_pick">选择备份文件</string> <string name="system_op_restore_pick">选择备份文件</string>
<string name="system_op_import_confirm_title">恢复完整 Hermes 备份?</string> <string name="system_op_import_confirm_title">恢复完整 AITURK 备份?</string>
<string name="system_op_import_confirm_desc">这将覆盖当前的 Hermes 配置、技能、会话和数据。此操作无法撤销。</string> <string name="system_op_import_confirm_desc">这将覆盖当前的 AITURK 配置、技能、会话和数据。此操作无法撤销。</string>
<string name="system_sec_debug_share">调试分享</string> <string name="system_sec_debug_share">调试分享</string>
<string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 Hermes 团队。粘贴内容 6 小时后自动删除。</string> <string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 AITURK 团队。粘贴内容 6 小时后自动删除。</string>
<string name="system_debug_share_generate">生成分享链接</string> <string name="system_debug_share_generate">生成分享链接</string>
<string name="system_debug_share_uploading">上传中…</string> <string name="system_debug_share_uploading">上传中…</string>
<string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string> <string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string>
@@ -691,7 +689,7 @@
<item quantity="other">%1$d 个模型</item> <item quantity="other">%1$d 个模型</item>
</plurals> </plurals>
<string name="history_empty_title">暂无历史</string> <string name="history_empty_title">暂无历史</string>
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string> <string name="history_empty_desc">之前的 AITURK 对话会话将显示在这里。</string>
<string name="history_untitled">未命名</string> <string name="history_untitled">未命名</string>
<string name="history_message_count">%1$d 条消息</string> <string name="history_message_count">%1$d 条消息</string>
<plurals name="sessions_card_message_count"> <plurals name="sessions_card_message_count">
@@ -820,7 +818,7 @@
<string name="config_action_save">保存</string> <string name="config_action_save">保存</string>
<string name="config_action_save_yaml">保存 YAML</string> <string name="config_action_save_yaml">保存 YAML</string>
<string name="profiles_empty_title">没有可用配置</string> <string name="profiles_empty_title">没有可用配置</string>
<string name="profiles_empty_desc">Hermes 的连接配置将显示在这里。</string> <string name="profiles_empty_desc">AITURK 的连接配置将显示在这里。</string>
<string name="profiles_label_model">模型:%1$s</string> <string name="profiles_label_model">模型:%1$s</string>
<string name="profiles_label_provider">提供商:%1$s</string> <string name="profiles_label_provider">提供商:%1$s</string>
<string name="profiles_label_skills">%1$d 个技能</string> <string name="profiles_label_skills">%1$d 个技能</string>
@@ -862,7 +860,7 @@
<string name="profiles_delete_warning">这将永久删除配置"%1$s"及其数据。</string> <string name="profiles_delete_warning">这将永久删除配置"%1$s"及其数据。</string>
<string name="profiles_delete_path">路径:%1$s</string> <string name="profiles_delete_path">路径:%1$s</string>
<string name="profiles_delete_gateway_warning">此配置有正在运行的网关——删除它会将其停止。</string> <string name="profiles_delete_gateway_warning">此配置有正在运行的网关——删除它会将其停止。</string>
<string name="profiles_setup_command_hint">在 Hermes CLI 中运行此命令以配置此配置文件。</string> <string name="profiles_setup_command_hint">在 AITURK CLI 中运行此命令以配置此配置文件。</string>
<string name="profiles_builder_step_indicator">第 %1$d 步,共 5 步</string> <string name="profiles_builder_step_indicator">第 %1$d 步,共 5 步</string>
<string name="profiles_builder_step_identity">身份</string> <string name="profiles_builder_step_identity">身份</string>
<string name="profiles_builder_step_model">模型配置</string> <string name="profiles_builder_step_model">模型配置</string>
@@ -880,7 +878,7 @@
<string name="profiles_builder_skills_default_desc">自动打包常用系统工具</string> <string name="profiles_builder_skills_default_desc">自动打包常用系统工具</string>
<string name="profiles_builder_skills_local">选择本地技能</string> <string name="profiles_builder_skills_local">选择本地技能</string>
<string name="profiles_builder_skills_search_hub">搜索中心</string> <string name="profiles_builder_skills_search_hub">搜索中心</string>
<string name="profiles_builder_skills_hub_title">从 Hermes 中心添加技能</string> <string name="profiles_builder_skills_hub_title">从 AITURK 中心添加技能</string>
<string name="profiles_builder_skills_added">已添加技能:</string> <string name="profiles_builder_skills_added">已添加技能:</string>
<string name="profiles_builder_skills_search_results">搜索结果:</string> <string name="profiles_builder_skills_search_results">搜索结果:</string>
<string name="profiles_builder_mcp_title">已配置的 MCP 服务器</string> <string name="profiles_builder_mcp_title">已配置的 MCP 服务器</string>
@@ -1016,7 +1014,7 @@
<string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string> <string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string>
<string name="pressure_banner_memory_elevated">代理内存不足</string> <string name="pressure_banner_memory_elevated">代理内存不足</string>
<string name="cron_empty_title">没有定时任务</string> <string name="cron_empty_title">没有定时任务</string>
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string> <string name="cron_empty_desc">AITURK 的定时任务将显示在这里。</string>
<string name="cron_status_active">启用</string> <string name="cron_status_active">启用</string>
<string name="cron_status_paused">已暂停</string> <string name="cron_status_paused">已暂停</string>
<string name="cron_action_pause">暂停</string> <string name="cron_action_pause">暂停</string>
@@ -1091,7 +1089,7 @@
<string name="achievements_reset_title">重置成就?</string> <string name="achievements_reset_title">重置成就?</string>
<string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</string> <string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</string>
<string name="keys_empty_title">没有环境密钥数据</string> <string name="keys_empty_title">没有环境密钥数据</string>
<string name="keys_empty_desc">Hermes 的环境密钥将显示在这里。</string> <string name="keys_empty_desc">AITURK 的环境密钥将显示在这里。</string>
<string name="keys_label_not_configured">未配置</string> <string name="keys_label_not_configured">未配置</string>
<string name="keys_action_toggle_visibility">切换可见性</string> <string name="keys_action_toggle_visibility">切换可见性</string>
<string name="keys_search_placeholder">搜索密钥…</string> <string name="keys_search_placeholder">搜索密钥…</string>
@@ -1132,7 +1130,7 @@
<string name="files_dialog_delete_title">删除?</string> <string name="files_dialog_delete_title">删除?</string>
<string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</string> <string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</string>
<string name="channels_empty_title">未配置渠道</string> <string name="channels_empty_title">未配置渠道</string>
<string name="channels_empty_desc">在 Hermes 中添加消息平台后,会显示在这里。</string> <string name="channels_empty_desc">在 AITURK 中添加消息平台后,会显示在这里。</string>
<string name="channels_action_configure">配置</string> <string name="channels_action_configure">配置</string>
<string name="channels_sec_settings">配置设置</string> <string name="channels_sec_settings">配置设置</string>
<string name="channels_action_save_settings">保存设置</string> <string name="channels_action_save_settings">保存设置</string>
@@ -1155,7 +1153,7 @@
<string name="settings_saved_profiles">已保存配置(%1$d)</string> <string name="settings_saved_profiles">已保存配置(%1$d)</string>
<string name="settings_logout">退出登录</string> <string name="settings_logout">退出登录</string>
<string name="auth_login_title">认证登录</string> <string name="auth_login_title">认证登录</string>
<string name="auth_login_desc">连接到你的 Hermes dashboard</string> <string name="auth_login_desc">连接到你的 AITURK dashboard</string>
<string name="auth_login_base_url_label">服务器地址</string> <string name="auth_login_base_url_label">服务器地址</string>
<string name="auth_login_token_label">会话令牌</string> <string name="auth_login_token_label">会话令牌</string>
<string name="auth_login_username_label">用户名</string> <string name="auth_login_username_label">用户名</string>
@@ -1168,11 +1166,11 @@
<string name="auth_login_error_password_required">需要填写密码</string> <string name="auth_login_error_password_required">需要填写密码</string>
<string name="auth_login_error_unreachable">无法访问 dashboard——检查 IP 和端口</string> <string name="auth_login_error_unreachable">无法访问 dashboard——检查 IP 和端口</string>
<string name="auth_login_error_oauth_unsupported">此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639)</string> <string name="auth_login_error_oauth_unsupported">此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639)</string>
<string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 Hermes Mobile 中支持。</string> <string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 AITURK Mobil 中支持。</string>
<string name="auth_login_existing_profiles_title">使用已登录的配置:</string> <string name="auth_login_existing_profiles_title">使用已登录的配置:</string>
<string name="auth_login_use_profile">以 %1$s 身份进入</string> <string name="auth_login_use_profile">以 %1$s 身份进入</string>
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">AITURK Mobil</string>
<string name="landing_subtitle">Hermes Agent 控制面板</string> <string name="landing_subtitle">AITURK 控制面板</string>
<string name="landing_action_auth_login">认证登录</string> <string name="landing_action_auth_login">认证登录</string>
<string name="auth_init_failed">应用设置初始化失败</string> <string name="auth_init_failed">应用设置初始化失败</string>
<string name="providers_empty_title">没有 OAuth 提供商</string> <string name="providers_empty_title">没有 OAuth 提供商</string>
@@ -1193,7 +1191,7 @@
<string name="action_dismiss">忽略</string> <string name="action_dismiss">忽略</string>
<string name="action_done">完成</string> <string name="action_done">完成</string>
<string name="action_close">关闭</string> <string name="action_close">关闭</string>
<string name="update_notice_banner_text">Hermes Mobile %1$s 已可用</string> <string name="update_notice_banner_text">AITURK Mobil %1$s 已可用</string>
<string name="update_notice_banner_action">更新</string> <string name="update_notice_banner_action">更新</string>
<string name="processes_empty_title">没有运行中的进程</string> <string name="processes_empty_title">没有运行中的进程</string>
<string name="processes_empty_desc">此会话的后台进程将显示在这里。</string> <string name="processes_empty_desc">此会话的后台进程将显示在这里。</string>
@@ -1283,7 +1281,7 @@
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string> <string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
<string name="gif_play">播放 GIF</string> <string name="gif_play">播放 GIF</string>
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string> <string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
<string name="logs_clipboard_label">Hermes 日志</string> <string name="logs_clipboard_label">AITURK 日志</string>
<string name="logs_search_placeholder">搜索日志…</string> <string name="logs_search_placeholder">搜索日志…</string>
<string name="mcp_no_match">没有匹配&quot;%1$s&quot;的服务器</string> <string name="mcp_no_match">没有匹配&quot;%1$s&quot;的服务器</string>
<string name="mcp_search_placeholder">搜索 MCP 服务器…</string> <string name="mcp_search_placeholder">搜索 MCP 服务器…</string>
@@ -1506,7 +1504,7 @@
<string name="connection_setup_open_browser">在浏览器中打开</string> <string name="connection_setup_open_browser">在浏览器中打开</string>
<string name="connection_setup_required">%1$s为必填项</string> <string name="connection_setup_required">%1$s为必填项</string>
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string> <string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
<string name="connection_setup_pending">正在等待 Hermes…</string> <string name="connection_setup_pending">正在等待 AITURK…</string>
<string name="connection_catalog_title">安装 %1$s</string> <string name="connection_catalog_title">安装 %1$s</string>
<string name="connection_catalog_kind_plugin">插件</string> <string name="connection_catalog_kind_plugin">插件</string>
<string name="connection_catalog_kind_skill">技能</string> <string name="connection_catalog_kind_skill">技能</string>
@@ -1535,6 +1533,12 @@
<string name="chat_busy_override">本条消息的发送方式</string> <string name="chat_busy_override">本条消息的发送方式</string>
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string> <string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
<string name="chat_pending_sends">待发送消息</string> <string name="chat_pending_sends">待发送消息</string>
<string name="chat_send_accepted">服务器已接收</string>
<plurals name="chat_pending_count">
<item quantity="other">%d 条待处理消息</item>
</plurals>
<string name="chat_pending_discard">移除</string>
<string name="chat_pending_discard_warning">要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。</string>
<string name="chat_pending_queued">已在本机排队</string> <string name="chat_pending_queued">已在本机排队</string>
<string name="chat_pending_parked">停止后已暂停</string> <string name="chat_pending_parked">停止后已暂停</string>
<string name="chat_pending_sending">正在发送…</string> <string name="chat_pending_sending">正在发送…</string>
@@ -1565,7 +1569,7 @@
<string name="detail_dialog_docs">文档</string> <string name="detail_dialog_docs">文档</string>
<string name="detail_dialog_maintainer">维护者</string> <string name="detail_dialog_maintainer">维护者</string>
<string name="detail_dialog_repository">仓库</string> <string name="detail_dialog_repository">仓库</string>
<string name="detail_dialog_requires_hermes">需要 Hermes</string> <string name="detail_dialog_requires_hermes">需要 AITURK</string>
<string name="detail_dialog_tier">层级</string> <string name="detail_dialog_tier">层级</string>
<string name="group_chat_action_stop">停止生成</string> <string name="group_chat_action_stop">停止生成</string>
<string name="group_chat_bot_passed">%1$s 本轮未发言</string> <string name="group_chat_bot_passed">%1$s 本轮未发言</string>
@@ -1574,7 +1578,7 @@
<string name="group_chat_tool_no_output_success">退出码 0(无输出)</string> <string name="group_chat_tool_no_output_success">退出码 0(无输出)</string>
<string name="group_chat_tool_running">运行中…</string> <string name="group_chat_tool_running">运行中…</string>
<string name="kanban_bulk_complete_title">完成 %1$d 项任务</string> <string name="kanban_bulk_complete_title">完成 %1$d 项任务</string>
<string name="notif_input_needed">Hermes 需要你的输入</string> <string name="notif_input_needed">AITURK 需要你的输入</string>
<string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string> <string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string>
<string name="plugins_catalog_empty_title">未找到目录插件</string> <string name="plugins_catalog_empty_title">未找到目录插件</string>
<string name="plugins_catalog_filter_all">全部</string> <string name="plugins_catalog_filter_all">全部</string>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_image_paste_loading">Adding pasted images…</string>
<string name="chat_image_paste_failed">Could not paste the image. Check access and try again.</string>
<string name="chat_image_paste_too_large">Image exceeds the 10 MB attachment limit.</string>
<string name="chat_image_paste_batch_limit">Paste up to 8 images totaling no more than 20 MB.</string>
<string name="chat_image_paste_busy">Wait for the images to finish loading before pasting again.</string>
</resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="chat_reply_failed_title">Hermes couldn\'t finish this reply</string> <string name="chat_reply_failed_title">AITURK couldn\'t finish this reply</string>
<string name="chat_reply_failed_description">Something went wrong while Hermes was replying. Open the details or logs to understand what happened.</string> <string name="chat_reply_failed_description">Something went wrong while AITURK was replying. Open the details or logs to understand what happened.</string>
<string name="chat_reply_failed_details">Details</string> <string name="chat_reply_failed_details">Details</string>
<string name="chat_reply_failed_details_collapsed">Collapsed</string> <string name="chat_reply_failed_details_collapsed">Collapsed</string>
<string name="chat_reply_failed_details_expanded">Expanded</string> <string name="chat_reply_failed_details_expanded">Expanded</string>
+91 -43
View File
@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">Hermes Mobile</string> <string name="app_name">AITURK Mobil</string>
<!-- Chat --> <!-- Chat -->
<string name="chat_send_desc">Send</string> <string name="chat_send_desc">Send</string>
@@ -75,7 +75,7 @@
<!-- Navigation & Drawer --> <!-- Navigation & Drawer -->
<string name="press_back_again_to_exit">Press back again to exit</string> <string name="press_back_again_to_exit">Press back again to exit</string>
<string name="nav_drawer_title">Hermes</string> <string name="nav_drawer_title">AITURK</string>
<string name="nav_drawer_subtitle">AI Agent Control</string> <string name="nav_drawer_subtitle">AI Agent Control</string>
<string name="nav_drawer_section_converse">Converse</string> <string name="nav_drawer_section_converse">Converse</string>
<string name="nav_drawer_section_automate">Automate</string> <string name="nav_drawer_section_automate">Automate</string>
@@ -150,21 +150,21 @@
<string name="screen_files">Files</string> <string name="screen_files">Files</string>
<!-- Notifications --> <!-- Notifications -->
<string name="notif_title">Hermes</string> <string name="notif_title">AITURK</string>
<string name="notif_waiting_replies">Waiting for Hermes replies</string> <string name="notif_waiting_replies">Waiting for AITURK replies</string>
<string name="notif_connected_in_background">Connected in background</string> <string name="notif_connected_in_background">Connected in background</string>
<string name="notif_action_turn_off">Turn off</string> <string name="notif_action_turn_off">Turn off</string>
<string name="notif_connecting">Connecting…</string> <string name="notif_connecting">Connecting…</string>
<string name="notif_waiting_network">Waiting for network</string> <string name="notif_waiting_network">Waiting for network</string>
<string name="notif_reconnecting">Reconnecting</string> <string name="notif_reconnecting">Reconnecting</string>
<string name="notif_new_message">New message</string> <string name="notif_new_message">New message</string>
<string name="notif_clarification_needed">Hermes needs a clarification</string> <string name="notif_clarification_needed">AITURK needs a clarification</string>
<string name="notif_input_needed">Hermes needs your input</string> <string name="notif_input_needed">AITURK needs your input</string>
<string name="notif_reply_placeholder">Type your reply\u2026</string> <string name="notif_reply_placeholder">Type your reply\u2026</string>
<string name="notif_channel_service_name">Hermes Background Service</string> <string name="notif_channel_service_name">AITURK Background Service</string>
<string name="notif_channel_service_desc">Keeps connection alive in the background</string> <string name="notif_channel_service_desc">Keeps connection alive in the background</string>
<string name="notif_channel_chat_name">Hermes Chat Replies</string> <string name="notif_channel_chat_name">AITURK Chat Replies</string>
<string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string> <string name="notif_channel_chat_desc">Alerts for new replies from AITURK</string>
<string name="notif_session_complete">Session finished: %1$s</string> <string name="notif_session_complete">Session finished: %1$s</string>
<string name="notif_session_failed">Session failed: %1$s</string> <string name="notif_session_failed">Session failed: %1$s</string>
<string name="action_reply">Reply</string> <string name="action_reply">Reply</string>
@@ -188,7 +188,7 @@
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string> <string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
<string name="chat_action_reconnect">Reconnect</string> <string name="chat_action_reconnect">Reconnect</string>
<string name="chat_action_relogin">Re-login</string> <string name="chat_action_relogin">Re-login</string>
<string name="chat_relogin_title">Re-login to Hermes</string> <string name="chat_relogin_title">Re-login to AITURK</string>
<string name="chat_relogin_username">Username</string> <string name="chat_relogin_username">Username</string>
<string name="chat_relogin_password">Password</string> <string name="chat_relogin_password">Password</string>
<string name="chat_relogin_submit">Login</string> <string name="chat_relogin_submit">Login</string>
@@ -196,7 +196,7 @@
<string name="chat_relogin_error_empty">Username and password cannot be empty</string> <string name="chat_relogin_error_empty">Username and password cannot be empty</string>
<string name="chat_empty_title">Ready to chat</string> <string name="chat_empty_title">Ready to chat</string>
<string name="chat_empty_subtitle">Send a message to start a conversation</string> <string name="chat_empty_subtitle">Send a message to start a conversation</string>
<string name="chat_status_connecting">Connecting to Hermes\u2026</string> <string name="chat_status_connecting">Connecting to AITURK\u2026</string>
<string name="chat_resume_error_title">Couldn\'t connect to this session</string> <string name="chat_resume_error_title">Couldn\'t connect to this session</string>
<string name="chat_status_reconnecting">Reconnecting\u2026</string> <string name="chat_status_reconnecting">Reconnecting\u2026</string>
<string name="chat_status_disconnected">Disconnected</string> <string name="chat_status_disconnected">Disconnected</string>
@@ -219,6 +219,7 @@
<string name="chat_busy_override">Send mode for this message</string> <string name="chat_busy_override">Send mode for this message</string>
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string> <string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
<string name="chat_pending_sends">Pending messages</string> <string name="chat_pending_sends">Pending messages</string>
<string name="chat_send_accepted">Accepted by server</string>
<plurals name="chat_pending_count"> <plurals name="chat_pending_count">
<item quantity="one">%d pending message</item> <item quantity="one">%d pending message</item>
<item quantity="other">%d pending messages</item> <item quantity="other">%d pending messages</item>
@@ -249,7 +250,7 @@
<string name="content_desc_copied">Copied</string> <string name="content_desc_copied">Copied</string>
<string name="content_desc_speak">Read aloud</string> <string name="content_desc_speak">Read aloud</string>
<string name="content_desc_stop_speaking">Stop speaking</string> <string name="content_desc_stop_speaking">Stop speaking</string>
<string name="tts_error_network">Could not reach the Hermes server for speech</string> <string name="tts_error_network">Could not reach the AITURK server for speech</string>
<string name="tts_error_synthesis">Could not synthesize the message audio</string> <string name="tts_error_synthesis">Could not synthesize the message audio</string>
<string name="tts_error_playback">Could not play the message audio</string> <string name="tts_error_playback">Could not play the message audio</string>
<string name="content_desc_edit">Edit</string> <string name="content_desc_edit">Edit</string>
@@ -277,9 +278,9 @@
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string> <string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
<string name="settings_field_token">Auth Token</string> <string name="settings_field_token">Auth Token</string>
<string name="settings_keep_connected_in_background_title">Stay connected in background</string> <string name="settings_keep_connected_in_background_title">Stay connected in background</string>
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string> <string name="settings_keep_connected_in_background_desc">Keep AITURK connected while using other apps. Shows an ongoing notification and may use more battery.</string>
<string name="settings_notify_session_completions_title">Notify when a session completes</string> <string name="settings_notify_session_completions_title">Notify when a session completes</string>
<string name="settings_notify_session_completions_desc">Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string> <string name="settings_notify_session_completions_desc">Alert when any AITURK session finishes while the app is in the background. Keeps AITURK connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string>
<string name="settings_sec_behavior">Behavior</string> <string name="settings_sec_behavior">Behavior</string>
<string name="settings_item_auto_reconnect">Auto-Reconnect</string> <string name="settings_item_auto_reconnect">Auto-Reconnect</string>
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string> <string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
@@ -321,7 +322,9 @@
<string name="settings_action_test_connection">Test Connection</string> <string name="settings_action_test_connection">Test Connection</string>
<string name="settings_sec_about">About</string> <string name="settings_sec_about">About</string>
<string name="settings_sec_language">Language</string> <string name="settings_sec_language">Language</string>
<string name="settings_about_app_name">Hermes Mobile</string> <string name="settings_about_app_name">AITURK Mobil</string>
<string name="settings_about_attribution">AITURK Mobil is adapted from Hy4ri/hermes-mobile (Apache-2.0).</string>
<string name="settings_about_download">Updates</string>
<string name="settings_about_version">Version</string> <string name="settings_about_version">Version</string>
<string name="settings_about_build">Build</string> <string name="settings_about_build">Build</string>
<string name="settings_about_debug">Debug</string> <string name="settings_about_debug">Debug</string>
@@ -340,9 +343,9 @@
<string name="settings_about_update_retry">Retry</string> <string name="settings_about_update_retry">Retry</string>
<string name="settings_about_rc_updates_title">Check release candidate updates</string> <string name="settings_about_rc_updates_title">Check release candidate updates</string>
<string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string> <string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string>
<string name="settings_support_title">Support Hermes Mobile</string> <string name="settings_support_title">Support the original developer</string>
<string name="settings_support_message">If Hermes Mobile saves you time, you can support its development.</string> <string name="settings_support_message">AITURK Mobil is adapted from the open-source hermes-mobile project by Hy4ri. If it saves you time, you can support the original developer.</string>
<string name="settings_support_action">Support me on Ko-fi</string> <string name="settings_support_action">Support on Ko-fi</string>
<!-- Settings list summaries (drill-down) --> <!-- Settings list summaries (drill-down) -->
<string name="settings_summary_profiles">%1$d saved</string> <string name="settings_summary_profiles">%1$d saved</string>
@@ -385,12 +388,13 @@
<string name="language_arabic">العربية</string> <string name="language_arabic">العربية</string>
<string name="language_chinese">简体中文</string> <string name="language_chinese">简体中文</string>
<string name="language_korean">한국어</string> <string name="language_korean">한국어</string>
<string name="language_turkish">Türkçe</string>
<!-- Connect Screen --> <!-- Connect Screen -->
<string name="connect_selected_profile">Profile: %1$s</string> <string name="connect_selected_profile">Profile: %1$s</string>
<string name="connect_action_select_profile">Select Saved Profile</string> <string name="connect_action_select_profile">Select Saved Profile</string>
<string name="connect_health_title">Backend health</string> <string name="connect_health_title">Backend health</string>
<string name="connect_health_body">Hermes %1$s · %2$s</string> <string name="connect_health_body">AITURK %1$s · %2$s</string>
<string name="connect_health_auth_gated">Gated (OAuth)</string> <string name="connect_health_auth_gated">Gated (OAuth)</string>
<string name="connect_health_auth_token">Token</string> <string name="connect_health_auth_token">Token</string>
@@ -400,7 +404,7 @@
<string name="connect_error_403">Access denied (403 Forbidden)</string> <string name="connect_error_403">Access denied (403 Forbidden)</string>
<string name="connect_error_http_code">Server returned HTTP %1$d</string> <string name="connect_error_http_code">Server returned HTTP %1$d</string>
<string name="connect_error_timeout">Connection timed out</string> <string name="connect_error_timeout">Connection timed out</string>
<string name="connect_error_refused">Connection refused – is Hermes running?</string> <string name="connect_error_refused">Connection refused – is AITURK IDE running?</string>
<string name="connect_error_resolve">Could not resolve host</string> <string name="connect_error_resolve">Could not resolve host</string>
<string name="connect_error_connection_failed">Connection failed: %1$s</string> <string name="connect_error_connection_failed">Connection failed: %1$s</string>
@@ -608,7 +612,7 @@
<string name="skills_hub_source_offline">offline</string> <string name="skills_hub_source_offline">offline</string>
<string name="skills_hub_source_via_index">via index</string> <string name="skills_hub_source_via_index">via index</string>
<string name="skills_hub_featured_title">Featured skills</string> <string name="skills_hub_featured_title">Featured skills</string>
<string name="skills_hub_featured_subtitle">from the Hermes index — search above for more</string> <string name="skills_hub_featured_subtitle">from the skills index — search above for more</string>
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string> <string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
<string name="skills_hub_scan">Scan</string> <string name="skills_hub_scan">Scan</string>
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string> <string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
@@ -636,7 +640,7 @@
<!-- Logs Screen --> <!-- Logs Screen -->
<string name="logs_empty_title">No logs available</string> <string name="logs_empty_title">No logs available</string>
<string name="logs_empty_desc">Log output from Hermes will appear here.</string> <string name="logs_empty_desc">Log output from AITURK will appear here.</string>
<string name="logs_action_pause">Pause</string> <string name="logs_action_pause">Pause</string>
<string name="logs_action_resume">Resume</string> <string name="logs_action_resume">Resume</string>
<string name="logs_action_export">Share</string> <string name="logs_action_export">Share</string>
@@ -663,7 +667,7 @@
<string name="system_label_arch">Arch</string> <string name="system_label_arch">Arch</string>
<string name="system_label_hostname">Host</string> <string name="system_label_hostname">Host</string>
<string name="system_label_python">Python</string> <string name="system_label_python">Python</string>
<string name="system_label_hermes_version">Hermes</string> <string name="system_label_hermes_version">AITURK</string>
<string name="system_label_cpu">CPU</string> <string name="system_label_cpu">CPU</string>
<string name="system_label_memory">Memory</string> <string name="system_label_memory">Memory</string>
<string name="system_label_disk">Disk</string> <string name="system_label_disk">Disk</string>
@@ -677,7 +681,7 @@
<string name="system_version_behind">%1$d behind</string> <string name="system_version_behind">%1$d behind</string>
<string name="system_action_check_updates">Check for updates</string> <string name="system_action_check_updates">Check for updates</string>
<string name="system_action_update_now">Update now</string> <string name="system_action_update_now">Update now</string>
<string name="system_update_confirm_title">Update Hermes?</string> <string name="system_update_confirm_title">Update AITURK?</string>
<string name="system_update_confirm_desc">This will run hermes update and restart the gateway.</string> <string name="system_update_confirm_desc">This will run hermes update and restart the gateway.</string>
<string name="system_update_progress_title">Software update</string> <string name="system_update_progress_title">Software update</string>
@@ -766,12 +770,12 @@
<string name="system_sec_restore">Restore from upload</string> <string name="system_sec_restore">Restore from upload</string>
<string name="system_op_restore_upload">Restore upload</string> <string name="system_op_restore_upload">Restore upload</string>
<string name="system_op_restore_pick">Choose backup archive</string> <string name="system_op_restore_pick">Choose backup archive</string>
<string name="system_op_import_confirm_title">Restore full Hermes backup?</string> <string name="system_op_import_confirm_title">Restore full AITURK backup?</string>
<string name="system_op_import_confirm_desc">This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone.</string> <string name="system_op_import_confirm_desc">This will overwrite your current AITURK configuration, skills, sessions, and data. This cannot be undone.</string>
<!-- Debug share section --> <!-- Debug share section -->
<string name="system_sec_debug_share">Debug share</string> <string name="system_sec_debug_share">Debug share</string>
<string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send the Hermes team. Pastes auto-delete after 6 hours.</string> <string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send to support. Pastes auto-delete after 6 hours.</string>
<string name="system_debug_share_generate">Generate share link</string> <string name="system_debug_share_generate">Generate share link</string>
<string name="system_debug_share_uploading">Uploading…</string> <string name="system_debug_share_uploading">Uploading…</string>
<string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string> <string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string>
@@ -838,7 +842,7 @@
<!-- Sessions (History) Screen --> <!-- Sessions (History) Screen -->
<string name="history_empty_title">No history</string> <string name="history_empty_title">No history</string>
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string> <string name="history_empty_desc">Previous chat sessions from AITURK will appear here.</string>
<string name="history_untitled">Untitled</string> <string name="history_untitled">Untitled</string>
<string name="history_message_count">%1$d messages</string> <string name="history_message_count">%1$d messages</string>
<plurals name="sessions_card_message_count"> <plurals name="sessions_card_message_count">
@@ -988,7 +992,7 @@
<!-- Profiles Screen --> <!-- Profiles Screen -->
<string name="profiles_empty_title">No profiles available</string> <string name="profiles_empty_title">No profiles available</string>
<string name="profiles_empty_desc">Connection profiles from Hermes will appear here.</string> <string name="profiles_empty_desc">Connection profiles from AITURK will appear here.</string>
<string name="profiles_label_model">Model: %1$s</string> <string name="profiles_label_model">Model: %1$s</string>
<string name="profiles_label_provider">Provider: %1$s</string> <string name="profiles_label_provider">Provider: %1$s</string>
<string name="profiles_label_skills">%1$d skills</string> <string name="profiles_label_skills">%1$d skills</string>
@@ -1030,7 +1034,7 @@
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string> <string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
<string name="profiles_delete_path">Path: %1$s</string> <string name="profiles_delete_path">Path: %1$s</string>
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string> <string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
<string name="profiles_setup_command_hint">Run this command in the Hermes CLI to configure this profile.</string> <string name="profiles_setup_command_hint">Run this command in the agent CLI to configure this profile.</string>
<string name="profiles_builder_step_indicator">Step %1$d of 5</string> <string name="profiles_builder_step_indicator">Step %1$d of 5</string>
<string name="profiles_builder_step_identity">Identity</string> <string name="profiles_builder_step_identity">Identity</string>
<string name="profiles_builder_step_model">Model Configuration</string> <string name="profiles_builder_step_model">Model Configuration</string>
@@ -1048,7 +1052,7 @@
<string name="profiles_builder_skills_default_desc">Automatically bundles common system tools</string> <string name="profiles_builder_skills_default_desc">Automatically bundles common system tools</string>
<string name="profiles_builder_skills_local">Select Local Skills</string> <string name="profiles_builder_skills_local">Select Local Skills</string>
<string name="profiles_builder_skills_search_hub">Search Hub</string> <string name="profiles_builder_skills_search_hub">Search Hub</string>
<string name="profiles_builder_skills_hub_title">Add Skills from Hermes Hub</string> <string name="profiles_builder_skills_hub_title">Add Skills from the Skills Hub</string>
<string name="profiles_builder_skills_added">Added Skills:</string> <string name="profiles_builder_skills_added">Added Skills:</string>
<string name="profiles_builder_skills_search_results">Search Results:</string> <string name="profiles_builder_skills_search_results">Search Results:</string>
<string name="profiles_builder_mcp_title">Configured MCP Servers</string> <string name="profiles_builder_mcp_title">Configured MCP Servers</string>
@@ -1164,7 +1168,7 @@
<string name="detail_dialog_maintainer">Maintainer</string> <string name="detail_dialog_maintainer">Maintainer</string>
<string name="detail_dialog_repository">Repository</string> <string name="detail_dialog_repository">Repository</string>
<string name="detail_dialog_commit">Commit SHA</string> <string name="detail_dialog_commit">Commit SHA</string>
<string name="detail_dialog_requires_hermes">Requires Hermes</string> <string name="detail_dialog_requires_hermes">Requires AITURK</string>
<string name="detail_dialog_docs">Documentation</string> <string name="detail_dialog_docs">Documentation</string>
<string name="detail_dialog_capabilities">Capabilities</string> <string name="detail_dialog_capabilities">Capabilities</string>
@@ -1240,7 +1244,7 @@
<!-- CronJobs Screen --> <!-- CronJobs Screen -->
<string name="cron_empty_title">No cron jobs</string> <string name="cron_empty_title">No cron jobs</string>
<string name="cron_empty_desc">Scheduled tasks from Hermes will appear here.</string> <string name="cron_empty_desc">Scheduled tasks from AITURK will appear here.</string>
<string name="cron_status_active">Active</string> <string name="cron_status_active">Active</string>
<string name="cron_status_paused">Paused</string> <string name="cron_status_paused">Paused</string>
<string name="cron_action_pause">Pause</string> <string name="cron_action_pause">Pause</string>
@@ -1319,7 +1323,7 @@
<!-- Keys Screen --> <!-- Keys Screen -->
<string name="keys_empty_title">No environment keys reported</string> <string name="keys_empty_title">No environment keys reported</string>
<string name="keys_empty_desc">Environment keys from Hermes will appear here.</string> <string name="keys_empty_desc">Environment keys from AITURK will appear here.</string>
<string name="keys_label_not_configured">Not configured</string> <string name="keys_label_not_configured">Not configured</string>
<string name="keys_action_toggle_visibility">Toggle Visibility</string> <string name="keys_action_toggle_visibility">Toggle Visibility</string>
<string name="keys_search_placeholder">Search keys…</string> <string name="keys_search_placeholder">Search keys…</string>
@@ -1364,7 +1368,7 @@
<!-- Channels Screen --> <!-- Channels Screen -->
<string name="channels_empty_title">No channels configured</string> <string name="channels_empty_title">No channels configured</string>
<string name="channels_empty_desc">Add messaging platforms in Hermes to see them here.</string> <string name="channels_empty_desc">Add messaging platforms in AITURK to see them here.</string>
<string name="channels_action_configure">Configure</string> <string name="channels_action_configure">Configure</string>
<string name="channels_sec_settings">Configuration Settings</string> <string name="channels_sec_settings">Configuration Settings</string>
<string name="channels_action_save_settings">Save Settings</string> <string name="channels_action_save_settings">Save Settings</string>
@@ -1393,7 +1397,7 @@
<!-- Auth Login Screen --> <!-- Auth Login Screen -->
<string name="auth_login_title">Auth Login</string> <string name="auth_login_title">Auth Login</string>
<string name="auth_login_desc">Connect to your Hermes dashboard</string> <string name="auth_login_desc">Connect to your AITURK IDE</string>
<string name="auth_login_base_url_label">Server URL</string> <string name="auth_login_base_url_label">Server URL</string>
<string name="auth_login_token_label">Session Token</string> <string name="auth_login_token_label">Session Token</string>
<string name="auth_login_username_label">Username</string> <string name="auth_login_username_label">Username</string>
@@ -1406,13 +1410,13 @@
<string name="auth_login_error_password_required">Password is required</string> <string name="auth_login_error_password_required">Password is required</string>
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string> <string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string> <string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile.</string> <string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to AITURK Mobil.</string>
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string> <string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
<string name="auth_login_use_profile">Enter as %1$s</string> <string name="auth_login_use_profile">Enter as %1$s</string>
<!-- Landing Screen --> <!-- Landing Screen -->
<string name="landing_title">Hermes Mobile</string> <string name="landing_title">AITURK Mobil</string>
<string name="landing_subtitle">Control panel for Hermes Agent</string> <string name="landing_subtitle">Control panel for AITURK</string>
<string name="landing_action_auth_login">Auth Login</string> <string name="landing_action_auth_login">Auth Login</string>
<!-- Loading / Startup State (issue #1171) --> <!-- Loading / Startup State (issue #1171) -->
@@ -1439,7 +1443,7 @@
<string name="action_close">Close</string> <string name="action_close">Close</string>
<!-- Update notice banner (issue #890) --> <!-- Update notice banner (issue #890) -->
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string> <string name="update_notice_banner_text">AITURK Mobil %1$s is available</string>
<string name="update_notice_banner_action">Update</string> <string name="update_notice_banner_action">Update</string>
<string name="update_dialog_title">Update to %1$s</string> <string name="update_dialog_title">Update to %1$s</string>
<string name="update_dialog_changelog_header">What\'s New</string> <string name="update_dialog_changelog_header">What\'s New</string>
@@ -1537,7 +1541,7 @@
<string name="context_window_usage_unavailable">Context window usage unavailable</string> <string name="context_window_usage_unavailable">Context window usage unavailable</string>
<string name="gif_play">Play GIF</string> <string name="gif_play">Play GIF</string>
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string> <string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
<string name="logs_clipboard_label">Hermes Logs</string> <string name="logs_clipboard_label">AITURK Logs</string>
<string name="logs_search_placeholder">Search logs...</string> <string name="logs_search_placeholder">Search logs...</string>
<string name="mcp_no_match">No servers match &quot;%1$s&quot;</string> <string name="mcp_no_match">No servers match &quot;%1$s&quot;</string>
<string name="mcp_search_placeholder">Search MCP servers...</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_open_browser">Open in browser</string>
<string name="connection_setup_required">%1$s is required</string> <string name="connection_setup_required">%1$s is required</string>
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string> <string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
<string name="connection_setup_pending">Waiting for Hermes…</string> <string name="connection_setup_pending">Waiting for AITURK…</string>
<string name="connection_catalog_title">Install %1$s</string> <string name="connection_catalog_title">Install %1$s</string>
<string name="connection_catalog_kind_plugin">Plugin</string> <string name="connection_catalog_kind_plugin">Plugin</string>
<string name="connection_catalog_kind_skill">Skill</string> <string name="connection_catalog_kind_skill">Skill</string>
@@ -1834,7 +1838,7 @@
<string name="kanban_active_workers">Active workers</string> <string name="kanban_active_workers">Active workers</string>
<string name="kanban_no_active_workers">No active workers</string> <string name="kanban_no_active_workers">No active workers</string>
<string name="custom_headers_title">Custom headers</string> <string name="custom_headers_title">Custom headers</string>
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string> <string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. AITURK login is still required.</string>
<string name="custom_headers_name">Header name</string> <string name="custom_headers_name">Header name</string>
<string name="custom_headers_value">Header value</string> <string name="custom_headers_value">Header value</string>
<string name="custom_headers_add">Add header</string> <string name="custom_headers_add">Add header</string>
@@ -1858,4 +1862,48 @@
<string name="mcp_servers_catalog_empty_title">No catalog entries</string> <string name="mcp_servers_catalog_empty_title">No catalog entries</string>
<string name="mcp_servers_catalog_installed">Installed</string> <string name="mcp_servers_catalog_installed">Installed</string>
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string> <string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
<!-- AITURK IDE pairing (QR / code) -->
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
<string name="aiturk_pair_title">Connect to your computer</string>
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. It works from anywhere with internet; the same Wi-Fi is not required. The computer must be on with AITURK IDE running.</string>
<string name="aiturk_pair_scan_qr">Scan QR</string>
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
<string name="aiturk_pair_or">or</string>
<string name="aiturk_pair_code_label">8-character code</string>
<string name="aiturk_pair_code_placeholder">ABCD-EFGH</string>
<string name="aiturk_pair_connect">Connect</string>
<string name="aiturk_pair_cancel">Cancel</string>
<string name="aiturk_pair_back">Back</string>
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s. Continue only if you opened this link yourself from AITURK IDE.</string>
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed, so the computer could not be reached over Wi-Fi. Allow the permission in the phone\'s settings and try again.</string>
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
<string name="aiturk_pair_error_code_invalid">The code has 8 characters. Type it exactly as shown in AITURK IDE.</string>
<string name="aiturk_pair_error_code_not_found">Code not found or expired. Check it, or open Phone connection in AITURK IDE again for a new code.</string>
<string name="aiturk_pair_error_rate_limited">Too many attempts. Wait a minute and try again.</string>
<string name="aiturk_pair_error_gateway">The code service could not be reached. Check your internet connection, or scan the QR code instead.</string>
<string name="aiturk_pair_error_pc_unreachable">Your computer could not be reached. Make sure the phone and the computer are on the same Wi-Fi and Phone connection is open in AITURK IDE.</string>
<string name="aiturk_pair_error_wrong_code">Wrong code. Check the code in AITURK IDE and try again.</string>
<string name="aiturk_pair_error_pairing_closed">Pairing is closed, or the code expired or was already used. Open Phone connection in AITURK IDE again.</string>
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
<string name="aiturk_pair_error_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again.</string>
<string name="aiturk_pair_error_host_timeout">The computer did not answer. Check that AITURK IDE is running and try again.</string>
<string name="aiturk_pair_error_relay_busy">Too many connections. Try again in a moment.</string>
<string name="aiturk_pair_error_relay_unknown">This pairing is no longer valid. Pair again from AITURK IDE.</string>
<string name="aiturk_pair_error_tls_pin">The computer\'s identity could not be verified. Pair again from AITURK IDE.</string>
<string name="aiturk_pair_security_title">Connected</string>
<string name="aiturk_pair_security_message">Security code: %1$s\n\nIt should match the security code shown under the QR code in AITURK IDE. The connection is end-to-end encrypted.</string>
<string name="aiturk_pair_security_ok">OK</string>
<string name="aiturk_pair_error_no_internet">The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again.</string>
<string name="aiturk_pair_error_relay">Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil.</string>
<string name="aiturk_pair_error_code">Diagnostic code: %1$s</string>
<string name="aiturk_status_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off</string>
</resources> </resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Registers Hermes Mobile as a candidate for the Android digital-assistant Registers AITURK Mobil as a candidate for the Android digital-assistant
role (Settings > Apps > Default apps > Digital assistant app). role (Settings > Apps > Default apps > Digital assistant app).
Both sessionService and recognitionService are REQUIRED by the framework Both sessionService and recognitionService are REQUIRED by the framework
@@ -0,0 +1,148 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import java.net.InetAddress
import java.net.Socket
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
/**
* A local stand-in for the turkservis.online tunnel relay: a WebSocket endpoint
* (`/t/<relayId>`) that bridges binary frames to a TCP socket of [targetPort] —
* exactly what gateway + IDE relay client do together. It never looks inside
* the bytes, so the TLS handshake with the fake IDE really runs end to end.
*/
internal class FakeRelay(
private val targetPort: () -> Int,
) {
val server = MockWebServer()
val streams = AtomicInteger()
val bytesFromPhone = AtomicLong()
val paths = CopyOnWriteArrayList<String>()
val headerNames = CopyOnWriteArrayList<String>()
val textFrames = AtomicInteger()
/** When set, every new stream is accepted and immediately closed with this code. */
@Volatile
var closeWith: Int? = null
private val sockets = CopyOnWriteArrayList<Socket>()
private val webSockets = CopyOnWriteArrayList<WebSocket>()
/** Cuts every open stream abnormally, like a network change (Wi-Fi ⇄ mobile data). */
fun dropStreams() {
webSockets.forEach { runCatching { it.cancel() } }
sockets.forEach { runCatching { it.close() } }
}
val wsBase: String get() = "ws://$LOOPBACK:${server.port}/t/"
fun connector(): AiturkTunnelConnector = WebSocketTunnelConnector(wsBase, OkHttpClient())
fun start(): FakeRelay {
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
paths += request.path.orEmpty()
headerNames += request.headers.names().map { it.lowercase() }
return MockResponse().withWebSocketUpgrade(Bridge())
}
}
server.start(InetAddress.getByName(LOOPBACK), 0)
return this
}
fun stop() {
sockets.forEach { runCatching { it.close() } }
runCatching { server.shutdown() }
}
private inner class Bridge : WebSocketListener() {
@Volatile
private var tcp: Socket? = null
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
streams.incrementAndGet()
webSockets += webSocket
closeWith?.let {
webSocket.close(it, null)
return
}
val socket = Socket(LOOPBACK, targetPort())
sockets += socket
tcp = socket
thread(isDaemon = true, name = "fake-relay-pump") {
val buffer = ByteArray(BUFFER)
try {
val input = socket.getInputStream()
while (true) {
val read = input.read(buffer)
if (read < 0) break
webSocket.send(buffer.toByteString(0, read))
}
webSocket.close(NORMAL, null)
} catch (_: Exception) {
webSocket.close(NORMAL, null)
}
}
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) {
bytesFromPhone.addAndGet(bytes.size.toLong())
val socket = tcp ?: return
runCatching {
socket.getOutputStream().write(bytes.toByteArray())
socket.getOutputStream().flush()
}
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
textFrames.incrementAndGet()
}
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, null)
runCatching { tcp?.close() }
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
runCatching { tcp?.close() }
}
}
companion object {
const val LOOPBACK = "127.0.0.1"
const val RELAY_ID = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
const val OTHER_RELAY_ID = "7a6b5c4d-3e2f-4a1b-8c9d-0e1f2a3b4c5d"
private const val BUFFER = 16 * 1024
private const val NORMAL = 1000
}
}
@@ -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,293 @@
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",
relays: List<String> = emptyList(),
): String =
buildList {
v?.let { add("v=$it") }
us.forEach { add("u=$it") }
fingerprint?.let { add("fp=$it") }
code?.let { add("c=$it") }
relays.forEach { add("r=$it") }
}.joinToString("&", prefix = "aiturk://pair?")
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
// ── Contract v3: relay id ────────────────────────────────────────────
@Test
fun relayOnlyPayloadIsTheDefaultAndNeedsNoAddress() {
// Exactly what the IDE prints by default: aiturk://pair?v=1&fp=…&c=…&r=<relayId>
val result = AiturkPairing.parse("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
val offer = (result as AiturkPayloadResult.Ok).payload.offer
assertEquals(relayId, offer.relay)
assertTrue(offer.endpoints.isEmpty())
assertEquals(fp, offer.fingerprint)
assertEquals("https://$relayId.relay.aiturk.invalid/", AiturkRelay.baseUrl(relayId))
assertEquals("$relayId.relay.aiturk.invalid:443", AiturkRelay.hostPort(relayId))
assertEquals(relayId, AiturkRelay.relayIdOfHost("$relayId.relay.aiturk.invalid"))
}
@Test
fun relayAndLanAddressesCanComeTogether() {
val result = AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322"), relays = listOf(relayId)))
val offer = (result as AiturkPayloadResult.Ok).payload.offer
assertEquals(relayId, offer.relay)
assertEquals(2, offer.endpoints.size)
// Without `r` the offer simply has no relay.
assertNull((AiturkPairing.parse(link()) as AiturkPayloadResult.Ok).payload.offer.relay)
}
@Test
fun anythingButALowercaseUuid4RelayIdRejectsThePayload() {
val rejected =
listOf(
// 1. the cancelled v2 form (a URL)
"https%3A%2F%2Fturkservis.online%2Fv1%2Faiturk%2Fmobile-relay%2Fr%2F$relayId",
// 2. upper case
relayId.uppercase(Locale.ROOT),
// 3. not version 4
"3f2b8c1e-5d4a-1b7c-9e1f-0a1b2c3d4e5f",
// 4. wrong variant nibble
"3f2b8c1e-5d4a-4b7c-ce1f-0a1b2c3d4e5f",
// 5. a host name smuggled in
"$relayId.evil.example",
// 6. extra path
"$relayId%2F..%2Fhost",
// 7. surrounding whitespace
"%20$relayId",
// 8. braces / no dashes
"%7B$relayId%7D",
relayId.replace("-", ""),
// 9. too short / empty-ish
relayId.dropLast(1),
"x",
)
rejected.forEach { value ->
// The LAN address is valid: a bad `r` must still reject the whole payload.
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(value))), value)
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(us = emptyList(), relays = listOf(value))), value)
assertNull(AiturkRelay.parse(java.net.URLDecoder.decode(value, "UTF-8")), value)
}
// 10. `r` given twice
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(relayId, relayId))))
// An empty `r=` is a parameter without a value.
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&r"))
assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link() + "&r="))
}
@Test
fun payloadWithNeitherAddressNorRelayIsRejected() {
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, relays = listOf(relayId))))
// A relay does not excuse a bad LAN address.
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf("8.8.8.8:443"), relays = listOf(relayId))))
// The fingerprint stays mandatory: it is what makes the tunnel end-to-end.
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf("aiturk://pair?v=1&c=ABCDEFGH&r=$relayId"))
}
@Test
fun onlyExactVirtualHostsAreRelayHosts() {
assertTrue(AiturkRelay.isVirtualHost("$relayId.relay.aiturk.invalid"))
listOf(
null,
"",
"relay.aiturk.invalid",
".relay.aiturk.invalid",
"abc.relay.aiturk.invalid",
"${relayId.uppercase(Locale.ROOT)}.relay.aiturk.invalid",
"$relayId.relay.aiturk.invalid.evil.example",
"x.$relayId.relay.aiturk.invalid",
"turkservis.online",
"192.168.1.20",
).forEach { assertFalse(AiturkRelay.isVirtualHost(it), it.toString()) }
}
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,393 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.runBlocking
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.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
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.io.InterruptedIOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.ServerSocket
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateException
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
/** Contract v3 pairing order: tunnel first, LAN second, exactly one `claim`. */
class AiturkRelayPairingTest {
private lateinit var gateway: MockWebServer
private lateinit var relayIde: FakeIde
private lateinit var relay: FakeRelay
private val others = mutableListOf<FakeIde>()
@BeforeEach
fun setUp() {
gateway = MockWebServer()
gateway.start()
relayIde = FakeIde().start()
relay = FakeRelay { relayIde.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { gateway.shutdown() }
(others + relayIde).forEach { runCatching { it.stop() } }
}
private fun client(
helloTimeoutMs: Long = 2_500L,
relayHelloTimeoutMs: Long = 5_000L,
) = AiturkPairingClient(
gatewayBaseUrl = gateway.url("/"),
helloTimeoutMs = helloTimeoutMs,
relayHelloTimeoutMs = relayHelloTimeoutMs,
tunnel = { relay.connector() },
)
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
private fun <T> AiturkPairResult<T>.failure(): AiturkPairResult.Failure = this as AiturkPairResult.Failure
// ── Order ────────────────────────────────────────────────────────────
@Test
fun relayIsTriedFirstAndLanCandidatesAreNeverContacted() =
runBlocking {
// Same certificate on the LAN listener, as with the real IDE.
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "Galaxy S24") as AiturkPairResult.Success).value
assertEquals("https://${FakeRelay.RELAY_ID}.relay.aiturk.invalid/", device.baseUrl)
assertEquals("${FakeRelay.RELAY_ID}.relay.aiturk.invalid:443", device.hostPort)
assertNull(device.endpoint)
assertTrue(device.viaRelay)
assertEquals(relayIde.fingerprint, device.fingerprint)
assertEquals(FakeIde.TOKEN, device.token)
assertEquals(listOf("/aiturk/pair/hello", "/aiturk/pair/claim"), relayIde.paths.toList())
assertEquals("""{"code":"ABCDEFGH","device_name":"Galaxy S24"}""", relayIde.claimBodies.single())
assertTrue(lan.paths.isEmpty(), "LAN must not be contacted when the relay answered: ${lan.paths}")
}
@Test
fun relayOnlyOfferPairsWithoutAnyLanAddress() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
// lanAllowed = false: the local-network permission is irrelevant for the tunnel.
val result = client().pair(offer, "ABCDEFGH", "x", lanAllowed = false)
assertTrue(result is AiturkPairResult.Success)
assertEquals(1, relayIde.claimCount)
}
@Test
fun hostOfflineOnTheRelayFallsBackToTheLan() =
runBlocking {
relay.closeWith = 4503
val lan = FakeIde().start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), lan.fingerprint, FakeRelay.RELAY_ID)
val device = (client().pair(offer, "ABCDEFGH", "x") as AiturkPairResult.Success).value
assertEquals(lan.endpoint, device.endpoint)
assertEquals(lan.endpoint.baseUrl, device.baseUrl)
assertEquals(lan.endpoint.hostPort, device.hostPort)
assertFalse(device.viaRelay)
assertEquals(1, lan.claimCount)
assertEquals(0, relayIde.claimCount)
assertEquals(1, relay.streams.get())
}
@Test
fun hostOfflineThenUnreachableLanKeepsBothCodes() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_OFFLINE, failure.error)
assertEquals("relay:host_offline,lan:refused", failure.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun relayFailuresAreToldApart() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val cases =
listOf(
Triple(4503, AiturkPairError.HOST_OFFLINE, "relay:host_offline,lan:no_candidates"),
Triple(4404, AiturkPairError.RELAY_UNKNOWN, "relay:unknown,lan:no_candidates"),
Triple(4429, AiturkPairError.RELAY_BUSY, "relay:busy,lan:no_candidates"),
Triple(4504, AiturkPairError.HOST_TIMEOUT, "relay:timeout,lan:no_candidates"),
)
cases.forEach { (closeCode, error, code) ->
relay.closeWith = closeCode
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(error, failure.error, "close $closeCode")
assertEquals(code, failure.code, "close $closeCode")
}
relay.stop()
val offline = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.NO_INTERNET, offline.error)
assertEquals("relay:network,lan:no_candidates", offline.code)
assertEquals(0, relayIde.claimCount)
}
@Test
fun wrongCertificateInsideTheTunnelIsAPinFailure() =
runBlocking {
val offer = AiturkPairingOffer(emptyList(), FakeIde.fingerprintOf(FakeIde.selfSigned()), FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.TLS_PIN, failure.error)
assertEquals("tls:pin,lan:no_candidates", failure.code)
assertTrue(relayIde.paths.isEmpty(), "nothing may be sent to an unverified computer: ${relayIde.paths}")
}
@Test
fun silentComputerBehindAnOpenTunnelTimesOut() =
runBlocking {
relayIde.hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS)
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client(relayHelloTimeoutMs = 600L).pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.HOST_TIMEOUT, failure.error)
assertEquals("relay:timeout,lan:no_candidates", failure.code)
}
// ── One claim ────────────────────────────────────────────────────────
@Test
fun claimAnsweredOverTheRelayIsNeverRepeatedOnTheLan() =
runBlocking {
relayIde.claim = FakeIde.json(401, """{"error":"wrong_code"}""")
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.WRONG_CODE, failure.error)
assertEquals("claim:wrong_code", failure.code)
assertEquals(1, relayIde.claimCount)
assertTrue(lan.paths.isEmpty(), "a failed claim must not be retried on the LAN: ${lan.paths}")
}
@Test
fun claimCutMidFlightIsNotSentAgain() =
runBlocking {
// The computer receives the claim; the tunnel drops before its answer returns.
relayIde.claim = FakeIde.json(200, FakeIde.claimBody()).setHeadersDelay(5, TimeUnit.SECONDS)
val lan = FakeIde(relayIde.certificate).start().also { others += it }
val offer = AiturkPairingOffer(listOf(lan.endpoint), relayIde.fingerprint, FakeRelay.RELAY_ID)
Thread {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10)
while (relayIde.claimCount == 0 && System.nanoTime() < deadline) Thread.sleep(10)
relay.dropStreams()
}.apply { isDaemon = true }.start()
val result = client().pair(offer, "ABCDEFGH", "x")
assertTrue(result is AiturkPairResult.Failure)
assertTrue(result.failure().code.startsWith("claim:relay_"), result.failure().code)
assertEquals(1, relayIde.claimCount, "the single-use code must be sent exactly once")
assertEquals(0, lan.claimCount)
}
@Test
fun pairingClosedOverTheRelaySpendsNoClaim() =
runBlocking {
relayIde.hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1","pairing":false}""")
val offer = AiturkPairingOffer(emptyList(), relayIde.fingerprint, FakeRelay.RELAY_ID)
val failure = client().pair(offer, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PAIRING_CLOSED, failure.error)
assertEquals("relay:pairing_closed", failure.code)
assertEquals(0, relayIde.claimCount)
}
// ── LAN codes ────────────────────────────────────────────────────────
@Test
fun lanFailuresCarryTheirOwnCodes() =
runBlocking {
val refused = AiturkPairingOffer(listOf(AiturkEndpoint(FakeIde.LOOPBACK, closedPort())), relayIde.fingerprint)
val refusedFailure = client().pair(refused, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, refusedFailure.error)
assertEquals("lan:refused", refusedFailure.code)
val impostor = FakeIde().start().also { others += it }
val tls = AiturkPairingOffer(listOf(impostor.endpoint), relayIde.fingerprint)
val tlsFailure = client().pair(tls, "ABCDEFGH", "x").failure()
assertEquals(AiturkPairError.PC_UNREACHABLE, tlsFailure.error)
assertEquals("lan:tls", tlsFailure.code)
val silent =
FakeIde().apply { hello = FakeIde.json(200, "{}").setHeadersDelay(5, TimeUnit.SECONDS) }.start().also { others += it }
val timeout = AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint)
val timeoutFailure = client(helloTimeoutMs = 400L).pair(timeout, "ABCDEFGH", "x").failure()
assertEquals("lan:timeout", timeoutFailure.code)
val denied = client().pair(refused, "ABCDEFGH", "x", lanAllowed = false).failure()
assertEquals(AiturkPairError.LAN_PERMISSION, denied.error)
assertEquals("lan:permission", denied.code)
assertEquals(0, relay.streams.get(), "an offer without a relay id must never open a tunnel")
}
@Test
fun everyExceptionTypeHasItsOwnKind() {
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(InterruptedIOException("timeout")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.networkKind(ConnectException("connect failed: ETIMEDOUT (Connection timed out)")))
assertEquals(AiturkDiag.REFUSED, AiturkDiag.networkKind(ConnectException("connect failed: ECONNREFUSED (Connection refused)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(NoRouteToHostException("x")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: EHOSTUNREACH (No route to host)")))
assertEquals(AiturkDiag.UNREACHABLE, AiturkDiag.networkKind(ConnectException("connect failed: ENETUNREACH (Network is unreachable)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(ConnectException("connect failed: EPERM (Operation not permitted)")))
assertEquals(AiturkDiag.PERMISSION, AiturkDiag.networkKind(java.net.SocketException("socket failed: EACCES (Permission denied)")))
assertEquals(AiturkDiag.DNS, AiturkDiag.networkKind(UnknownHostException("host")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLHandshakeException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(SSLException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.networkKind(IOException("wrapped", CertificateException("pin"))))
assertEquals(AiturkDiag.IO, AiturkDiag.networkKind(IOException("unexpected end of stream")))
assertEquals(AiturkPairError.LAN_PERMISSION, AiturkPairingClient.lanError(AiturkDiag.PERMISSION))
listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED, AiturkDiag.UNREACHABLE, AiturkDiag.TLS, AiturkDiag.IO).forEach {
assertEquals(AiturkPairError.PC_UNREACHABLE, AiturkPairingClient.lanError(it), it)
}
assertEquals(AiturkDiag.TLS, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.TLS, AiturkDiag.REFUSED)))
assertEquals(AiturkDiag.REFUSED, AiturkPairingClient.mostTelling(listOf(AiturkDiag.TIMEOUT, AiturkDiag.REFUSED)))
}
@Test
fun tunnelFailuresHaveTheirOwnKindsAndTexts() {
assertEquals(AiturkTunnelException.HOST_OFFLINE, AiturkDiag.tunnelKind(SSLException("x", AiturkTunnelException("host_offline", 4503))))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLHandshakeException("x").apply { initCause(CertificateException("pin")) }))
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(SSLPeerUnverifiedException("x")))
assertEquals(AiturkDiag.TIMEOUT, AiturkDiag.tunnelKind(SocketTimeoutException("x")))
assertEquals(AiturkDiag.TLS, AiturkDiag.tunnelKind(SSLException("closed")))
assertEquals(AiturkDiag.NETWORK, AiturkDiag.tunnelKind(IOException("x")))
assertEquals("tls:pin", AiturkDiag.relayCode(AiturkDiag.PIN))
assertEquals("relay:host_offline", AiturkDiag.relayCode(AiturkTunnelException.HOST_OFFLINE))
assertEquals(AiturkPairError.HOST_OFFLINE, AiturkPairingClient.relayError("host_offline"))
assertEquals(AiturkPairError.RELAY_UNKNOWN, AiturkPairingClient.relayError("unknown"))
assertEquals(AiturkPairError.NO_INTERNET, AiturkPairingClient.relayError("network"))
assertEquals(AiturkPairError.RELAY_BUSY, AiturkPairingClient.relayError("busy"))
assertEquals(AiturkPairError.HOST_TIMEOUT, AiturkPairingClient.relayError("timeout"))
assertEquals(AiturkPairError.TLS_PIN, AiturkPairingClient.relayError("pin"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("overflow"))
assertEquals(AiturkPairError.RELAY_ERROR, AiturkPairingClient.relayError("http_500"))
}
@Test
fun diagnosticCodeNeverCarriesATokenACodeOrAnAddress() =
runBlocking {
relay.closeWith = 4503
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead), relayIde.fingerprint, FakeRelay.RELAY_ID)
val code = client().pair(offer, "ABCDEFGH", "x").failure().code
listOf("ABCDEFGH", FakeRelay.RELAY_ID, FakeIde.LOOPBACK, dead.port.toString(), relayIde.fingerprint, "amd_", "https", ".invalid").forEach {
assertFalse(it in code, "'$it' leaked into '$code'")
}
assertEquals(code, AiturkDiag.sanitize(code))
// The exception message (which on Android names the address) is never copied.
val noisy = ConnectException("failed to connect to /192.168.1.20 (port 47321): connect failed: ECONNREFUSED")
assertEquals("refused", AiturkDiag.networkKind(noisy))
// The UI drops anything that is not built from the fixed vocabulary.
listOf(
"relay:" + FakeIde.TOKEN,
"lan:192.168.1.20:47321",
"claim:ABCDEFGH",
"relay:https://turkservis.online/x",
"relay:host_offline lan:timeout",
"x".repeat(200) + ":y",
).forEach { assertEquals("", AiturkDiag.sanitize(it), it) }
assertEquals("relay:host_offline,lan:timeout", AiturkDiag.sanitize("relay:host_offline,lan:timeout"))
assertEquals("tls:pin", AiturkDiag.sanitize("tls:pin"))
assertEquals("resolve:http_500", AiturkDiag.sanitize("resolve:http_500"))
}
// ── resolve ──────────────────────────────────────────────────────────
@Test
fun resolveAcceptsARelayIdWithOrWithoutLanAddresses() =
runBlocking {
val fp = "A".repeat(43)
gateway.enqueue(FakeIde.json(200, """{"urls":[],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}","expiresIn":280}"""))
gateway.enqueue(FakeIde.json(200, """{"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"${FakeRelay.RELAY_ID}"}"""))
gateway.enqueue(FakeIde.json(200, """{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":null}"""))
val relayOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(emptyList(), fp, FakeRelay.RELAY_ID), relayOnly)
assertEquals(relayOnly, (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value)
val both = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertEquals(AiturkPairingOffer(listOf(AiturkEndpoint("192.168.1.20", 47321)), fp, FakeRelay.RELAY_ID), both)
val lanOnly = (client().resolve("ABCDEFGH") as AiturkPairResult.Success).value
assertNull(lanOnly.relay)
}
@Test
fun resolveRejectsAnythingThatIsNotARelayId() =
runBlocking {
val fp = "A".repeat(43)
val id = FakeRelay.RELAY_ID
listOf(
"""{"urls":[],"fp":"$fp","relay":"https://turkservis.online/v1/aiturk/mobile-relay/r/$id"}""",
"""{"urls":[],"fp":"$fp","relay":"${id.uppercase()}"}""",
"""{"urls":[],"fp":"$fp","relay":"$id.evil.example"}""",
"""{"urls":[],"fp":"$fp","relay":"$id/"}""",
"""{"urls":[],"fp":"$fp","relay":""}""",
"""{"urls":[],"fp":"$fp","relay":42}""",
"""{"urls":[],"fp":"$fp","relay":["$id"]}""",
// A wrong relay is never silently dropped in favour of the LAN addresses.
"""{"urls":["https://192.168.1.20:47321"],"fp":"$fp","relay":"evil.example"}""",
"""{"urls":[],"fp":"$fp"}""",
"""{"urls":"https://192.168.1.20:47321","fp":"$fp","relay":"$id"}""",
).forEach { body ->
gateway.enqueue(FakeIde.json(200, body))
val failure = client().resolve("ABCDEFGH").failure()
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, failure.error, body)
assertEquals("resolve:bad_response", failure.code, body)
}
}
@Test
fun resolveFailuresHaveCodes() =
runBlocking {
gateway.enqueue(MockResponse().setResponseCode(404))
gateway.enqueue(MockResponse().setResponseCode(429))
gateway.enqueue(MockResponse().setResponseCode(500))
assertEquals("resolve:not_found", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:rate_limited", client().resolve("ABCDEFGH").failure().code)
assertEquals("resolve:http_500", client().resolve("ABCDEFGH").failure().code)
gateway.shutdown()
assertEquals("resolve:network", client().resolve("ABCDEFGH").failure().code)
}
}
@@ -0,0 +1,109 @@
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())
/** Answers for paths other than hello/claim (null → 404). */
@Volatile
var extra: (RecordedRequest) -> MockResponse? = { null }
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 -> {
extra(request) ?: 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,130 @@
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 tunnelPairedConnectionNeverAsksTheDashboardForAToken() =
runBlocking {
// Contract v3: a relay pairing is the virtual host, pinned on port 443.
val relayId = FakeRelay.RELAY_ID
AiturkLanTrust.pin(AiturkRelay.hostPort(relayId), "A".repeat(43))
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(relayId)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertNull(DashboardSessionTokenRefresher.refresh())
assertEquals(0, calls.get())
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun unpairedVirtualHostIsNotTreatedAsPaired() =
runBlocking {
every { AuthManager.baseUrl() } returns AiturkRelay.baseUrl(FakeRelay.OTHER_RELAY_ID)
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertEquals(1, calls.get())
}
@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,417 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.Dns
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
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.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import kotlin.random.Random
/**
* Contract v3: REST and WebSocket traffic to the virtual relay host really
* travels through the tunnel, with TLS terminated by the (fake) IDE and pinned
* to its certificate — and nothing changes for any other host.
*/
class AiturkTunnelTest {
private lateinit var ide: FakeIde
private lateinit var relay: FakeRelay
private val virtualBase = "https://${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}"
@BeforeEach
fun setUp() {
AiturkLanTrust.resetForTest(MemoryPins())
ide = FakeIde().start()
relay = FakeRelay { ide.server.port }.start()
}
@AfterEach
fun tearDown() {
relay.stop()
runCatching { ide.stop() }
AiturkLanTrust.resetForTest(MemoryPins())
}
private fun pinnedClient(fingerprint: String = ide.fingerprint): OkHttpClient =
AiturkLanTrust.pinnedClient(
OkHttpClient
.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build(),
fingerprint,
) { relay.connector() }
// ── REST ─────────────────────────────────────────────────────────────
@Test
fun restRequestReachesTheIdeThroughTheTunnelWithPinnedTls() {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute().use { response ->
assertEquals(200, response.code)
assertTrue(response.body.string().contains("aiturk-ide"))
assertNotNull(response.handshake, "TLS must be established inside the tunnel")
}
// The IDE saw the request with NO path prefix; the relay saw only its own stream path.
assertEquals(listOf("/aiturk/pair/hello"), ide.paths.toList())
assertEquals(listOf("/t/${FakeRelay.RELAY_ID}"), relay.paths.toList())
assertEquals(0, relay.textFrames.get(), "the stream is binary only")
assertFalse("origin" in relay.headerNames, "headers seen by the relay: ${relay.headerNames}")
assertFalse("cookie" in relay.headerNames)
assertFalse("authorization" in relay.headerNames)
}
@Test
fun largeBodiesSurviveTheTunnelInBothDirections() {
val payload = Random(7).nextBytes(1_500_000)
ide.extra = { request ->
if (request.path == "/api/echo") {
MockResponse().setResponseCode(200).setBody(okio.Buffer().write(request.body.readByteArray()))
} else {
null
}
}
val request =
Request
.Builder()
.url("$virtualBase/api/echo")
.post(payload.toRequestBody("application/octet-stream".toMediaType()))
.build()
val echoed = pinnedClient().newCall(request).execute().use { it.body.bytes() }
assertArrayEquals(payload, echoed)
}
@Test
fun tokenTravelsOnlyInsideTls() {
val token = "amd_" + "s".repeat(43)
val seen = AtomicReference<String>()
ide.extra = { request ->
seen.set(request.getHeader("Authorization"))
FakeIde.json(200, """{"ok":true}""")
}
val sniffed = LinkedBlockingQueue<ByteArray>()
val sniffing =
AiturkTunnelConnector { relayId, timeoutMs ->
val inner = relay.connector().open(relayId, timeoutMs)
object : AiturkTunnelChannel by inner {
override val output =
object : java.io.OutputStream() {
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
override fun write(
b: ByteArray,
off: Int,
len: Int,
) {
sniffed += b.copyOfRange(off, off + len)
inner.output.write(b, off, len)
}
}
}
}
val client = AiturkLanTrust.pinnedClient(OkHttpClient(), ide.fingerprint) { sniffing }
client
.newCall(
Request
.Builder()
.url("$virtualBase/api/status?marker=PLAINTEXT-MARKER")
.header("Authorization", "Bearer $token")
.build(),
).execute()
.close()
assertEquals("Bearer $token", seen.get())
val onTheWire = String(sniffed.flatMap { it.asIterable() }.toByteArray(), Charsets.ISO_8859_1)
assertTrue(onTheWire.isNotEmpty())
assertFalse(token in onTheWire, "the device token must never be readable by the relay")
assertFalse("PLAINTEXT-MARKER" in onTheWire, "paths and queries must never be readable by the relay")
assertFalse("/api/status" in onTheWire)
}
// ── WebSocket ────────────────────────────────────────────────────────
@Test
fun webSocketWorksThroughTheTunnel() {
ide.extra = { request ->
if (request.path?.startsWith("/api/ws") == true) {
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
webSocket.send("echo:$text")
}
},
)
} else {
null
}
}
val opened = CountDownLatch(1)
val messages = LinkedBlockingQueue<String>()
val failure = AtomicReference<Throwable>()
val socket =
pinnedClient().newWebSocket(
Request.Builder().url("$virtualBase/api/ws?token=t").build(),
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
messages += text
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
failure.set(t)
opened.countDown()
}
},
)
try {
assertTrue(opened.await(10, TimeUnit.SECONDS), "WebSocket did not open")
assertNull(failure.get(), "WebSocket failed: ${failure.get()}")
socket.send("hello")
assertEquals("echo:hello", messages.poll(10, TimeUnit.SECONDS))
// The app's WebSocket URL is seen by the IDE as written (no prefix).
assertTrue(ide.paths.any { it == "/api/ws?token=t" }, "paths: ${ide.paths}")
} finally {
socket.cancel()
}
}
// ── Trust ────────────────────────────────────────────────────────────
@Test
fun certificateThatDoesNotMatchThePairingFingerprintIsRejected() {
val other = FakeIde.fingerprintOf(FakeIde.selfSigned())
val error =
assertThrows(IOException::class.java) {
pinnedClient(other).newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkDiag.PIN, AiturkDiag.tunnelKind(error))
assertTrue(ide.paths.isEmpty(), "no request may be sent before the pin matched: ${ide.paths}")
}
@Test
fun sharedClientHookUsesTheStoredPinForTheVirtualHost() {
// What OkHttpProvider.base does: AiturkLanTrust.applyTo(builder).
val previous = AiturkTunnel.connector
AiturkTunnel.connector = relay.connector()
try {
val client = AiturkLanTrust.applyTo(OkHttpClient.Builder()).build()
val request = Request.Builder().url("$virtualBase/aiturk/pair/hello").build()
// Not paired yet: platform trust rejects the IDE's self-signed certificate.
assertThrows(IOException::class.java) { client.newCall(request).execute() }
assertTrue(ide.paths.isEmpty())
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), ide.fingerprint)
assertTrue(AiturkLanTrust.isPairedUrl("$virtualBase/"))
client.newCall(request).execute().use { assertEquals(200, it.code) }
// A pin for one relay id says nothing about another.
val otherHost = AiturkRelay.virtualHost(FakeRelay.OTHER_RELAY_ID)
assertFalse(AiturkLanTrust.isPairedUrl("https://$otherHost/"))
assertThrows(IOException::class.java) {
client.newCall(Request.Builder().url("https://$otherHost/aiturk/pair/hello").build()).execute()
}
} finally {
AiturkTunnel.connector = previous
}
}
@Test
fun onlyAnIdeAddressCanBePinned() {
val fp = "A".repeat(43)
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("turkservis.online:443", fp) }
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("evil.relay.aiturk.invalid:443", fp) }
assertThrows(IllegalArgumentException::class.java) {
AiturkLanTrust.pin("${AiturkRelay.virtualHost(FakeRelay.RELAY_ID)}:8443", fp)
}
AiturkLanTrust.pin(AiturkRelay.hostPort(FakeRelay.RELAY_ID), fp)
AiturkLanTrust.pin("192.168.1.20:47321", fp)
}
// ── Everything else is untouched ─────────────────────────────────────
@Test
fun otherHostsKeepDefaultDnsSocketsAndProxy() {
val opens = AtomicInteger()
val counting =
AiturkTunnelConnector { relayId, timeoutMs ->
opens.incrementAndGet()
relay.connector().open(relayId, timeoutMs)
}
val plain = MockWebServer()
plain.enqueue(MockResponse().setBody("plain"))
plain.start()
try {
val client = AiturkTunnel.applyTo(OkHttpClient.Builder()) { counting }.build()
val body = client.newCall(Request.Builder().url(plain.url("/x")).build()).execute().use { it.body.string() }
assertEquals("plain", body)
assertEquals(0, opens.get(), "a normal host must never open a tunnel")
assertEquals(0, relay.streams.get())
// DNS: real names go to the system resolver, unchanged.
val recorded = mutableListOf<String>()
val delegate =
Dns { host ->
recorded += host
Dns.SYSTEM.lookup(host)
}
assertEquals(Dns.SYSTEM.lookup("localhost"), AiturkTunnelDns(delegate).lookup("localhost"))
assertEquals(listOf("localhost"), recorded)
// Socket: an ordinary TCP connection made by java.net.Socket itself.
val socket = AiturkTunnelSocketFactory { counting }.createSocket()
assertTrue(socket is Socket)
socket.use {
it.connect(InetSocketAddress(plain.hostName, plain.port), 2_000)
assertTrue(it.isConnected)
assertNull((it as AiturkTunnelSocket).channel)
assertEquals(plain.port, it.port)
}
assertEquals(0, opens.get())
} finally {
plain.shutdown()
}
}
@Test
fun virtualHostResolvesToAPlaceholderWithoutTouchingRealDns() {
val host = AiturkRelay.virtualHost(FakeRelay.RELAY_ID)
val delegate = Dns { throw AssertionError("system DNS must not be asked about $it") }
val address = AiturkTunnelDns(delegate).lookup(host).single()
assertEquals(host, address.hostName)
assertEquals("192.0.2.1", address.hostAddress)
assertEquals(listOf(Proxy.NO_PROXY), AiturkTunnelProxySelector().select(URI("https://$host/")))
// Look-alikes are ordinary names.
listOf("relay.aiturk.invalid", "x.relay.aiturk.invalid", "${FakeRelay.RELAY_ID}.relay.aiturk.invalid.evil.example").forEach {
assertFalse(AiturkRelay.isVirtualHost(it), it)
}
}
// ── Close codes ──────────────────────────────────────────────────────
@Test
fun relayCloseCodesBecomeNamedFailures() {
val expected =
mapOf(
4503 to AiturkTunnelException.HOST_OFFLINE,
4404 to AiturkTunnelException.UNKNOWN,
4429 to AiturkTunnelException.BUSY,
4504 to AiturkTunnelException.TIMEOUT,
4408 to AiturkTunnelException.OVERFLOW,
)
expected.forEach { (code, kind) ->
relay.closeWith = code
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(kind, AiturkDiag.tunnelKind(error), "close code $code")
assertEquals(code, AiturkTunnelException.find(error)?.closeCode)
}
assertTrue(ide.paths.isEmpty())
assertNull(AiturkTunnelException.kindForCloseCode(1000))
}
@Test
fun unreachableRelayIsANetworkFailure() {
relay.stop()
val error =
assertThrows(IOException::class.java) {
pinnedClient().newCall(Request.Builder().url("$virtualBase/aiturk/pair/hello").build()).execute()
}
assertEquals(AiturkTunnelException.NETWORK, AiturkDiag.tunnelKind(error))
}
@Test
fun hostOfflineIsRememberedForTheChatBanner() {
AiturkRelayStatus.onSocketEnded(IOException("x", AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503)))
assertTrue(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(IOException("timeout"))
assertFalse(AiturkRelayStatus.hostOffline.value)
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
AiturkRelayStatus.onSocketOpened()
assertFalse(AiturkRelayStatus.hostOffline.value)
}
// ── No replay ────────────────────────────────────────────────────────
@Test
fun postCutMidFlightIsNotSentAgain() {
val posts = AtomicInteger()
val arrived = CountDownLatch(1)
ide.extra = { request ->
if (request.method == "POST") posts.incrementAndGet()
arrived.countDown()
// The computer got the request; its answer never makes it back.
FakeIde.json(200, """{"ok":true}""").setHeadersDelay(5, TimeUnit.SECONDS)
}
Thread {
if (arrived.await(10, TimeUnit.SECONDS)) relay.dropStreams()
}.apply { isDaemon = true }.start()
// Same setting the pairing `claim` uses.
val client = pinnedClient().newBuilder().retryOnConnectionFailure(false).build()
val request =
Request
.Builder()
.url("$virtualBase/api/once")
.post("""{"n":1}""".toRequestBody("application/json".toMediaType()))
.build()
assertThrows(IOException::class.java) { client.newCall(request).execute().use { it.body.string() } }
assertEquals(1, posts.get(), "a POST that may have reached the computer must not be replayed")
assertEquals(1, relay.streams.get(), "no second tunnel stream may be opened for a retry")
}
}
@@ -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,15 +71,21 @@ class GatewayContractFramesTest {
@After @After
fun tearDown() { fun tearDown() {
try {
HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true) HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking { runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
} }
runCatching { mockWebServer.shutdown() } } finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll() unmockkAll()
} }
}
}
/** Connect, run [trigger], and return the first frame the server sees for [method]. */ /** Connect, run [trigger], and return the first frame the server sees for [method]. */
private fun captureFrame( private fun captureFrame(
@@ -91,7 +97,7 @@ class GatewayContractFramesTest {
val captured = CountDownLatch(1) val captured = CountDownLatch(1)
val frame = AtomicReference<JsonObject?>(null) val frame = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -105,7 +105,7 @@ class GatewayContractTest {
// ChatViewModel, GroupChatViewModel // ChatViewModel, GroupChatViewModel
"session.create" to setOf("profile", "title", "source", "hidden"), "session.create" to setOf("profile", "title", "source", "hidden"),
// ChatViewModel.resumeSession, NotificationReplyReceiver // ChatViewModel.resumeSession, NotificationReplyReceiver
"session.resume" to setOf("session_id", "omit_messages"), "session.resume" to setOf("session_id", "source", "omit_messages"),
// ChatViewModel, GroupChatViewModel // ChatViewModel, GroupChatViewModel
"session.interrupt" to setOf("session_id"), "session.interrupt" to setOf("session_id"),
) )
@@ -76,6 +76,7 @@ class HermesWsClientSessionControlTest {
@After @After
fun tearDown() { fun tearDown() {
try {
AuthManager.resetAuthStateForTest() AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.releaseBackgroundConnectionLease()
@@ -83,9 +84,14 @@ class HermesWsClientSessionControlTest {
runBlocking { runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
} }
runCatching { mockWebServer.shutdown() } } finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll() unmockkAll()
} }
}
}
private fun connectClient() { private fun connectClient() {
HermesWsClient.connect() HermesWsClient.connect()
@@ -101,7 +107,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -163,7 +169,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -224,7 +230,7 @@ class HermesWsClientSessionControlTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -104,19 +104,18 @@ class HermesWsClientTest {
@After @After
fun tearDown() { fun tearDown() {
try {
HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true) HermesWsClient.disconnect(clearPendingMessages = true)
// Wait a bit to allow internal OkHttp coroutines to clean up before shutting down MockWebServer } finally {
// Increased from 100ms for OkHttp 5.x — needs more time for the WS close handshake
Thread.sleep(500)
try { try {
mockWebServer.shutdown() mockWebServer.shutdown()
} catch (e: Throwable) { } finally {
e.printStackTrace()
}
unmockkAll() unmockkAll()
} }
}
}
@Test @Test
fun testConnectAndSend() { fun testConnectAndSend() {
@@ -126,7 +125,7 @@ class HermesWsClientTest {
var receivedMessage: String? = null var receivedMessage: String? = null
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -171,7 +170,7 @@ class HermesWsClientTest {
var capabilityFrame: String? = null var capabilityFrame: String? = null
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -221,7 +220,7 @@ class HermesWsClientTest {
val capabilityResponseCount = AtomicInteger(0) val capabilityResponseCount = AtomicInteger(0)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -301,7 +300,7 @@ class HermesWsClientTest {
fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() = fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() =
runBlocking { runBlocking {
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -759,7 +758,7 @@ class HermesWsClientTest {
HermesWsClient.connectedForTest.set(true) HermesWsClient.connectedForTest.set(true)
val oldSocket = mockk<WebSocket>(relaxed = true) val oldSocket = mockk<WebSocket>(relaxed = true)
HermesWsClient.webSocketForTest = oldSocket HermesWsClient.webSocketForTest = oldSocket
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect() HermesWsClient.connect()
@@ -789,7 +788,7 @@ class HermesWsClientTest {
assertTrue(refreshStarted.await(5, TimeUnit.SECONDS)) assertTrue(refreshStarted.await(5, TimeUnit.SECONDS))
HermesWsClient.disconnect(clearPendingMessages = true) HermesWsClient.disconnect(clearPendingMessages = true)
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get() val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
val replacementConnect = thread { HermesWsClient.connect() } val replacementConnect = thread { HermesWsClient.connect() }
val generationDeadline = System.currentTimeMillis() + 5_000 val generationDeadline = System.currentTimeMillis() + 5_000
@@ -821,7 +820,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -862,7 +861,7 @@ class HermesWsClientTest {
@Test @Test
fun testBackgroundWithoutPendingWorkDisconnects() { fun testBackgroundWithoutPendingWorkDisconnects() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect() HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -874,7 +873,7 @@ class HermesWsClientTest {
@Test @Test
fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() { fun testExternalActivityLeaseKeepsIdleBackgroundSocketUntilReleased() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect() HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -891,7 +890,7 @@ class HermesWsClientTest {
@Test @Test
fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() { fun testBackgroundConnectionLeaseKeepsIdleBackgroundSocketUntilReleased() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect() HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
@@ -908,7 +907,7 @@ class HermesWsClientTest {
@Test @Test
fun testBackgroundWithPendingReplyStaysConnected() { fun testBackgroundWithPendingReplyStaysConnected() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {})) mockWebServer.enqueue(MockResponse().withClosingWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect() HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } } runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
HermesWsClient.sendMessage("session-1", "hello") HermesWsClient.sendMessage("session-1", "hello")
@@ -923,7 +922,7 @@ class HermesWsClientTest {
lateinit var serverSocket: WebSocket lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1) val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -958,7 +957,7 @@ class HermesWsClientTest {
fun testBackgroundQueuedSendDisconnectsAfterFlush() { fun testBackgroundQueuedSendDisconnectsAfterFlush() {
val messageLatch = CountDownLatch(1) val messageLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onMessage( override fun onMessage(
webSocket: WebSocket, webSocket: WebSocket,
@@ -987,7 +986,7 @@ class HermesWsClientTest {
lateinit var serverSocket: WebSocket lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1) val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1033,7 +1032,7 @@ class HermesWsClientTest {
val closedLatch = CountDownLatch(1) val closedLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1073,7 +1072,7 @@ class HermesWsClientTest {
var receivedMessage: String? = null var receivedMessage: String? = null
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1128,7 +1127,7 @@ class HermesWsClientTest {
val connect2Latch = CountDownLatch(1) val connect2Latch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1142,7 +1141,7 @@ class HermesWsClientTest {
) )
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1206,7 +1205,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1235,7 +1234,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1265,7 +1264,7 @@ class HermesWsClientTest {
fun testDoubleConnect_ignoresSecondCallWhenConnected() { fun testDoubleConnect_ignoresSecondCallWhenConnected() {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1291,7 +1290,7 @@ class HermesWsClientTest {
fun testStatusTransitionOnConnect() { fun testStatusTransitionOnConnect() {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1328,7 +1327,7 @@ class HermesWsClientTest {
val connectLatch = CountDownLatch(1) val connectLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1342,7 +1341,7 @@ class HermesWsClientTest {
// Enqueue a second response for reconnect attempt // Enqueue a second response for reconnect attempt
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1400,7 +1399,7 @@ class HermesWsClientTest {
val connectLatch = CountDownLatch(1) val connectLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
ws: WebSocket, ws: WebSocket,
@@ -1537,7 +1536,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1589,7 +1588,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1664,7 +1663,7 @@ class HermesWsClientTest {
var serverWebSocket: WebSocket? = null var serverWebSocket: WebSocket? = null
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1719,7 +1718,7 @@ class HermesWsClientTest {
} }
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1780,7 +1779,7 @@ class HermesWsClientTest {
val socket = mockk<WebSocket>(relaxed = true) val socket = mockk<WebSocket>(relaxed = true)
HermesWsClient.intentionalCloseForTest.set(false) HermesWsClient.intentionalCloseForTest.set(false)
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get()) 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. // though this test never performed a real OkHttp connect.
HermesWsClient.webSocketForTest = socket HermesWsClient.webSocketForTest = socket
mockkObject(EventParser) mockkObject(EventParser)
@@ -1826,7 +1825,7 @@ class HermesWsClientTest {
} }
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1888,7 +1887,7 @@ class HermesWsClientTest {
var pingReceived = false var pingReceived = false
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -1971,7 +1970,7 @@ class HermesWsClientTest {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -103,6 +103,7 @@ class HermesWsClientTypedCallTest {
@After @After
fun tearDown() { fun tearDown() {
try {
AuthManager.resetAuthStateForTest() AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.releaseBackgroundConnectionLease()
@@ -110,9 +111,14 @@ class HermesWsClientTypedCallTest {
runBlocking { runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
} }
runCatching { mockWebServer.shutdown() } } finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll() unmockkAll()
} }
}
}
private fun connectClient() { private fun connectClient() {
HermesWsClient.connect() HermesWsClient.connect()
@@ -128,7 +134,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -190,7 +196,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1) val opened = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -233,7 +239,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1) val opened = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -281,7 +287,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -332,7 +338,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -382,7 +388,7 @@ class HermesWsClientTypedCallTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -429,7 +435,7 @@ class HermesWsClientTypedCallTest {
val opened = CountDownLatch(1) val opened = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -475,7 +481,7 @@ class HermesWsClientTypedCallTest {
val requestReceived = CountDownLatch(1) val requestReceived = CountDownLatch(1)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -77,6 +77,7 @@ class HermesWsClientTypedSendTest {
@After @After
fun tearDown() { fun tearDown() {
try {
AuthManager.resetAuthStateForTest() AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease() HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease() HermesWsClient.releaseBackgroundConnectionLease()
@@ -84,9 +85,14 @@ class HermesWsClientTypedSendTest {
runBlocking { runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } } withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
} }
runCatching { mockWebServer.shutdown() } } finally {
try {
mockWebServer.shutdown()
} finally {
unmockkAll() unmockkAll()
} }
}
}
private fun connectClient() { private fun connectClient() {
HermesWsClient.connect() HermesWsClient.connect()
@@ -102,7 +108,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -163,7 +169,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -216,7 +222,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -274,7 +280,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -325,7 +331,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -382,7 +388,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
@@ -437,7 +443,7 @@ class HermesWsClientTypedSendTest {
val frameRef = AtomicReference<JsonObject?>(null) val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue( mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade( MockResponse().withClosingWebSocketUpgrade(
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, 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) 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 @Test
fun resumeWithExplicitFalseOmitMessagesIsEncoded() { fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false) val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
@@ -330,7 +330,13 @@ class NotificationReplyReceiverTest {
verify { verify {
HermesWsClient.request( HermesWsClient.request(
WsMethods.SESSION_RESUME, 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(), any(),
) )
HermesWsClient.request( HermesWsClient.request(

Some files were not shown because too many files have changed in this diff Show More