Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc01c225f7 | ||
|
|
1f692044d2 | ||
|
|
8161f3bf09 | ||
|
|
1d11afefec | ||
|
|
5006d1c7a8 | ||
|
|
004d4622c4 | ||
|
|
824f0a07e2 | ||
|
|
71dd92a4c2 | ||
|
|
ebdfeddd9a | ||
|
|
017864a0db | ||
|
|
6472d876b3 | ||
|
|
9e4e979e7a | ||
|
|
cb7e6acb59 | ||
|
|
827c74b3c3 | ||
|
|
c2d828b90a | ||
|
|
cbe716300c | ||
|
|
9afac6f508 | ||
|
|
6f4309c746 | ||
|
|
c2b08ff73e | ||
|
|
8e48a0fa1e | ||
|
|
34758975b7 | ||
|
|
e3d0a97921 | ||
|
|
5f548f5a17 | ||
|
|
944390bec1 | ||
|
|
62fcf07e1b | ||
|
|
e7927f6631 | ||
|
|
1cef9e7ebb | ||
|
|
82cf8df238 | ||
|
|
ba029e83dc | ||
|
|
728daa9acd | ||
|
|
ed915d0816 | ||
|
|
e4004decb4 | ||
|
|
7386c3035b | ||
|
|
15a0abc7a7 | ||
|
|
ed26f54855 |
@@ -0,0 +1,36 @@
|
||||
name: Android acceptance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.twinpay.one
|
||||
- name: Set up Java 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- name: Set up Android SDK
|
||||
shell: powershell
|
||||
run: ./scripts/setup-android-ci.ps1
|
||||
- name: Test and build exact commit
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ((git rev-parse HEAD).Trim() -ne $env:GITHUB_SHA) { throw 'Checkout SHA mismatch' }
|
||||
Get-CimInstance Win32_OperatingSystem | Select-Object TotalVisibleMemorySize, FreePhysicalMemory
|
||||
# A fresh runner compiles both variants from scratch. Keep their compiler
|
||||
# heaps separate in time; the previous shared 2 GiB heap exhausted itself.
|
||||
$buildArgs = @('--no-daemon', '--console=plain', '--max-workers=1', '-Pkotlin.daemon.jvmargs=-Xmx4096m')
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
.\gradlew.bat compileReleaseKotlin @buildArgs
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,26 @@
|
||||
AITURK Mobil
|
||||
Copyright 2026 TurkServis
|
||||
|
||||
AITURK Mobil, Hy4ri/hermes-mobile (Apache-2.0) projesinden uyarlanmıştır.
|
||||
AITURK Mobil is adapted from Hy4ri/hermes-mobile (Apache-2.0).
|
||||
|
||||
This product includes software developed by M57 (Hy4ri):
|
||||
Hermes Mobile - https://github.com/Hy4ri/hermes-mobile
|
||||
Copyright (c) 2026 M57 (Hy4ri)
|
||||
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
|
||||
Summary of changes made by TurkServis (Apache-2.0, section 4(b)):
|
||||
- Display name changed to "AITURK Mobil"; user-visible "Hermes" / "Hermes
|
||||
Mobile" texts replaced with AITURK branding in all locales.
|
||||
- applicationId changed to online.turkservis.aiturk.mobil (the Kotlin
|
||||
namespace and source packages remain com.m57.hermescontrol).
|
||||
- Turkish translation added (app/src/main/res/values-tr) and Turkish added
|
||||
to the in-app language picker.
|
||||
- Launcher icon replaced with the AITURK mark.
|
||||
- The in-app GitHub self-updater (Hy4ri/hermes-mobile releases) is switched
|
||||
off; builds are distributed from https://turkservis.online/indir.
|
||||
- About screen shows this attribution; the support card names the original
|
||||
developer.
|
||||
|
||||
AITURK and TurkServis names and the AITURK mark are not covered by the
|
||||
Apache-2.0 license grant of the original project.
|
||||
+10
-1
@@ -18,9 +18,10 @@ android {
|
||||
compileSdk = 37
|
||||
buildToolsVersion = "37.0.0"
|
||||
defaultConfig {
|
||||
applicationId = "com.m57.hermescontrol"
|
||||
applicationId = "online.turkservis.aiturk.mobil"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||
// Falls back to defaults for local development.
|
||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||
@@ -201,6 +202,9 @@ dependencies {
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
|
||||
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
|
||||
implementation(libs.zxing.android.embedded)
|
||||
|
||||
// Encrypted storage
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.datastore)
|
||||
@@ -218,6 +222,7 @@ dependencies {
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.okhttp.tls)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.turbine)
|
||||
@@ -322,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
maxParallelForks = 2
|
||||
// Source-level expectations use decimal dots; the developer's Windows
|
||||
// display language must not change test output (the APK locale is unaffected).
|
||||
systemProperty("user.language", "en")
|
||||
systemProperty("user.country", "US")
|
||||
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||
if (!includeBenchmarks) {
|
||||
filter { excludeTestsMatching("*Benchmark") }
|
||||
|
||||
Vendored
+5
@@ -57,3 +57,8 @@
|
||||
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
||||
-keep class * extends androidx.navigation3.runtime.NavKey { *; }
|
||||
|
||||
# ── AITURK IDE pairing ───────────────────────────────────────────────────
|
||||
# Android looks this hostname-aware overload up by reflection (X509TrustManagerExtensions).
|
||||
-keepclassmembers class com.m57.hermescontrol.data.aiturk.PinAwareTrustManager {
|
||||
public java.util.List checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String, java.lang.String);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Handshake
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Opt-in live Android/Conscrypt regression. Arguments contain no credentials. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AiturkRelayDeviceTest {
|
||||
@Test
|
||||
fun pinnedHelloOverPublicRelay() {
|
||||
val args = InstrumentationRegistry.getArguments()
|
||||
val relayId = args.getString("aiturkRelayId")
|
||||
val fingerprint = args.getString("aiturkFingerprint")
|
||||
assumeTrue("Supply the IDE relay ID and public certificate fingerprint", relayId != null && fingerprint != null)
|
||||
requireNotNull(relayId)
|
||||
requireNotNull(fingerprint)
|
||||
val started = System.nanoTime()
|
||||
fun mark(stage: String) {
|
||||
Log.i("AiturkRelayTest", "$stage ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)}ms")
|
||||
}
|
||||
val listener = object : EventListener() {
|
||||
override fun secureConnectStart(call: Call) = mark("TLS start")
|
||||
override fun secureConnectEnd(call: Call, handshake: Handshake?) = mark("TLS complete")
|
||||
override fun requestHeadersEnd(call: Call, request: Request) = mark("HTTP sent")
|
||||
override fun responseHeadersEnd(call: Call, response: Response) = mark("HTTP ${response.code}")
|
||||
override fun callFailed(call: Call, ioe: IOException) = mark("FAILED ${ioe.javaClass.simpleName}")
|
||||
}
|
||||
val base = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.callTimeout(30, TimeUnit.SECONDS)
|
||||
.eventListener(listener)
|
||||
.build()
|
||||
runBlocking { AuthManager.awaitInitialization() }
|
||||
val hostPort = "$relayId.relay.aiturk.invalid:443"
|
||||
val previousPin = AiturkLanTrust.pinFor(hostPort)
|
||||
AiturkLanTrust.pin(hostPort, fingerprint)
|
||||
val client = if (args.getString("aiturkProductionClient") == "true") {
|
||||
OkHttpProvider.base.newBuilder().eventListener(listener).callTimeout(30, TimeUnit.SECONDS).build()
|
||||
} else {
|
||||
AiturkLanTrust.pinnedClient(base, fingerprint)
|
||||
}
|
||||
try {
|
||||
repeat(2) {
|
||||
client.newCall(Request.Builder().url("https://$relayId.relay.aiturk.invalid/aiturk/pair/hello").build())
|
||||
.execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
assertTrue(response.body.string().contains("aiturk-ide"))
|
||||
}
|
||||
mark("hello $it complete")
|
||||
}
|
||||
} finally {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
if (previousPin == null) AiturkLanTrust.unpin(hostPort) else AiturkLanTrust.pin(hostPort, previousPin)
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputContentInfo
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.platform.InterceptPlatformTextInput
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/** Real BasicTextField paste and InputConnection entry points; no direct listener invocation. */
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||
class ComposerImagePasteTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val attachments = mutableStateListOf<Attachment>()
|
||||
private val errors = mutableListOf<Int>()
|
||||
private val ownedFiles = mutableListOf<File>()
|
||||
private val input = TextFieldState("keep draft", TextRange(1, 3))
|
||||
private var sends = 0
|
||||
private var connection: InputConnection? = null
|
||||
private var editorInfo: EditorInfo? = null
|
||||
private lateinit var controller: ChatImagePasteController
|
||||
|
||||
private fun prepareComposer(interceptIme: Boolean = false) {
|
||||
compose.setContent {
|
||||
val scope = rememberCoroutineScope()
|
||||
controller =
|
||||
remember {
|
||||
ChatImagePasteController(
|
||||
scope = scope,
|
||||
importImage = ChatPastedImageStore(context)::importImage,
|
||||
captureTarget = { ChatAttachmentTarget("test", 1, "http://test.local", null, null) },
|
||||
commit = { _, images ->
|
||||
attachments.addAll(images)
|
||||
true
|
||||
},
|
||||
onError = errors::add,
|
||||
isImageUri = { uri ->
|
||||
context.contentResolver.getType(uri)?.startsWith("image/", ignoreCase = true) == true
|
||||
},
|
||||
)
|
||||
}
|
||||
InterceptPlatformTextInput(interceptor = { request, next ->
|
||||
if (interceptIme) {
|
||||
val info = EditorInfo()
|
||||
connection = request.createInputConnection(info)
|
||||
editorInfo = info
|
||||
awaitCancellation()
|
||||
} else {
|
||||
next.startInputMethod(request)
|
||||
}
|
||||
}) {
|
||||
ChatInputBar(
|
||||
inputState = input,
|
||||
onSend = { sends++ },
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = false,
|
||||
isConnected = true,
|
||||
isSessionReady = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
pendingAttachments = attachments,
|
||||
receiveContentListener = controller,
|
||||
isReceivingContent = controller.isReceiving,
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("chat_input").performClick()
|
||||
compose.runOnIdle { input.edit { selection = TextRange(1, 3) } }
|
||||
}
|
||||
|
||||
private fun imageUri(): Uri {
|
||||
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.png")
|
||||
ownedFiles.add(file)
|
||||
val bitmap = Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888)
|
||||
try {
|
||||
file.outputStream().use { assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) }
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
}
|
||||
|
||||
private fun pdfUri(): Uri {
|
||||
val file = File(context.cacheDir, "clipboard-test-${UUID.randomUUID()}.pdf")
|
||||
ownedFiles.add(file)
|
||||
file.writeText("%PDF-1.4\nfixture\n")
|
||||
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
}
|
||||
|
||||
private fun paste(clip: ClipData) {
|
||||
compose.runOnIdle {
|
||||
context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip)
|
||||
}
|
||||
compose.onNodeWithTag("chat_input").performSemanticsAction(SemanticsActions.PasteText) { it() }
|
||||
}
|
||||
|
||||
private fun assertImported(source: Uri) {
|
||||
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||
compose.runOnIdle {
|
||||
assertEquals("keep draft", input.text.toString())
|
||||
assertEquals(TextRange(1, 3), input.selection)
|
||||
assertEquals(0, sends)
|
||||
assertTrue(errors.isEmpty())
|
||||
assertNotEquals(source.toString(), attachments.single().uri)
|
||||
val copy = File(context.cacheDir, "pasted_images/${attachments.single().name}")
|
||||
assertTrue(copy.isFile)
|
||||
assertEquals(copy.length(), attachments.single().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clipboardImage_becomesPrivateAttachmentWithoutReplacingSelectionOrSending() {
|
||||
prepareComposer()
|
||||
val source = imageUri()
|
||||
paste(ClipData.newUri(context.contentResolver, "test image", source))
|
||||
assertImported(source)
|
||||
// Clipboard replacement cannot revoke access to the independently owned chip.
|
||||
compose.runOnIdle {
|
||||
context
|
||||
.getSystemService(ClipboardManager::class.java)
|
||||
.setPrimaryClip(ClipData.newPlainText("replacement", "different"))
|
||||
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imeCommitContent_usesRealInputConnectionAndPreservesDraft() {
|
||||
prepareComposer(interceptIme = true)
|
||||
compose.waitUntil(timeoutMillis = 5_000) { connection != null }
|
||||
val source = imageUri()
|
||||
compose.runOnIdle {
|
||||
assertTrue(
|
||||
editorInfo!!.contentMimeTypes.orEmpty().any { ClipDescription.compareMimeTypes("image/png", it) },
|
||||
)
|
||||
assertTrue(
|
||||
connection!!.commitContent(
|
||||
InputContentInfo(source, ClipDescription("image", arrayOf("image/png")), null),
|
||||
0,
|
||||
null,
|
||||
),
|
||||
)
|
||||
}
|
||||
assertImported(source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedClipboard_keepsTextItemsOnTheNormalPastePath() {
|
||||
prepareComposer()
|
||||
val source = imageUri()
|
||||
val clip = ClipData(ClipDescription("mixed", arrayOf("image/png", "text/plain")), ClipData.Item("hello"))
|
||||
clip.addItem(ClipData.Item(source))
|
||||
paste(clip)
|
||||
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||
compose.runOnIdle {
|
||||
assertEquals("khellop draft", input.text.toString())
|
||||
assertFalse(input.text.contains("content://"))
|
||||
assertEquals(0, sends)
|
||||
assertTrue(errors.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dualRepresentationItem_preservesTextPasteAndImportsImage() {
|
||||
prepareComposer()
|
||||
val source = imageUri()
|
||||
val clip =
|
||||
ClipData(
|
||||
ClipDescription("dual", arrayOf("image/png", "text/plain")),
|
||||
ClipData.Item("hello", null, null, source),
|
||||
)
|
||||
paste(clip)
|
||||
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||
compose.runOnIdle {
|
||||
assertEquals("khellop draft", input.text.toString())
|
||||
assertEquals("image/png", attachments.single().mimeType)
|
||||
assertNotEquals(source.toString(), attachments.single().uri)
|
||||
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||
assertTrue(errors.isEmpty())
|
||||
assertEquals(0, sends)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mixedClipboard_importsImageDespitePdfAndPreservesText() {
|
||||
prepareComposer()
|
||||
val image = imageUri()
|
||||
val pdf = pdfUri()
|
||||
val clip =
|
||||
ClipData(
|
||||
ClipDescription("mixed", arrayOf("image/png", "application/pdf", "text/plain")),
|
||||
ClipData.Item("hello"),
|
||||
)
|
||||
clip.addItem(ClipData.Item(image))
|
||||
clip.addItem(ClipData.Item(pdf))
|
||||
paste(clip)
|
||||
compose.waitUntil(timeoutMillis = 10_000) { attachments.size == 1 && !controller.isReceiving }
|
||||
compose.runOnIdle {
|
||||
assertEquals("khellop draft", input.text.toString())
|
||||
assertEquals("image/png", attachments.single().mimeType)
|
||||
assertNotEquals(image.toString(), attachments.single().uri)
|
||||
assertTrue(File(context.cacheDir, "pasted_images/${attachments.single().name}").isFile)
|
||||
assertTrue(errors.isEmpty())
|
||||
assertEquals(0, sends)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainTextPaste_stillReplacesOnlyTheSelection() {
|
||||
prepareComposer()
|
||||
paste(ClipData.newPlainText("text", "hello"))
|
||||
compose.runOnIdle {
|
||||
assertEquals("khellop draft", input.text.toString())
|
||||
assertTrue(attachments.isEmpty())
|
||||
assertEquals(0, sends)
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun cleanupOnlyTestOwnedFiles() {
|
||||
if (::controller.isInitialized) compose.runOnIdle { controller.close() }
|
||||
ownedFiles.forEach { it.delete() }
|
||||
attachments.forEach { File(context.cacheDir, "pasted_images/${it.name}").delete() }
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -1,8 +1,11 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -23,7 +26,6 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,6 +60,7 @@ class ComposerInteractionTest {
|
||||
private var fileTaps = 0
|
||||
|
||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun setComposer(
|
||||
reasoningLevel: String? = "medium",
|
||||
model: String = "openai/gpt-5.5",
|
||||
@@ -67,18 +70,17 @@ class ComposerInteractionTest {
|
||||
showModelProvider: Boolean = false,
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
var listening by remember { mutableStateOf(false) }
|
||||
var currentModel by remember { mutableStateOf(model) }
|
||||
|
||||
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||
val composer: @Composable () -> Unit = {
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = {
|
||||
sends++
|
||||
input = TextFieldValue("")
|
||||
input.clearText()
|
||||
},
|
||||
onMicTap = {
|
||||
micTaps++
|
||||
|
||||
+7
-10
@@ -1,9 +1,8 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
@@ -11,12 +10,12 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
class ComposerReadinessTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
@@ -26,10 +25,9 @@ class ComposerReadinessTest {
|
||||
val ready = mutableStateOf(false)
|
||||
var sends = 0
|
||||
compose.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = { sends++ },
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
@@ -64,10 +62,9 @@ class ComposerReadinessTest {
|
||||
@Test
|
||||
fun emptyDraftWhileStreamingMakesStopThePrimaryAction() {
|
||||
compose.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||
val input = rememberTextFieldState()
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
inputState = input,
|
||||
onSend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
|
||||
+120
-2
@@ -1,27 +1,42 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.hasScrollAction
|
||||
import androidx.compose.ui.test.isDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PendingSendRecoveryTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val uncertain =
|
||||
PendingSend(
|
||||
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
|
||||
state = PendingSendState.UNKNOWN,
|
||||
)
|
||||
|
||||
private fun imageAttachment(): Attachment {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val file = File(context.cacheDir, "photo.png")
|
||||
val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888)
|
||||
bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160))
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
bitmap.recycle()
|
||||
return Attachment(file.toURI().toString(), file.name, "image/png", file.length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uncertainRetryRequiresExplicitConfirmation() {
|
||||
var retried: String? = null
|
||||
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
|
||||
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() {
|
||||
val image = imageAttachment()
|
||||
val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image))
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
UserBubble(
|
||||
ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments),
|
||||
pendingSendState = accepted.state,
|
||||
)
|
||||
PendingSendRecovery(listOf(accepted), true, true, {}, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText(accepted.text).assertIsDisplayed()
|
||||
compose.waitUntil(5_000) {
|
||||
compose
|
||||
.onNodeWithContentDescription("photo.png")
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot.height > 0
|
||||
}
|
||||
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
|
||||
compose.onNodeWithContentDescription("Accepted by server").assertIsDisplayed()
|
||||
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() {
|
||||
val image = imageAttachment()
|
||||
val file = Attachment("file:///private/note", "note.txt", "text/plain", 4)
|
||||
var openedImage: String? = null
|
||||
var openedFile: String? = null
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
PendingSendRecovery(
|
||||
listOf(uncertain.copy(attachments = listOf(image, file))),
|
||||
false,
|
||||
false,
|
||||
{},
|
||||
{},
|
||||
onOpenAttachment = { openedFile = it.name },
|
||||
onImageClick = { openedImage = it.name },
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("pending_send_recovery").performClick()
|
||||
compose.onNodeWithText("photo.png").assertIsDisplayed()
|
||||
compose.waitUntil(5_000) {
|
||||
compose
|
||||
.onNodeWithContentDescription("photo.png")
|
||||
.fetchSemanticsNode()
|
||||
.boundsInRoot.height > 0
|
||||
}
|
||||
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { assertEquals("photo.png", openedImage) }
|
||||
// The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children.
|
||||
if (!compose.onNodeWithText("note.txt").isDisplayed()) {
|
||||
compose.onNode(hasScrollAction()).performTouchInput { swipeUp() }
|
||||
}
|
||||
compose.onNodeWithText("note.txt").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { assertEquals("note.txt", openedFile) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("DEPRECATION")
|
||||
fun chineseRecoveryCountAndDismissAreLocalized() {
|
||||
// Native dialog windows resolve the Activity locale, not a synthetic LocalContext.
|
||||
val resources = compose.activity.resources
|
||||
val original = Configuration(resources.configuration)
|
||||
val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) }
|
||||
try {
|
||||
compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) }
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
PendingSendRecovery(listOf(uncertain), false, false, {}, {})
|
||||
}
|
||||
}
|
||||
compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick()
|
||||
compose
|
||||
.onNodeWithText("移除")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
compose
|
||||
.onNodeWithText(
|
||||
"要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。",
|
||||
).assertIsDisplayed()
|
||||
} finally {
|
||||
compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
||||
compose.setContent {
|
||||
|
||||
+7
-3
@@ -3,9 +3,11 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -19,6 +21,7 @@ import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsFocused
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.captureToImage
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
@@ -26,7 +29,6 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
@@ -52,6 +54,7 @@ import java.io.File
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
class VoiceNoteGestureTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
|
||||
@@ -88,8 +91,7 @@ class VoiceNoteGestureTest {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue(draft),
|
||||
onInputChange = {},
|
||||
inputState = rememberTextFieldState(initialText = draft),
|
||||
onSend = { sendCount++ },
|
||||
onMicTap = { micTaps++ },
|
||||
isListening = false,
|
||||
@@ -275,6 +277,8 @@ class VoiceNoteGestureTest {
|
||||
assertEquals("release must submit the note", 1, holdEnds)
|
||||
assertEquals("a stationary hold-release must not fire a tap", tapsBefore, micTaps)
|
||||
}
|
||||
// The state-based input must retain the draft when the recording strip is removed.
|
||||
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.hasContentDescription
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.R
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class SchedulePickerTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun fullEditorHasReachableSaveAction() {
|
||||
var saved = false
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
CronJobEditorDialog(
|
||||
state = CronJobEditorState(isOpen = true, isNew = true, schedule = "59 23 * * *"),
|
||||
onFieldChange = { _, _ -> },
|
||||
onToggleNoAgent = {},
|
||||
onToggleRunContinuity = {},
|
||||
onSetMonitorMode = {},
|
||||
onSelectBlueprint = {},
|
||||
onBlueprintFieldChange = { _, _ -> },
|
||||
onSave = { saved = true },
|
||||
onDismiss = {},
|
||||
onClearToast = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.waitForIdle()
|
||||
android.os.SystemClock.sleep(500)
|
||||
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
|
||||
File(context.getExternalFilesDir(null), "scheduler-editor-360.png").outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
compose.onNodeWithText(context.getString(R.string.cron_edit_action_save))
|
||||
.assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { assertTrue(saved) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatDropdownAndClockPreserveSchedule() {
|
||||
var schedule by mutableStateOf("59 23 * * *")
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
|
||||
SchedulePickerSection(schedule) { schedule = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun text(id: Int) = context.getString(id)
|
||||
fun screenshot(name: String) {
|
||||
compose.waitForIdle()
|
||||
android.os.SystemClock.sleep(500)
|
||||
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
|
||||
File(context.getExternalFilesDir(null), "$name.png").outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
}
|
||||
screenshot("schedule-daily-360")
|
||||
compose.onNodeWithText("23:59").performClick()
|
||||
screenshot("schedule-clock-360")
|
||||
compose.onNodeWithText("59").performClick()
|
||||
compose.onNode(hasContentDescription("55", substring = true)).performClick()
|
||||
compose.onNodeWithText(text(R.string.action_ok)).performClick()
|
||||
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
|
||||
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||
screenshot("schedule-repeat-360")
|
||||
compose.onAllNodesWithText(text(R.string.cron_repeat_yearly))[0].performClick()
|
||||
compose.runOnIdle { assertEquals("55 23 1 1 *", schedule) }
|
||||
screenshot("schedule-yearly-360")
|
||||
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||
compose.onAllNodesWithText(text(R.string.cron_repeat_hourly))[0].performClick()
|
||||
compose.runOnIdle { assertEquals("55 * * * *", schedule) }
|
||||
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||
compose.onAllNodesWithText(text(R.string.cron_schedule_mode_daily))[0].performClick()
|
||||
compose.runOnIdle { assertEquals("55 23 * * *", schedule) }
|
||||
compose.onNodeWithText(text(R.string.cron_repeat_label)).performClick()
|
||||
compose.onAllNodesWithText(text(R.string.cron_repeat_minutely))[0].performClick()
|
||||
compose.runOnIdle { assertEquals("* * * * *", schedule) }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Dev</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- AITURK IDE pairing: QR scan. Requested at runtime; typing the code works without it. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
@@ -38,8 +41,23 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- AITURK IDE pairing link: aiturk://pair?v=1&u=...&fp=...&c=... -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="aiturk" android:host="pair" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.aiturkpair.AiturkQrCaptureActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
|
||||
<service
|
||||
android:name=".assistant.AssistantService"
|
||||
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import com.m57.hermescontrol.ui.chat.cleanStalePastedImages
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -28,8 +29,14 @@ class HermesControlApp :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val startupTimeMs = System.currentTimeMillis()
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
cleanStalePastedImages(cacheDir, startupTimeMs)
|
||||
}
|
||||
AuthManager.init(this)
|
||||
NetworkMonitor.init(this)
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
.init(this)
|
||||
SessionListCacheStore.init(this)
|
||||
// Reply-notification turn boundaries survive process death (armed before
|
||||
// each mobile prompt, consumed by the matching completion).
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.notification.NotificationReplyReceiver
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLink
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.util.LocaleContextWrapper
|
||||
@@ -62,6 +63,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
@@ -129,6 +131,7 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
consumeNotificationIntent(intent)
|
||||
AiturkPairDeepLink.offer(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -52,6 +52,9 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkUnavailableScreen
|
||||
import com.m57.hermescontrol.ui.common.DisableDrawerGestures
|
||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||
@@ -66,6 +69,7 @@ import com.m57.hermescontrol.ui.settings.SettingsVaultPage
|
||||
import com.m57.hermescontrol.ui.settings.SettingsViewModel
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetDetailScreen
|
||||
import kotlinx.coroutines.launch
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairScreen as AiturkPairScreenContent
|
||||
import com.m57.hermescontrol.ui.authlogin.AuthLoginScreen as AuthLoginScreenContent
|
||||
import com.m57.hermescontrol.ui.landing.LandingScreen as LandingScreenContent
|
||||
|
||||
@@ -79,6 +83,9 @@ private fun appEntryProvider(
|
||||
onAuthLogin = {
|
||||
NavigationController.navigateTo(AuthLoginScreen)
|
||||
},
|
||||
onAiturkPair = {
|
||||
NavigationController.navigateTo(AiturkPairScreen)
|
||||
},
|
||||
)
|
||||
// Landing doesn't use HermesScaffold — opt out of drawer gestures explicitly (issue #619).
|
||||
DisableDrawerGestures()
|
||||
@@ -96,9 +103,25 @@ private fun appEntryProvider(
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
entry<AiturkPairScreen> {
|
||||
AiturkPairScreenContent(
|
||||
onConnected = {
|
||||
NavigationController.resetTo(ChatScreen)
|
||||
},
|
||||
onBack = {
|
||||
NavigationController.goBack()
|
||||
},
|
||||
)
|
||||
DisableDrawerGestures()
|
||||
}
|
||||
|
||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||
addEntryProvider(clazz = screen.key::class) {
|
||||
screen.content(sessionId, openDrawer)
|
||||
if (AiturkFeatureGate.shows(screen.key)) {
|
||||
screen.content(sessionId, openDrawer)
|
||||
} else {
|
||||
AiturkUnavailableScreen(titleRes = screen.labelRes, onOpenDrawer = openDrawer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +216,7 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
|
||||
val backStack = remember(startScreen) { NavBackStack(startScreen) }
|
||||
NavigationController.backStack = backStack
|
||||
AiturkPairDeepLinkEffect()
|
||||
|
||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
@@ -307,6 +331,7 @@ fun MainNavigation(sessionId: String? = null) {
|
||||
)
|
||||
ScreenRegistry.ALL_SCREENS
|
||||
.filter { it.drawerSection == section }
|
||||
.filter { AiturkFeatureGate.shows(it.key) }
|
||||
.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
icon = { Icon(entry.icon, contentDescription = null) },
|
||||
|
||||
@@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object AuthLoginScreen : NavKey
|
||||
|
||||
@Serializable data object AiturkPairScreen : NavKey
|
||||
|
||||
@Serializable data object ChatScreen : NavKey
|
||||
|
||||
@Serializable data object SettingsScreen : NavKey
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* What differs when the app talks to a paired AITURK IDE instead of a plain
|
||||
* Hermes dashboard, kept in one place so the upstream files only carry
|
||||
* one-line hooks.
|
||||
*
|
||||
* AITURK IDE's agent is an older/forked Hermes gateway: some JSON-RPC methods
|
||||
* this app knows (`connectors.*`, `vault.*`, `subagent.list` …) are not
|
||||
* registered there and answer `-32601 unknown method`, and the IDE's phone
|
||||
* proxy refuses secret and settings routes with `403 {"error":"forbidden"}`.
|
||||
* Neither is an error the user can act on, so:
|
||||
*
|
||||
* - an awaited call that hits `-32601` fails quietly (no chat banner) and the
|
||||
* method is remembered until the next socket, so it is not sent again;
|
||||
* - every text shown for such an answer is a plain sentence, never the raw
|
||||
* `JsonRpcError(code=-32601, …)` or `unknown method` wording;
|
||||
* - idle pooled tunnel connections are dropped when the phone changes network
|
||||
* and the chat socket is reopened when the app returns to the foreground.
|
||||
*
|
||||
* Nothing here runs for a connection that is not a paired AITURK IDE.
|
||||
*/
|
||||
object AiturkCompat {
|
||||
const val METHOD_NOT_FOUND = -32601
|
||||
|
||||
private const val FALLBACK_FEATURE_UNAVAILABLE = "This feature is not available in this version of AITURK IDE."
|
||||
private const val FALLBACK_PHONE_FORBIDDEN =
|
||||
"For security this cannot be done from the phone. Do it in AITURK IDE on your computer."
|
||||
private const val FALLBACK_BACKEND_NOT_READY =
|
||||
"The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes."
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** Tests set this instead of pairing a real address. */
|
||||
@Volatile
|
||||
internal var linkedOverride: Boolean? = null
|
||||
|
||||
/** Tests replace the string lookup (no Android resources on the JVM). */
|
||||
@Volatile
|
||||
internal var stringLookup: ((Int) -> String?)? = null
|
||||
|
||||
private val missingMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
private val started = AtomicBoolean(false)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Called once from the Application: string lookup and the network-change hook. */
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (started.compareAndSet(false, true)) {
|
||||
scope.launch {
|
||||
NetworkMonitor.networkChanges.collect { available -> if (available) onNetworkChanged() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True while the active connection is a paired AITURK IDE (LAN or tunnel). */
|
||||
fun isLinked(): Boolean =
|
||||
linkedOverride ?: runCatching { AiturkLanTrust.isPairedUrl(AuthManager.baseUrl()) }.getOrDefault(false)
|
||||
|
||||
/** REST and chat must resolve the same phone-selected profile in the IDE proxy. */
|
||||
fun webSocketProfile(profile: String?): Map<String, String> =
|
||||
if (isLinked()) mapOf("profile" to (profile?.takeIf { it.isNotBlank() } ?: "default")) else emptyMap()
|
||||
|
||||
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
/** True when an awaited call's error must not also be shown as a chat banner. */
|
||||
fun quietRpcError(code: Int): Boolean = code == METHOD_NOT_FOUND && isLinked()
|
||||
|
||||
/** Remembers that the IDE on the other end does not have [method]. */
|
||||
fun noteRpcError(
|
||||
method: String?,
|
||||
code: Int,
|
||||
) {
|
||||
if (method != null && quietRpcError(code)) missingMethods.add(method)
|
||||
}
|
||||
|
||||
/** True when [method] already answered "unknown method" on this socket. */
|
||||
fun isKnownMissing(method: String): Boolean = method in missingMethods && isLinked()
|
||||
|
||||
/** A new socket may be a newer IDE: ask again. */
|
||||
fun onSocketOpened() {
|
||||
missingMethods.clear()
|
||||
}
|
||||
|
||||
/** The exception a locally refused call fails with (same code the IDE would send). */
|
||||
fun missingMethodError(): HermesWsClient.HermesRpcException =
|
||||
HermesWsClient.HermesRpcException(featureUnavailableText(), code = METHOD_NOT_FOUND)
|
||||
|
||||
/** Message of the exception an awaited call fails with. */
|
||||
fun rpcExceptionMessage(
|
||||
code: Int,
|
||||
message: String,
|
||||
): String = if (quietRpcError(code)) featureUnavailableText() else message
|
||||
|
||||
/**
|
||||
* Chat banner text for an RPC error event. [legacy] is the upstream text,
|
||||
* used unchanged for every connection that is not a paired AITURK IDE.
|
||||
*/
|
||||
fun rpcBannerText(
|
||||
error: Any?,
|
||||
legacy: String,
|
||||
): String {
|
||||
if (!isLinked()) return legacy
|
||||
val code = (error as? JsonRpcError)?.code ?: ((error as? Map<*, *>)?.get("code") as? Number)?.toInt()
|
||||
if (code == METHOD_NOT_FOUND) return featureUnavailableText()
|
||||
val message = (error as? JsonRpcError)?.message ?: (error as? Map<*, *>)?.get("message") as? String
|
||||
return message?.takeIf { it.isNotBlank() } ?: legacy
|
||||
}
|
||||
|
||||
// ── REST ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A plain sentence for the two answers that mean "not from the phone" /
|
||||
* "not in this IDE": the proxy's `403 {"error":"forbidden"}` and a route
|
||||
* the IDE does not have (`404 {"detail":"Not Found"}`, the proxy's
|
||||
* `404 {"error":"not_found"}`). Null for everything else, including a 404
|
||||
* that names a real missing record ("Session not found").
|
||||
*/
|
||||
fun httpErrorMessage(
|
||||
code: Int,
|
||||
errorBody: String?,
|
||||
): String? {
|
||||
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND && code != HTTP_UNAVAILABLE) return null
|
||||
if (!isLinked()) return null
|
||||
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
||||
val error = (body?.get("error") as? JsonPrimitive)?.content
|
||||
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
||||
return when {
|
||||
code == HTTP_UNAVAILABLE && error == "backend_unavailable" ->
|
||||
string(R.string.aiturk_backend_not_ready) ?: FALLBACK_BACKEND_NOT_READY
|
||||
code == HTTP_FORBIDDEN && error == "forbidden" -> phoneForbiddenText()
|
||||
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A brand-new chat has no row in the IDE's session store until its first
|
||||
* message, so `GET /api/sessions/<id>` answers 404 on every poll.
|
||||
*/
|
||||
fun skipSessionDetail(transcriptEmpty: Boolean): Boolean = transcriptEmpty && isLinked()
|
||||
|
||||
// ── Network and lifecycle ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The default network changed (Wi-Fi ⇄ mobile data). Idle pooled
|
||||
* connections still ride the old network's tunnel stream; drop them so the
|
||||
* next REST call opens a fresh stream instead of waiting for a read timeout.
|
||||
*/
|
||||
internal fun onNetworkChanged(evictIdle: () -> Unit = { OkHttpProvider.base.connectionPool.evictAll() }) {
|
||||
if (isLinked()) runCatching(evictIdle)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app came back to the foreground. An idle socket is closed on purpose
|
||||
* in the background; reopen it so the user does not have to tap "reconnect".
|
||||
* A socket that is open, opening, or signed out is left alone.
|
||||
*/
|
||||
fun onAppForeground(
|
||||
status: ConnectionStatus = HermesWsClient.connectionStatus.value,
|
||||
autoReconnect: () -> Boolean = { runCatching { AuthManager.isAutoReconnect() }.getOrDefault(false) },
|
||||
connect: () -> Unit = { HermesWsClient.connect() },
|
||||
) {
|
||||
if (status == ConnectionStatus.DISCONNECTED && isLinked() && autoReconnect()) connect()
|
||||
}
|
||||
|
||||
// ── Text ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** "v1.2.3 (45)": what the pair screen and Settings → About show. */
|
||||
fun versionLabel(
|
||||
name: String = BuildConfig.VERSION_NAME,
|
||||
code: Int = BuildConfig.VERSION_CODE,
|
||||
): String = "v$name ($code)"
|
||||
|
||||
fun featureUnavailableText(): String = string(R.string.aiturk_feature_unavailable) ?: FALLBACK_FEATURE_UNAVAILABLE
|
||||
|
||||
fun phoneForbiddenText(): String = string(R.string.aiturk_phone_forbidden) ?: FALLBACK_PHONE_FORBIDDEN
|
||||
|
||||
private fun string(
|
||||
@StringRes id: Int,
|
||||
): String? = stringLookup?.invoke(id) ?: runCatching { appContext?.getString(id) }.getOrNull()
|
||||
|
||||
internal fun resetForTest() {
|
||||
linkedOverride = null
|
||||
stringLookup = null
|
||||
missingMethods.clear()
|
||||
}
|
||||
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
private const val HTTP_UNAVAILABLE = 503
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Stores a freshly claimed AITURK IDE pairing in the app's existing connection
|
||||
* model: a [ConnectionProfile] named after the computer, token auth, WS `?token=`.
|
||||
*/
|
||||
object AiturkConnectionRegistrar {
|
||||
const val FALLBACK_PC_NAME = "AITURK IDE"
|
||||
|
||||
suspend fun register(device: AiturkPairedDevice) {
|
||||
// The address the claim was sent to: the LAN `https://ip:port/` or the
|
||||
// virtual tunnel host `https://<relayId>.relay.aiturk.invalid/`.
|
||||
val baseUrl = device.baseUrl
|
||||
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
|
||||
|
||||
// Trust first: every later request to this host:port — over the LAN or
|
||||
// through the tunnel — is checked against the pin, end to end.
|
||||
AiturkLanTrust.pin(device.hostPort, device.fingerprint)
|
||||
|
||||
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
|
||||
|
||||
// Re-pairing the same computer reuses its profile instead of piling up duplicates.
|
||||
val profiles = AuthManager.getConnectionProfiles()
|
||||
val existing = profiles.firstOrNull { it.baseUrl == baseUrl }
|
||||
val profile =
|
||||
existing?.copy(name = name)
|
||||
?: ConnectionProfile(name = name, host = "", port = 0, baseUrl = baseUrl)
|
||||
AuthManager.saveConnectionProfiles(
|
||||
if (existing != null) {
|
||||
profiles.map { if (it.id == profile.id) profile else it }
|
||||
} else {
|
||||
profiles + profile
|
||||
},
|
||||
)
|
||||
AuthManager.setSelectedProfileId(profile.id)
|
||||
AuthManager.syncCookieStoreForProfile(profile.id)
|
||||
|
||||
// Same order as the manual token login (AuthLoginViewModel.connect).
|
||||
AuthManager.setBaseUrl(baseUrl)
|
||||
AuthManager.setToken(device.token)
|
||||
AuthManager.setSessionCookie(null)
|
||||
AuthManager.setWsAuthParam("token")
|
||||
// Rebuilds Retrofit, tells chat the server changed, then re-dials the socket.
|
||||
ProfileSwitchCoordinator.switchConnectionProfile(profile.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSession
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* TLS trust for AITURK IDE LAN pairing.
|
||||
*
|
||||
* The IDE's LAN proxy presents a self-signed certificate. Its DER SHA-256
|
||||
* fingerprint travels in the QR code (or the gateway rendezvous record) and is
|
||||
* stored here per `host:port`. For a paired `host:port` the certificate is
|
||||
* accepted ONLY when its fingerprint matches (system CAs are not consulted);
|
||||
* for every other address the platform's default trust manager and OkHttp's
|
||||
* default hostname verifier decide exactly as before.
|
||||
*/
|
||||
object AiturkLanTrust {
|
||||
/** Where pins are persisted. Swappable so JVM tests need no Android keystore. */
|
||||
interface PinPersistence {
|
||||
fun load(): Map<String, String>
|
||||
|
||||
fun save(pins: Map<String, String>)
|
||||
}
|
||||
|
||||
private const val PREF_KEY = "aiturk_lan_pins"
|
||||
private val IPV4_HOST_PORT = Regex("""\d{1,3}(\.\d{1,3}){3}:\d{1,5}""")
|
||||
|
||||
private val pins = ConcurrentHashMap<String, String>()
|
||||
|
||||
@Volatile
|
||||
private var loaded = false
|
||||
|
||||
@Volatile
|
||||
internal var persistence: PinPersistence = EncryptedPrefsPersistence
|
||||
|
||||
private val platformTrustManager: X509TrustManager by lazy { defaultTrustManager() }
|
||||
private val platformHostnameVerifier: HostnameVerifier by lazy { OkHttpClient.Builder().build().hostnameVerifier }
|
||||
|
||||
val trustManager: X509TrustManager by lazy { PinAwareTrustManager(platformTrustManager, ::pinFor, ::pinsForHost) }
|
||||
val sslSocketFactory: SSLSocketFactory by lazy { socketFactoryFor(trustManager) }
|
||||
val hostnameVerifier: HostnameVerifier by lazy { PinAwareHostnameVerifier(platformHostnameVerifier, ::pinFor) }
|
||||
|
||||
/**
|
||||
* The single hook for [com.m57.hermescontrol.data.remote.OkHttpProvider.base]:
|
||||
* every REST / WebSocket / media / image client derives from that builder.
|
||||
* If the TLS objects cannot be built the builder is left untouched (platform
|
||||
* defaults), which only means a paired IDE cannot be reached.
|
||||
*/
|
||||
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder =
|
||||
runCatching {
|
||||
builder
|
||||
.sslSocketFactory(sslSocketFactory, trustManager)
|
||||
.hostnameVerifier(hostnameVerifier)
|
||||
// A relay pairing lives on a virtual host: DNS, socket and proxy
|
||||
// choice are special-cased for that host only (see AiturkTunnel).
|
||||
.let(AiturkTunnel::applyTo)
|
||||
}.getOrDefault(builder)
|
||||
|
||||
/**
|
||||
* A client that talks ONLY to a server presenting [fingerprint] — used for
|
||||
* `hello` / `claim` before the pin is stored. Nothing else is trusted on it.
|
||||
*/
|
||||
fun pinnedClient(
|
||||
base: OkHttpClient,
|
||||
fingerprint: String,
|
||||
tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
|
||||
): OkHttpClient {
|
||||
val lookup: (String) -> String? = { fingerprint }
|
||||
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
|
||||
return base
|
||||
.newBuilder()
|
||||
.sslSocketFactory(socketFactoryFor(manager), manager)
|
||||
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
|
||||
.let { AiturkTunnel.applyTo(it, tunnel) }
|
||||
.build()
|
||||
}
|
||||
|
||||
fun pin(
|
||||
hostPort: String,
|
||||
fingerprint: String,
|
||||
) {
|
||||
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
|
||||
// Pins are for the IDE's own TLS proxy only: its LAN `ip:port`, or the
|
||||
// virtual tunnel host. A real DNS name keeps the platform's checks.
|
||||
require(IPV4_HOST_PORT.matches(hostPort) || isRelayHostPort(hostPort)) {
|
||||
"Only an AITURK IDE address can be pinned"
|
||||
}
|
||||
ensureLoaded()
|
||||
// Saving on top of an unread store would drop the pins already in it.
|
||||
check(loaded) { "Secure storage is not ready" }
|
||||
pins[hostPort] = fingerprint
|
||||
persistence.save(pins.toMap())
|
||||
}
|
||||
|
||||
fun unpin(hostPort: String) {
|
||||
ensureLoaded()
|
||||
if (pins.remove(hostPort) != null) persistence.save(pins.toMap())
|
||||
}
|
||||
|
||||
fun pinFor(hostPort: String): String? {
|
||||
ensureLoaded()
|
||||
return pins[hostPort]
|
||||
}
|
||||
|
||||
/** Pins of every paired port on [host] (the Android chain-cleaner path knows no port). */
|
||||
internal fun pinsForHost(host: String): Collection<String> {
|
||||
ensureLoaded()
|
||||
return pins.filterKeys { it.substringBeforeLast(':') == host }.values
|
||||
}
|
||||
|
||||
/** True when [baseUrl] is an HTTPS address of a paired AITURK IDE. */
|
||||
fun isPairedUrl(baseUrl: String?): Boolean {
|
||||
val url = baseUrl?.toHttpUrlOrNull() ?: return false
|
||||
return url.isHttps && pinFor("${url.host}:${url.port}") != null
|
||||
}
|
||||
|
||||
internal fun resetForTest(newPersistence: PinPersistence) {
|
||||
persistence = newPersistence
|
||||
pins.clear()
|
||||
loaded = false
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
if (loaded) return
|
||||
synchronized(this) {
|
||||
if (loaded) return
|
||||
// Storage not ready yet (or broken): behave as "nothing paired" for
|
||||
// now and look again on the next call. load() never blocks.
|
||||
val stored = runCatching { persistence.load() }.getOrNull() ?: return
|
||||
stored.forEach { (key, value) -> pins.putIfAbsent(key, value) }
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/** DER SHA-256 of [certificate] compared in constant time with a base64url pin. */
|
||||
internal fun matches(
|
||||
certificate: X509Certificate,
|
||||
fingerprint: String,
|
||||
): Boolean {
|
||||
val expected = AiturkPairing.decodeFingerprint(fingerprint) ?: return false
|
||||
val actual = MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
|
||||
return MessageDigest.isEqual(expected, actual)
|
||||
}
|
||||
|
||||
private fun isRelayHostPort(hostPort: String): Boolean =
|
||||
hostPort.endsWith(":${AiturkRelay.PORT}") && AiturkRelay.isVirtualHost(hostPort.substringBeforeLast(':'))
|
||||
|
||||
// Wrapped so that TLS over a tunnel stream is layered on a socket without a
|
||||
// file descriptor; for every other socket the wrapper is a pass-through.
|
||||
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
|
||||
AiturkTunnelSslSocketFactory(
|
||||
SSLContext
|
||||
.getInstance("TLS")
|
||||
.apply { init(null, arrayOf(manager), null) }
|
||||
.socketFactory,
|
||||
)
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||
}
|
||||
|
||||
/** Pins live in the app's encrypted preferences next to the connection tokens. */
|
||||
private object EncryptedPrefsPersistence : PinPersistence {
|
||||
private fun prefs(): android.content.SharedPreferences {
|
||||
// Never wait here: this runs inside TLS handshakes. A connection to a
|
||||
// paired IDE cannot start before AuthManager is ready anyway, because
|
||||
// its address and token come from the same store.
|
||||
check(AuthManager.initializationState.value == AuthManager.InitializationState.Ready) {
|
||||
"Secure storage is not ready"
|
||||
}
|
||||
return runBlocking { AuthManager.awaitPrefs() }
|
||||
}
|
||||
|
||||
override fun load(): Map<String, String> {
|
||||
val prefs = prefs()
|
||||
val raw = prefs.getString(PREF_KEY, null) ?: return emptyMap()
|
||||
return runCatching {
|
||||
Json.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content }
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
override fun save(pins: Map<String, String>) {
|
||||
val prefs = prefs()
|
||||
val encoded = JsonObject(pins.mapValues { JsonPrimitive(it.value) }).toString()
|
||||
prefs.edit().putString(PREF_KEY, encoded).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the leaf certificate against the pin of the peer `host:port` when one
|
||||
* exists; otherwise defers to [delegate] untouched.
|
||||
*/
|
||||
internal class PinAwareTrustManager(
|
||||
private val delegate: X509TrustManager,
|
||||
private val pinFor: (String) -> String?,
|
||||
private val pinsForHost: (String) -> Collection<String>,
|
||||
) : X509ExtendedTrustManager() {
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
socket: Socket?,
|
||||
) {
|
||||
val pin = peerOf(socket)?.let(pinFor)
|
||||
when {
|
||||
pin != null -> requirePinned(chain, pin)
|
||||
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, socket)
|
||||
else -> delegate.checkServerTrusted(chain, authType)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
engine: SSLEngine?,
|
||||
) {
|
||||
val pin = engine?.let { peerKey(it.peerHost, it.peerPort) }?.let(pinFor)
|
||||
when {
|
||||
pin != null -> requirePinned(chain, pin)
|
||||
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, engine)
|
||||
else -> delegate.checkServerTrusted(chain, authType)
|
||||
}
|
||||
}
|
||||
|
||||
/** No peer information: never apply a pin, platform validation only. */
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) = delegate.checkServerTrusted(chain, authType)
|
||||
|
||||
/**
|
||||
* Android's hostname-aware entry point (`X509TrustManagerExtensions`, used by
|
||||
* OkHttp's certificate chain cleaner). Looked up reflectively by the platform.
|
||||
* The port is unknown here, so any pin stored for [host] may match. This is
|
||||
* not the handshake's trust decision (that is the socket/engine overload).
|
||||
*/
|
||||
@Suppress("unused")
|
||||
fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
host: String,
|
||||
): List<X509Certificate> {
|
||||
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||
if (pinsForHost(host).any { AiturkLanTrust.matches(leaf, it) }) return listOf(leaf)
|
||||
val hostAware =
|
||||
runCatching {
|
||||
delegate.javaClass.getMethod(
|
||||
"checkServerTrusted",
|
||||
Array<X509Certificate>::class.java,
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
)
|
||||
}.getOrNull()
|
||||
if (hostAware == null) {
|
||||
delegate.checkServerTrusted(chain, authType)
|
||||
return chain.toList()
|
||||
}
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return hostAware.invoke(delegate, chain, authType, host) as? List<X509Certificate> ?: chain.toList()
|
||||
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||
throw e.targetException as? CertificateException ?: CertificateException(e.targetException)
|
||||
}
|
||||
}
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
) = delegate.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
socket: Socket?,
|
||||
) = delegate.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
engine: SSLEngine?,
|
||||
) = delegate.checkClientTrusted(chain, authType)
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
|
||||
|
||||
private fun requirePinned(
|
||||
chain: Array<X509Certificate>,
|
||||
pin: String,
|
||||
) {
|
||||
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||
if (!AiturkLanTrust.matches(leaf, pin)) {
|
||||
throw CertificateException("AITURK IDE certificate fingerprint mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
private fun peerOf(socket: Socket?): String? {
|
||||
if (socket == null) return null
|
||||
// Prefer the identity the TLS layer was asked to reach (the URL's host
|
||||
// and port); fall back to the connected address.
|
||||
val session = (socket as? SSLSocket)?.let { runCatching { it.handshakeSession }.getOrNull() }
|
||||
return session?.let { peerKey(it.peerHost, it.peerPort) }
|
||||
?: peerKey(socket.inetAddress?.hostAddress, socket.port)
|
||||
}
|
||||
|
||||
private fun peerKey(
|
||||
host: String?,
|
||||
port: Int,
|
||||
): String? = if (host.isNullOrBlank() || port <= 0) null else "$host:$port"
|
||||
}
|
||||
|
||||
/** Hostname check: fingerprint for a paired `host:port`, [delegate] for everything else. */
|
||||
internal class PinAwareHostnameVerifier(
|
||||
private val delegate: HostnameVerifier,
|
||||
private val pinFor: (String) -> String?,
|
||||
) : HostnameVerifier {
|
||||
override fun verify(
|
||||
hostname: String,
|
||||
session: SSLSession,
|
||||
): Boolean {
|
||||
val pin = pinFor("$hostname:${session.peerPort}") ?: return delegate.verify(hostname, session)
|
||||
val leaf =
|
||||
runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }.getOrNull()
|
||||
?: return false
|
||||
return AiturkLanTrust.matches(leaf, pin)
|
||||
}
|
||||
}
|
||||
|
||||
/** Used by [AiturkLanTrust.pinnedClient]: anything that is not the pinned certificate is refused. */
|
||||
private object RejectAllTrustManager : X509TrustManager {
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
): Unit = throw CertificateException("Not trusted")
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
authType: String,
|
||||
): Unit = throw CertificateException("Not trusted")
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.data.remote.await
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okio.Buffer
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.PortUnreachableException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
|
||||
enum class AiturkPairError {
|
||||
/** Gateway `resolve` 404: unknown, expired or exhausted code. */
|
||||
CODE_NOT_FOUND,
|
||||
|
||||
/** Gateway `resolve` 429. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** Gateway unreachable or answered something unexpected. */
|
||||
GATEWAY_UNAVAILABLE,
|
||||
|
||||
/** No LAN candidate answered `hello` with the pinned certificate in time. */
|
||||
PC_UNREACHABLE,
|
||||
|
||||
/** Tunnel closed with 4503: AITURK IDE is not running or Phone connection is off. */
|
||||
HOST_OFFLINE,
|
||||
|
||||
/** Tunnel closed with 4504, or the computer stayed silent inside the tunnel. */
|
||||
HOST_TIMEOUT,
|
||||
|
||||
/** Tunnel closed with 4429: too many streams for this relay. */
|
||||
RELAY_BUSY,
|
||||
|
||||
/** Tunnel closed with 4404: the relay id no longer exists. */
|
||||
RELAY_UNKNOWN,
|
||||
|
||||
/** turkservis.online could not be reached at all (no internet, DNS, timeout). */
|
||||
NO_INTERNET,
|
||||
|
||||
/** Inside the tunnel the certificate did not match the pairing fingerprint. */
|
||||
TLS_PIN,
|
||||
|
||||
/** The tunnel ended in a way the contract does not name. */
|
||||
RELAY_ERROR,
|
||||
|
||||
/** Only LAN candidates were left and the local-network permission is denied. */
|
||||
LAN_PERMISSION,
|
||||
|
||||
/** `claim` 400 `invalid_request` (or 413). */
|
||||
INVALID_REQUEST,
|
||||
|
||||
/** `claim` 401 `wrong_code`. */
|
||||
WRONG_CODE,
|
||||
|
||||
/** `claim` 410 `pairing_closed` (not open, expired or code already used). */
|
||||
PAIRING_CLOSED,
|
||||
|
||||
/** `claim` 410 `pairing_locked` (5 wrong attempts). */
|
||||
PAIRING_LOCKED,
|
||||
|
||||
/** The computer answered, but not with what the contract promises. */
|
||||
UNEXPECTED_RESPONSE,
|
||||
}
|
||||
|
||||
/**
|
||||
* [baseUrl] is the address the `claim` was sent to (trailing slash): either the
|
||||
* LAN `https://<ip:port>/` ([endpoint] set) or the virtual tunnel host
|
||||
* `https://<relayId>.relay.aiturk.invalid/` ([endpoint] null). In both cases the
|
||||
* certificate is pinned to [fingerprint] under [hostPort].
|
||||
*/
|
||||
data class AiturkPairedDevice(
|
||||
val baseUrl: String,
|
||||
val hostPort: String,
|
||||
val endpoint: AiturkEndpoint?,
|
||||
val fingerprint: String,
|
||||
val token: String,
|
||||
val deviceId: String,
|
||||
val pcName: String,
|
||||
val version: String,
|
||||
) {
|
||||
val viaRelay: Boolean get() = endpoint == null
|
||||
}
|
||||
|
||||
sealed interface AiturkPairResult<out T> {
|
||||
data class Success<T>(
|
||||
val value: T,
|
||||
) : AiturkPairResult<T>
|
||||
|
||||
/**
|
||||
* [code] is a short diagnostic shown in small print (`relay:host_offline`,
|
||||
* `tls:pin`, `lan:timeout`, `relay:network,lan:refused` …). It is assembled only from
|
||||
* the fixed vocabulary in [AiturkDiag] plus an HTTP status number: never a
|
||||
* token, pairing code, address or exception message.
|
||||
*/
|
||||
data class Failure(
|
||||
val error: AiturkPairError,
|
||||
val code: String = "",
|
||||
) : AiturkPairResult<Nothing>
|
||||
}
|
||||
|
||||
/** Fixed vocabulary of the diagnostic codes. */
|
||||
object AiturkDiag {
|
||||
const val TIMEOUT = "timeout"
|
||||
const val DNS = "dns"
|
||||
const val TLS = "tls"
|
||||
const val REFUSED = "refused"
|
||||
const val UNREACHABLE = "unreachable"
|
||||
const val PERMISSION = "permission"
|
||||
const val IO = "io"
|
||||
const val BAD_RESPONSE = "bad_response"
|
||||
const val NO_CANDIDATES = "no_candidates"
|
||||
const val PAIRING_CLOSED = "pairing_closed"
|
||||
const val PIN = "pin"
|
||||
const val NETWORK = "network"
|
||||
|
||||
private const val MAX_CAUSE_DEPTH = 6
|
||||
private val safe = Regex("[a-z]{1,8}:[a-z0-9_]{1,24}(,[a-z]{1,8}:[a-z0-9_]{1,24})*")
|
||||
|
||||
/** Defence in depth for the UI: anything outside the vocabulary shape is not shown. */
|
||||
fun sanitize(code: String?): String = code?.takeIf { it.length <= MAX_CODE_LENGTH && safe.matches(it) }.orEmpty()
|
||||
|
||||
/**
|
||||
* Which kind of network failure [error] is. Looks at the exception TYPE and,
|
||||
* for connect failures, at the errno name the platform puts in the message
|
||||
* (`ECONNREFUSED`, `EHOSTUNREACH`, …). The message itself is never returned.
|
||||
*/
|
||||
fun networkKind(error: Throwable): String {
|
||||
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
|
||||
val text = chain.joinToString(" ") { it.message.orEmpty() }.uppercase(Locale.ROOT)
|
||||
|
||||
fun mentions(vararg needles: String) = needles.any { it in text }
|
||||
return when {
|
||||
chain.any { it is SSLException || it is CertificateException } -> TLS
|
||||
|
||||
chain.any { it is UnknownHostException } -> DNS
|
||||
|
||||
mentions("EPERM", "EACCES", "OPERATION NOT PERMITTED", "PERMISSION DENIED") -> PERMISSION
|
||||
|
||||
chain.any { it is NoRouteToHostException || it is PortUnreachableException } -> UNREACHABLE
|
||||
|
||||
mentions("EHOSTUNREACH", "ENETUNREACH", "NO ROUTE TO HOST", "NETWORK IS UNREACHABLE") -> UNREACHABLE
|
||||
|
||||
chain.any { it is SocketTimeoutException } || mentions("ETIMEDOUT") -> TIMEOUT
|
||||
|
||||
chain.any { it is ConnectException } -> REFUSED
|
||||
|
||||
// OkHttp's callTimeout surfaces as InterruptedIOException("timeout").
|
||||
chain.any { it is InterruptedIOException } -> TIMEOUT
|
||||
|
||||
else -> IO
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question for a request that went through the tunnel. The relay's
|
||||
* own verdict (close code) wins; a certificate that does not match the
|
||||
* pairing fingerprint is [PIN]; silence inside an open tunnel is [TIMEOUT];
|
||||
* everything else is the network between the phone and turkservis.online.
|
||||
*/
|
||||
fun tunnelKind(error: Throwable): String {
|
||||
AiturkTunnelException.find(error)?.let { return it.kind }
|
||||
val chain = generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }.take(MAX_CAUSE_DEPTH).toList()
|
||||
return when {
|
||||
chain.any { it is CertificateException || it is javax.net.ssl.SSLPeerUnverifiedException } -> PIN
|
||||
chain.any { it is InterruptedIOException } -> TIMEOUT
|
||||
chain.any { it is SSLException } -> TLS
|
||||
else -> NETWORK
|
||||
}
|
||||
}
|
||||
|
||||
/** `relay:<kind>`, except the pin failure which the contract names `tls:pin`. */
|
||||
fun relayCode(kind: String): String = if (kind == PIN) "tls:$PIN" else "relay:$kind"
|
||||
|
||||
private const val MAX_CODE_LENGTH = 96
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone side of the AITURK IDE pairing handshake.
|
||||
*
|
||||
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
|
||||
* Contract: v1 (`aiturk-mobil-eslestirme-spec`) plus v3 (`aiturk-mobil-tunel-spec`).
|
||||
*/
|
||||
class AiturkPairingClient(
|
||||
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
|
||||
private val baseClient: OkHttpClient = defaultBaseClient,
|
||||
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
|
||||
private val relayHelloTimeoutMs: Long = RELAY_HELLO_TIMEOUT_MS,
|
||||
private val tunnel: () -> AiturkTunnelConnector = { AiturkTunnel.connector },
|
||||
) {
|
||||
/** Code path: `resolve` the rendezvous record for a normalized code. */
|
||||
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body =
|
||||
buildJsonObject { put("lookup", AiturkPairing.lookup(normalizedCode)) }
|
||||
.toString()
|
||||
.toRequestBody(JSON)
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(under(gatewayBaseUrl, RESOLVE_PATH))
|
||||
.post(body)
|
||||
.build()
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.callTimeout(GATEWAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
try {
|
||||
client.newCall(request).await().use { response ->
|
||||
when (response.code) {
|
||||
200 -> parseOffer(readJson(response))
|
||||
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND, "resolve:not_found")
|
||||
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED, "resolve:rate_limited")
|
||||
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:http_${response.code}")
|
||||
}
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:network")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tunnel first, LAN second, and exactly ONE `claim`:
|
||||
* 1. [AiturkPairingOffer.relay] present → `hello` to the virtual tunnel host
|
||||
* (TLS inside the tunnel, pinned to the offer's fingerprint). Answered →
|
||||
* the single `claim` goes through the tunnel and the LAN candidates are
|
||||
* never contacted.
|
||||
* 2. Relay absent or failed → `hello` to every LAN candidate in parallel
|
||||
* (same pin), single `claim` to the first answer.
|
||||
*
|
||||
* [lanAllowed] false (Android 17+ local-network permission denied) skips step
|
||||
* 2 and reports `lan:permission`. When both steps fail the relay's failure is
|
||||
* the user-facing error and both codes are kept (`relay:…,lan:…`).
|
||||
*/
|
||||
suspend fun pair(
|
||||
offer: AiturkPairingOffer,
|
||||
normalizedCode: String,
|
||||
deviceName: String,
|
||||
lanAllowed: Boolean = true,
|
||||
): AiturkPairResult<AiturkPairedDevice> =
|
||||
withContext(Dispatchers.IO) {
|
||||
var relayFailure: AiturkPairResult.Failure? = null
|
||||
// The only trust on this client is the offer's fingerprint: on the
|
||||
// LAN and inside the tunnel alike.
|
||||
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint, tunnel)
|
||||
// Validated again here: an offer built by hand must not widen the target.
|
||||
val relayId = offer.relay?.let(AiturkRelay::parse)
|
||||
if (relayId != null) {
|
||||
val relayBase = AiturkRelay.baseUrl(relayId).toHttpUrl()
|
||||
when (val hello = relayHello(pinned, relayBase)) {
|
||||
is Hello.Ok -> {
|
||||
if (!hello.pairingOpen) {
|
||||
return@withContext AiturkPairResult.Failure(
|
||||
AiturkPairError.PAIRING_CLOSED,
|
||||
"relay:${AiturkDiag.PAIRING_CLOSED}",
|
||||
)
|
||||
}
|
||||
return@withContext claim(
|
||||
pinned,
|
||||
relayBase,
|
||||
AiturkRelay.hostPort(relayId),
|
||||
null,
|
||||
offer.fingerprint,
|
||||
normalizedCode,
|
||||
deviceName,
|
||||
)
|
||||
}
|
||||
|
||||
is Hello.Failed -> {
|
||||
relayFailure = AiturkPairResult.Failure(relayError(hello.kind), AiturkDiag.relayCode(hello.kind))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val lan = pairOverLan(pinned, offer, normalizedCode, deviceName, lanAllowed)
|
||||
if (lan is AiturkPairResult.Failure && relayFailure != null && lan.code.startsWith("lan:")) {
|
||||
// Nothing was claimed on the LAN either: the relay is the designed
|
||||
// path, so its failure is what the user needs to hear about.
|
||||
return@withContext AiturkPairResult.Failure(relayFailure.error, "${relayFailure.code},${lan.code}")
|
||||
}
|
||||
lan
|
||||
}
|
||||
|
||||
private suspend fun pairOverLan(
|
||||
pinned: OkHttpClient,
|
||||
offer: AiturkPairingOffer,
|
||||
normalizedCode: String,
|
||||
deviceName: String,
|
||||
lanAllowed: Boolean,
|
||||
): AiturkPairResult<AiturkPairedDevice> {
|
||||
if (offer.endpoints.isEmpty()) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE, "lan:${AiturkDiag.NO_CANDIDATES}")
|
||||
}
|
||||
if (!lanAllowed) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.LAN_PERMISSION, "lan:${AiturkDiag.PERMISSION}")
|
||||
}
|
||||
val reachable =
|
||||
when (val hello = firstReachable(pinned, offer.endpoints)) {
|
||||
is Hello.Ok -> hello
|
||||
is Hello.Failed -> return AiturkPairResult.Failure(lanError(hello.kind), "lan:${hello.kind}")
|
||||
}
|
||||
if (!reachable.pairingOpen) {
|
||||
// The computer says pairing is not open: a claim could only be
|
||||
// answered with 410 pairing_closed, so do not spend an attempt.
|
||||
return AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED, "lan:${AiturkDiag.PAIRING_CLOSED}")
|
||||
}
|
||||
val endpoint = checkNotNull(reachable.endpoint)
|
||||
return claim(
|
||||
pinned,
|
||||
endpoint.baseUrl.toHttpUrl(),
|
||||
endpoint.hostPort,
|
||||
endpoint,
|
||||
offer.fingerprint,
|
||||
normalizedCode,
|
||||
deviceName,
|
||||
)
|
||||
}
|
||||
|
||||
private sealed interface Hello {
|
||||
data class Ok(
|
||||
val endpoint: AiturkEndpoint?,
|
||||
val pairingOpen: Boolean,
|
||||
) : Hello
|
||||
|
||||
/** [kind] is one of the [AiturkDiag] words or `http_<status>`. */
|
||||
data class Failed(
|
||||
val kind: String,
|
||||
) : Hello
|
||||
}
|
||||
|
||||
private suspend fun relayHello(
|
||||
pinned: OkHttpClient,
|
||||
relayBase: HttpUrl,
|
||||
): Hello {
|
||||
val client =
|
||||
pinned
|
||||
.newBuilder()
|
||||
.connectTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(relayHelloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
return withTimeoutOrNull(relayHelloTimeoutMs + HELLO_MARGIN_MS) { hello(client, relayBase, null) }
|
||||
?: Hello.Failed(AiturkDiag.TIMEOUT)
|
||||
}
|
||||
|
||||
private suspend fun firstReachable(
|
||||
pinned: OkHttpClient,
|
||||
endpoints: List<AiturkEndpoint>,
|
||||
): Hello {
|
||||
val helloClient =
|
||||
pinned
|
||||
.newBuilder()
|
||||
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
val failures = CopyOnWriteArrayList<String>()
|
||||
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
|
||||
val winner =
|
||||
withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
|
||||
coroutineScope {
|
||||
val answers = Channel<Hello>(endpoints.size)
|
||||
endpoints.forEach { endpoint ->
|
||||
launch { answers.send(hello(helloClient, endpoint.baseUrl.toHttpUrl(), endpoint)) }
|
||||
}
|
||||
var ok: Hello.Ok? = null
|
||||
var received = 0
|
||||
while (ok == null && received < endpoints.size) {
|
||||
when (val answer = answers.receive()) {
|
||||
is Hello.Ok -> ok = answer
|
||||
is Hello.Failed -> failures += answer.kind
|
||||
}
|
||||
received++
|
||||
}
|
||||
coroutineContext.cancelChildren()
|
||||
ok
|
||||
}
|
||||
}
|
||||
if (winner != null) return winner
|
||||
// Candidates still silent when the window closed count as timeouts.
|
||||
if (failures.size < endpoints.size) failures += AiturkDiag.TIMEOUT
|
||||
return Hello.Failed(mostTelling(failures))
|
||||
}
|
||||
|
||||
private suspend fun hello(
|
||||
client: OkHttpClient,
|
||||
base: HttpUrl,
|
||||
endpoint: AiturkEndpoint?,
|
||||
): Hello =
|
||||
try {
|
||||
val request = Request.Builder().url(under(base, HELLO_PATH)).get().build()
|
||||
client.newCall(request).await().use { response ->
|
||||
val json = readJson(response)
|
||||
when {
|
||||
response.code != 200 -> {
|
||||
Hello.Failed("http_${response.code}")
|
||||
}
|
||||
|
||||
json?.string("app") != IDE_APP_ID -> {
|
||||
Hello.Failed(AiturkDiag.BAD_RESPONSE)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Hello.Ok(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Hello.Failed(if (endpoint == null) AiturkDiag.tunnelKind(e) else AiturkDiag.networkKind(e))
|
||||
}
|
||||
|
||||
private suspend fun claim(
|
||||
client: OkHttpClient,
|
||||
base: HttpUrl,
|
||||
hostPort: String,
|
||||
endpoint: AiturkEndpoint?,
|
||||
fingerprint: String,
|
||||
normalizedCode: String,
|
||||
deviceName: String,
|
||||
): AiturkPairResult<AiturkPairedDevice> {
|
||||
val viaRelay = endpoint == null
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("code", normalizedCode)
|
||||
put("device_name", sanitizeDeviceName(deviceName))
|
||||
}.toString().toRequestBody(JSON)
|
||||
val request = Request.Builder().url(under(base, CLAIM_PATH)).post(body).build()
|
||||
// The code is single-use and wrong attempts are counted: never let the
|
||||
// HTTP stack silently replay this POST.
|
||||
val claimClient =
|
||||
client
|
||||
.newBuilder()
|
||||
.retryOnConnectionFailure(false)
|
||||
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
return try {
|
||||
claimClient.newCall(request).await().use { response ->
|
||||
val json = readJson(response)
|
||||
val errorName = json?.string("error")
|
||||
when {
|
||||
response.code == 200 -> {
|
||||
parseClaim(json, base, hostPort, endpoint, fingerprint)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val error = claimError(response.code, errorName)
|
||||
AiturkPairResult.Failure(error, "claim:${claimCode(error, response.code)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
// The claim may or may not have reached the computer: it is never
|
||||
// retried and never re-sent over the other path.
|
||||
if (viaRelay) {
|
||||
val kind = AiturkDiag.tunnelKind(e)
|
||||
AiturkPairResult.Failure(relayError(kind), "claim:relay_$kind")
|
||||
} else {
|
||||
val kind = AiturkDiag.networkKind(e)
|
||||
AiturkPairResult.Failure(lanError(kind), "claim:lan_$kind")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseClaim(
|
||||
json: JsonObject?,
|
||||
base: HttpUrl,
|
||||
hostPort: String,
|
||||
endpoint: AiturkEndpoint?,
|
||||
fingerprint: String,
|
||||
): AiturkPairResult<AiturkPairedDevice> {
|
||||
val token = json?.string("token")
|
||||
if (token == null || !TOKEN_PATTERN.matches(token)) {
|
||||
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "claim:${AiturkDiag.BAD_RESPONSE}")
|
||||
}
|
||||
return AiturkPairResult.Success(
|
||||
AiturkPairedDevice(
|
||||
baseUrl = base.toString(),
|
||||
hostPort = hostPort,
|
||||
endpoint = endpoint,
|
||||
fingerprint = fingerprint,
|
||||
token = token,
|
||||
deviceId = json.string("device_id").orEmpty(),
|
||||
pcName = json.string("pc_name").orEmpty().trim().take(MAX_NAME_LENGTH),
|
||||
version = json.string("version").orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
|
||||
val bad = AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE, "resolve:${AiturkDiag.BAD_RESPONSE}")
|
||||
val fingerprint = json?.string("fp")
|
||||
if (json == null || fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint)) return bad
|
||||
// The rendezvous record is untrusted input: only private IPv4 targets
|
||||
// (0..4) and, optionally, a lowercase UUIDv4 relay id.
|
||||
val urls =
|
||||
when (val raw = json["urls"]) {
|
||||
null, is JsonNull -> emptyList()
|
||||
is JsonArray -> raw.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||
else -> return bad
|
||||
}
|
||||
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
|
||||
if (endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) return bad
|
||||
val relay =
|
||||
when (val raw = json["relay"]) {
|
||||
null, is JsonNull -> null
|
||||
is JsonPrimitive -> raw.takeIf { it.isString }?.content?.let(AiturkRelay::parse) ?: return bad
|
||||
else -> return bad
|
||||
}
|
||||
if (endpoints.isEmpty() && relay == null) return bad
|
||||
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint, relay))
|
||||
}
|
||||
|
||||
/** [path] under [base], keeping whatever path prefix the base carries (the relay's). */
|
||||
private fun under(
|
||||
base: HttpUrl,
|
||||
path: String,
|
||||
): HttpUrl = base.newBuilder().addPathSegments(path).build()
|
||||
|
||||
private fun readJson(response: Response): JsonObject? =
|
||||
runCatching {
|
||||
val buffer = Buffer()
|
||||
response.body.source().use { source ->
|
||||
while (buffer.size < MAX_BODY_BYTES && source.read(buffer, MAX_BODY_BYTES - buffer.size) != -1L) {
|
||||
// keep reading up to the cap
|
||||
}
|
||||
}
|
||||
Json.parseToJsonElement(buffer.readUtf8()) as? JsonObject
|
||||
}.getOrNull()
|
||||
|
||||
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_GATEWAY = "https://turkservis.online/"
|
||||
const val HELLO_TIMEOUT_MS = 2_500L
|
||||
const val RELAY_HELLO_TIMEOUT_MS = 10_000L
|
||||
const val MAX_NAME_LENGTH = 60
|
||||
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
|
||||
private const val HELLO_PATH = "aiturk/pair/hello"
|
||||
private const val CLAIM_PATH = "aiturk/pair/claim"
|
||||
private const val IDE_APP_ID = "aiturk-ide"
|
||||
private const val HELLO_MARGIN_MS = 500L
|
||||
private const val CLAIM_TIMEOUT_MS = 15_000L
|
||||
private const val GATEWAY_TIMEOUT_MS = 15_000L
|
||||
private const val MAX_BODY_BYTES = 16L * 1024
|
||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
|
||||
|
||||
private val LAN_KIND_PRIORITY =
|
||||
listOf(
|
||||
AiturkDiag.PERMISSION,
|
||||
AiturkDiag.TLS,
|
||||
AiturkDiag.BAD_RESPONSE,
|
||||
AiturkDiag.REFUSED,
|
||||
AiturkDiag.UNREACHABLE,
|
||||
AiturkDiag.TIMEOUT,
|
||||
)
|
||||
|
||||
// A bare client on purpose: no cookie jar, no user-configured custom
|
||||
// headers and no Authorization stamping reach the gateway or `claim`.
|
||||
private val defaultBaseClient: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun sanitizeDeviceName(raw: String): String =
|
||||
raw
|
||||
.filterNot { it.isISOControl() }
|
||||
.trim()
|
||||
.take(MAX_NAME_LENGTH)
|
||||
.ifBlank { "Android" }
|
||||
|
||||
/** Tunnel `hello`/`claim` failure kind → what the user is told (contract v3 §3). */
|
||||
internal fun relayError(kind: String): AiturkPairError =
|
||||
when (kind) {
|
||||
AiturkTunnelException.HOST_OFFLINE -> AiturkPairError.HOST_OFFLINE
|
||||
AiturkTunnelException.UNKNOWN -> AiturkPairError.RELAY_UNKNOWN
|
||||
AiturkTunnelException.BUSY -> AiturkPairError.RELAY_BUSY
|
||||
AiturkTunnelException.TIMEOUT -> AiturkPairError.HOST_TIMEOUT
|
||||
AiturkTunnelException.NETWORK -> AiturkPairError.NO_INTERNET
|
||||
AiturkDiag.PIN -> AiturkPairError.TLS_PIN
|
||||
else -> AiturkPairError.RELAY_ERROR
|
||||
}
|
||||
|
||||
/** LAN failure kind → what the user is told. */
|
||||
internal fun lanError(kind: String): AiturkPairError =
|
||||
if (kind == AiturkDiag.PERMISSION) AiturkPairError.LAN_PERMISSION else AiturkPairError.PC_UNREACHABLE
|
||||
|
||||
/** Of several failed LAN candidates, the one that says most about the cause. */
|
||||
internal fun mostTelling(kinds: List<String>): String =
|
||||
LAN_KIND_PRIORITY.firstOrNull { it in kinds }
|
||||
?: kinds.firstOrNull { it.startsWith("http_") }
|
||||
?: kinds.firstOrNull()
|
||||
?: AiturkDiag.TIMEOUT
|
||||
|
||||
private fun claimCode(
|
||||
error: AiturkPairError,
|
||||
httpCode: Int,
|
||||
): String =
|
||||
when (error) {
|
||||
AiturkPairError.INVALID_REQUEST -> "invalid_request"
|
||||
AiturkPairError.WRONG_CODE -> "wrong_code"
|
||||
AiturkPairError.PAIRING_CLOSED -> "pairing_closed"
|
||||
AiturkPairError.PAIRING_LOCKED -> "pairing_locked"
|
||||
else -> "http_$httpCode"
|
||||
}
|
||||
|
||||
internal fun claimError(
|
||||
httpCode: Int,
|
||||
errorName: String?,
|
||||
): AiturkPairError =
|
||||
when (errorName) {
|
||||
"invalid_request" -> {
|
||||
AiturkPairError.INVALID_REQUEST
|
||||
}
|
||||
|
||||
"wrong_code" -> {
|
||||
AiturkPairError.WRONG_CODE
|
||||
}
|
||||
|
||||
"pairing_closed" -> {
|
||||
AiturkPairError.PAIRING_CLOSED
|
||||
}
|
||||
|
||||
"pairing_locked" -> {
|
||||
AiturkPairError.PAIRING_LOCKED
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (httpCode) {
|
||||
400, 413 -> AiturkPairError.INVALID_REQUEST
|
||||
401 -> AiturkPairError.WRONG_CODE
|
||||
410 -> AiturkPairError.PAIRING_CLOSED
|
||||
else -> AiturkPairError.UNEXPECTED_RESPONSE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
||||
*
|
||||
* QR / deep-link payload:
|
||||
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… max 4]`
|
||||
*
|
||||
* Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE
|
||||
* sends by default; `u` (0..4) is only present when the user allowed direct
|
||||
* same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected.
|
||||
*/
|
||||
data class AiturkEndpoint(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
) {
|
||||
/** Key used by the certificate pin store. */
|
||||
val hostPort: String get() = "$host:$port"
|
||||
|
||||
/** Canonical dashboard base URL (always HTTPS, trailing slash). */
|
||||
val baseUrl: String get() = "https://$host:$port/"
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing offer: where the IDE listens and which certificate it must present
|
||||
* (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null.
|
||||
*/
|
||||
data class AiturkPairingOffer(
|
||||
val endpoints: List<AiturkEndpoint>,
|
||||
val fingerprint: String,
|
||||
val relay: String? = null,
|
||||
)
|
||||
|
||||
data class AiturkPairingPayload(
|
||||
val offer: AiturkPairingOffer,
|
||||
val code: String,
|
||||
)
|
||||
|
||||
enum class AiturkPayloadError {
|
||||
/** Not an `aiturk://pair` link at all. */
|
||||
NOT_AITURK,
|
||||
|
||||
/** `v` is present but not 1: the app is too old for this QR. */
|
||||
UNSUPPORTED_VERSION,
|
||||
|
||||
/** Missing or malformed field (`v`, `u`, `fp`, `c`). */
|
||||
MALFORMED,
|
||||
|
||||
/** `fp` missing or not a 43-char base64url SHA-256. */
|
||||
BAD_FINGERPRINT,
|
||||
|
||||
/** A `u` entry is not `<private IPv4>:<port>`. */
|
||||
BAD_ADDRESS,
|
||||
|
||||
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
|
||||
BAD_ADDRESS_COUNT,
|
||||
|
||||
/** `r` is present but is not a lowercase UUIDv4 relay id. */
|
||||
BAD_RELAY,
|
||||
|
||||
/** `c` is not 8 characters of the pairing alphabet. */
|
||||
BAD_CODE,
|
||||
}
|
||||
|
||||
sealed interface AiturkPayloadResult {
|
||||
data class Ok(
|
||||
val payload: AiturkPairingPayload,
|
||||
) : AiturkPayloadResult
|
||||
|
||||
data class Invalid(
|
||||
val error: AiturkPayloadError,
|
||||
) : AiturkPayloadResult
|
||||
}
|
||||
|
||||
object AiturkPairing {
|
||||
const val SCHEME = "aiturk"
|
||||
const val HOST = "pair"
|
||||
const val VERSION = "1"
|
||||
const val MAX_ENDPOINTS = 4
|
||||
const val CODE_LENGTH = 8
|
||||
const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
const val FINGERPRINT_LENGTH = 43
|
||||
const val LOOKUP_PREFIX = "aiturk-mobile-pair-v1:"
|
||||
|
||||
private val fingerprintPattern = Regex("^[A-Za-z0-9_-]{43}$")
|
||||
private val endpointPattern = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$""")
|
||||
|
||||
/** Quick check used by the deep-link entry point. */
|
||||
fun looksLikePairingLink(raw: String?): Boolean =
|
||||
raw != null && raw.trim().startsWith("$SCHEME://$HOST", ignoreCase = true)
|
||||
|
||||
fun parse(raw: String): AiturkPayloadResult {
|
||||
val uri =
|
||||
runCatching { URI(raw.trim()) }.getOrNull()
|
||||
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||
if (!SCHEME.equals(uri.scheme, ignoreCase = true) || !HOST.equals(uri.host, ignoreCase = true)) {
|
||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||
}
|
||||
val params =
|
||||
runCatching { parseQuery(uri.rawQuery.orEmpty()) }.getOrNull()
|
||||
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||
|
||||
fun single(name: String): String? = params.filter { it.first == name }.map { it.second }.singleOrNull()
|
||||
|
||||
// Version first: a newer QR may carry fields this build cannot judge.
|
||||
val version = single("v") ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||
if (version != VERSION) return AiturkPayloadResult.Invalid(AiturkPayloadError.UNSUPPORTED_VERSION)
|
||||
|
||||
val fingerprint = single("fp")
|
||||
if (fingerprint == null || !isValidFingerprint(fingerprint)) {
|
||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
|
||||
}
|
||||
|
||||
// Relay first: a present-but-wrong `r` rejects the whole payload, it is
|
||||
// never silently dropped in favour of the LAN candidates.
|
||||
val rawRelays = params.filter { it.first == "r" }.map { it.second }
|
||||
val relay =
|
||||
when (rawRelays.size) {
|
||||
0 -> null
|
||||
1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||
else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY)
|
||||
}
|
||||
|
||||
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
|
||||
if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) {
|
||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
|
||||
}
|
||||
val endpoints =
|
||||
rawEndpoints.map {
|
||||
parseEndpoint(it) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS)
|
||||
}
|
||||
|
||||
val code =
|
||||
single("c")?.let(::normalizeCode)
|
||||
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_CODE)
|
||||
|
||||
return AiturkPayloadResult.Ok(
|
||||
AiturkPairingPayload(
|
||||
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
|
||||
code = code,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper-cases (locale-independent), strips whitespace and `-`, and returns the
|
||||
* code only when it is exactly 8 characters of [CODE_ALPHABET]; null otherwise.
|
||||
*/
|
||||
fun normalizeCode(raw: String): String? {
|
||||
val stripped = raw.uppercase(Locale.ROOT).filterNot { it.isWhitespace() || it == '-' }
|
||||
return stripped.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
|
||||
}
|
||||
|
||||
/** Keeps only alphabet characters (upper-cased), capped at 8 — for the text field. */
|
||||
fun cleanCodeInput(raw: String): String =
|
||||
raw
|
||||
.uppercase(Locale.ROOT)
|
||||
.filter { it in CODE_ALPHABET }
|
||||
.take(CODE_LENGTH)
|
||||
|
||||
/** `hex(SHA-256("aiturk-mobile-pair-v1:" + CODE))` — the raw code never leaves the phone. */
|
||||
fun lookup(normalizedCode: String): String {
|
||||
val digest =
|
||||
MessageDigest
|
||||
.getInstance("SHA-256")
|
||||
.digest((LOOKUP_PREFIX + normalizedCode).toByteArray(Charsets.US_ASCII))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun isValidFingerprint(value: String): Boolean = fingerprintPattern.matches(value) && decodeFingerprint(value) != null
|
||||
|
||||
/** The 32 SHA-256 bytes behind a base64url (unpadded) fingerprint, or null. */
|
||||
fun decodeFingerprint(value: String): ByteArray? =
|
||||
runCatching { Base64.getUrlDecoder().decode(value) }
|
||||
.getOrNull()
|
||||
?.takeIf { it.size == 32 }
|
||||
|
||||
/** `<private IPv4>:<port>`; no DNS names, no leading zeros, no public ranges. */
|
||||
fun parseEndpoint(raw: String): AiturkEndpoint? {
|
||||
val match = endpointPattern.matchEntire(raw) ?: return null
|
||||
val parts = match.groupValues.drop(1)
|
||||
if (parts.any { it.length > 1 && it.startsWith('0') }) return null
|
||||
val octets = parts.take(4).map { it.toInt() }
|
||||
if (octets.any { it > 255 }) return null
|
||||
val port = parts[4].toInt()
|
||||
if (port !in 1..65535) return null
|
||||
val (a, b) = octets
|
||||
val isPrivate = a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168)
|
||||
if (!isPrivate) return null
|
||||
return AiturkEndpoint(octets.joinToString("."), port)
|
||||
}
|
||||
|
||||
/** Gateway `resolve` URLs are `https://<private IPv4>:<port>` (optional trailing slash). */
|
||||
fun parseEndpointUrl(raw: String): AiturkEndpoint? {
|
||||
if (!raw.startsWith("https://")) return null
|
||||
return parseEndpoint(raw.removePrefix("https://").removeSuffix("/"))
|
||||
}
|
||||
|
||||
private fun parseQuery(rawQuery: String): List<Pair<String, String>> =
|
||||
rawQuery
|
||||
.split('&')
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { part ->
|
||||
val index = part.indexOf('=')
|
||||
require(index > 0) { "Query parameter without a value" }
|
||||
decode(part.substring(0, index)) to decode(part.substring(index + 1))
|
||||
}
|
||||
|
||||
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
||||
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
|
||||
}
|
||||
|
||||
/**
|
||||
* The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id
|
||||
* (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host
|
||||
* `<relayId>.relay.aiturk.invalid` — a name that never resolves on the
|
||||
* internet; [AiturkTunnel] turns connections to it into an end-to-end
|
||||
* encrypted stream through the relay.
|
||||
*/
|
||||
object AiturkRelay {
|
||||
const val HOST_SUFFIX = ".relay.aiturk.invalid"
|
||||
const val PORT = 443
|
||||
|
||||
private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
private val relayId = Regex(UUID4)
|
||||
|
||||
/**
|
||||
* Untrusted input (QR `r`, gateway `relay`): returns the value only when it
|
||||
* is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or
|
||||
* surrounding whitespace.
|
||||
*/
|
||||
fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) }
|
||||
|
||||
fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX
|
||||
|
||||
/** Key used by the certificate pin store for a relay pairing. */
|
||||
fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT"
|
||||
|
||||
/** The saved connection base: no path prefix, trailing slash. */
|
||||
fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/"
|
||||
|
||||
/** The relay id behind a virtual host name, or null for any other host. */
|
||||
fun relayIdOfHost(host: String?): String? =
|
||||
host
|
||||
?.takeIf { it.endsWith(HOST_SUFFIX) }
|
||||
?.removeSuffix(HOST_SUFFIX)
|
||||
?.let(::parse)
|
||||
|
||||
fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.ProxySelector
|
||||
import java.net.Socket
|
||||
import java.net.SocketAddress
|
||||
import java.net.SocketException
|
||||
import java.net.SocketImpl
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.SocketFactory
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* End-to-end encrypted tunnel to a paired AITURK IDE through turkservis.online
|
||||
* (contract v3, `aiturk-mobil-tunel-spec`).
|
||||
*
|
||||
* The relay only carries bytes. A paired relay connection has the virtual base
|
||||
* address `https://<relayId>.relay.aiturk.invalid` (no path prefix). For such a
|
||||
* host — and for no other — the shared OkHttp client:
|
||||
* - resolves the name to a fixed placeholder address ([AiturkTunnelDns]);
|
||||
* - opens `wss://turkservis.online/v1/aiturk/mobile-relay/t/<relayId>` instead
|
||||
* of a TCP connection and exposes it as a socket ([AiturkTunnelSocket]);
|
||||
* - runs TLS ON TOP of that socket, pinned to the IDE's certificate
|
||||
* fingerprint ([AiturkLanTrust]). turkservis.online and anything in front of
|
||||
* it see ciphertext only: no device token, path, chat text or file.
|
||||
*/
|
||||
object AiturkTunnel {
|
||||
const val DEFAULT_RELAY_WS = "wss://turkservis.online/v1/aiturk/mobile-relay/t/"
|
||||
const val MAX_FRAME_BYTES = 64 * 1024
|
||||
|
||||
/** How the byte stream is opened. Replaced by JVM tests with a local fake relay. */
|
||||
@Volatile
|
||||
internal var connector: AiturkTunnelConnector = WebSocketTunnelConnector()
|
||||
|
||||
/**
|
||||
* The single hook for an OkHttp builder. Every host that is not a virtual
|
||||
* relay host keeps the platform's DNS, plain TCP sockets and proxy choice.
|
||||
*/
|
||||
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder = applyTo(builder) { connector }
|
||||
|
||||
internal fun applyTo(
|
||||
builder: OkHttpClient.Builder,
|
||||
connector: () -> AiturkTunnelConnector,
|
||||
): OkHttpClient.Builder =
|
||||
builder
|
||||
.dns(AiturkTunnelDns())
|
||||
.socketFactory(AiturkTunnelSocketFactory(connector))
|
||||
.proxySelector(AiturkTunnelProxySelector())
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a tunnel stream could not be opened or ended. [kind] is one of the fixed
|
||||
* words below (it becomes the `relay:<kind>` diagnostic code).
|
||||
*/
|
||||
class AiturkTunnelException(
|
||||
val kind: String,
|
||||
val closeCode: Int? = null,
|
||||
cause: Throwable? = null,
|
||||
) : IOException("AITURK tunnel: $kind", cause) {
|
||||
companion object {
|
||||
const val HOST_OFFLINE = "host_offline"
|
||||
const val UNKNOWN = "unknown"
|
||||
const val BUSY = "busy"
|
||||
const val TIMEOUT = "timeout"
|
||||
const val OVERFLOW = "overflow"
|
||||
const val NETWORK = "network"
|
||||
const val REJECTED = "rejected"
|
||||
const val CLOSED = "closed"
|
||||
|
||||
/** Relay close code → kind; null for a normal end of stream. */
|
||||
fun kindForCloseCode(code: Int): String? =
|
||||
when (code) {
|
||||
1000 -> null
|
||||
4503 -> HOST_OFFLINE
|
||||
4404 -> UNKNOWN
|
||||
4429 -> BUSY
|
||||
4504 -> TIMEOUT
|
||||
4408 -> OVERFLOW
|
||||
4400, 4403 -> REJECTED
|
||||
else -> CLOSED
|
||||
}
|
||||
|
||||
fun find(error: Throwable?): AiturkTunnelException? =
|
||||
generateSequence(error) { it.cause.takeIf { cause -> cause !== it } }
|
||||
.take(MAX_CAUSE_DEPTH)
|
||||
.filterIsInstance<AiturkTunnelException>()
|
||||
.firstOrNull()
|
||||
|
||||
private const val MAX_CAUSE_DEPTH = 8
|
||||
}
|
||||
}
|
||||
|
||||
/** An open byte stream to the IDE's TLS proxy. Blocking, like a socket. */
|
||||
interface AiturkTunnelChannel {
|
||||
val input: InputStream
|
||||
val output: OutputStream
|
||||
var soTimeoutMs: Int
|
||||
val isClosed: Boolean
|
||||
|
||||
fun close()
|
||||
}
|
||||
|
||||
fun interface AiturkTunnelConnector {
|
||||
/** Blocks until the stream is open; throws [AiturkTunnelException] otherwise. */
|
||||
@Throws(IOException::class)
|
||||
fun open(
|
||||
relayId: String,
|
||||
timeoutMs: Int,
|
||||
): AiturkTunnelChannel
|
||||
}
|
||||
|
||||
/** `*.relay.aiturk.invalid` → placeholder address (hostname kept); everything else → [delegate]. */
|
||||
class AiturkTunnelDns(
|
||||
private val delegate: Dns = Dns.SYSTEM,
|
||||
) : Dns {
|
||||
override fun lookup(hostname: String): List<InetAddress> =
|
||||
if (AiturkRelay.isVirtualHost(hostname)) {
|
||||
listOf(InetAddress.getByAddress(hostname, PLACEHOLDER))
|
||||
} else {
|
||||
delegate.lookup(hostname)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// TEST-NET-1 (RFC 5737): never routed, and never dialled by the tunnel socket.
|
||||
internal val PLACEHOLDER = byteArrayOf(192.toByte(), 0, 2, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Virtual relay hosts are never sent through a proxy; other hosts use the system selector. */
|
||||
class AiturkTunnelProxySelector : ProxySelector() {
|
||||
override fun select(uri: URI?): List<Proxy> {
|
||||
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return listOf(Proxy.NO_PROXY)
|
||||
return runCatching { ProxySelector.getDefault()?.select(uri) }.getOrNull()?.takeIf { it.isNotEmpty() } ?: listOf(Proxy.NO_PROXY)
|
||||
}
|
||||
|
||||
override fun connectFailed(
|
||||
uri: URI?,
|
||||
sa: SocketAddress?,
|
||||
ioe: IOException?,
|
||||
) {
|
||||
if (uri != null && AiturkRelay.isVirtualHost(uri.host)) return
|
||||
runCatching { ProxySelector.getDefault()?.connectFailed(uri, sa, ioe) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OkHttp asks for an unconnected socket and only then names the peer, so the
|
||||
* socket decides in `connect()`: a virtual relay host becomes a tunnel stream,
|
||||
* any other address is an ordinary TCP connection made by [java.net.Socket]
|
||||
* itself.
|
||||
*/
|
||||
class AiturkTunnelSocketFactory internal constructor(
|
||||
private val connector: () -> AiturkTunnelConnector,
|
||||
) : SocketFactory() {
|
||||
override fun createSocket(): Socket = AiturkTunnelSocket(connector)
|
||||
|
||||
override fun createSocket(
|
||||
host: String?,
|
||||
port: Int,
|
||||
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
host: String?,
|
||||
port: Int,
|
||||
localHost: InetAddress?,
|
||||
localPort: Int,
|
||||
): Socket = createSocket(host, port)
|
||||
|
||||
override fun createSocket(
|
||||
host: InetAddress?,
|
||||
port: Int,
|
||||
): Socket = createSocket().apply { connect(InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
address: InetAddress?,
|
||||
port: Int,
|
||||
localAddress: InetAddress?,
|
||||
localPort: Int,
|
||||
): Socket = createSocket(address, port)
|
||||
}
|
||||
|
||||
/** A plain TCP socket unless it is connected to a virtual relay host. */
|
||||
class AiturkTunnelSocket internal constructor(
|
||||
private val connector: () -> AiturkTunnelConnector,
|
||||
) : Socket() {
|
||||
@Volatile
|
||||
internal var channel: AiturkTunnelChannel? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
internal var tunnelPeer: InetSocketAddress? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
private var requestedSoTimeout = 0
|
||||
|
||||
override fun connect(endpoint: SocketAddress?) = connect(endpoint, 0)
|
||||
|
||||
override fun connect(
|
||||
endpoint: SocketAddress?,
|
||||
timeout: Int,
|
||||
) {
|
||||
val peer = endpoint as? InetSocketAddress
|
||||
val relayId = peer?.hostString?.let(AiturkRelay::relayIdOfHost)
|
||||
if (peer == null || relayId == null) {
|
||||
super.connect(endpoint, timeout)
|
||||
return
|
||||
}
|
||||
if (channel != null) throw SocketException("Already connected")
|
||||
val opened = connector().open(relayId, if (timeout > 0) timeout else DEFAULT_CONNECT_TIMEOUT_MS)
|
||||
opened.soTimeoutMs = requestedSoTimeout
|
||||
tunnelPeer = peer
|
||||
channel = opened
|
||||
}
|
||||
|
||||
override fun getInputStream(): InputStream = channel?.input ?: super.getInputStream()
|
||||
|
||||
override fun getOutputStream(): OutputStream = channel?.output ?: super.getOutputStream()
|
||||
|
||||
override fun setSoTimeout(timeout: Int) {
|
||||
requestedSoTimeout = timeout
|
||||
val tunnel = channel
|
||||
if (tunnel != null) tunnel.soTimeoutMs = timeout else super.setSoTimeout(timeout)
|
||||
}
|
||||
|
||||
override fun getSoTimeout(): Int = channel?.soTimeoutMs ?: super.getSoTimeout()
|
||||
|
||||
override fun setTcpNoDelay(on: Boolean) {
|
||||
if (channel == null) super.setTcpNoDelay(on)
|
||||
}
|
||||
|
||||
override fun setKeepAlive(on: Boolean) {
|
||||
if (channel == null) super.setKeepAlive(on)
|
||||
}
|
||||
|
||||
override fun isConnected(): Boolean = channel != null || super.isConnected()
|
||||
|
||||
override fun isClosed(): Boolean = channel?.isClosed ?: super.isClosed()
|
||||
|
||||
override fun isInputShutdown(): Boolean = if (channel != null) false else super.isInputShutdown()
|
||||
|
||||
override fun isOutputShutdown(): Boolean = if (channel != null) false else super.isOutputShutdown()
|
||||
|
||||
override fun getInetAddress(): InetAddress? = tunnelPeer?.address ?: super.getInetAddress()
|
||||
|
||||
override fun getPort(): Int = tunnelPeer?.port ?: super.getPort()
|
||||
|
||||
override fun getRemoteSocketAddress(): SocketAddress? = tunnelPeer ?: super.getRemoteSocketAddress()
|
||||
|
||||
override fun shutdownInput() {
|
||||
if (channel == null) super.shutdownInput()
|
||||
}
|
||||
|
||||
override fun shutdownOutput() {
|
||||
if (channel == null) super.shutdownOutput()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
channel?.close()
|
||||
super.close()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The view of a tunnel that is handed to the TLS layer: a socket WITHOUT a
|
||||
* platform socket implementation (and so without a file descriptor). Android's
|
||||
* TLS provider then takes its stream-based path and reads/writes through
|
||||
* [getInputStream] / [getOutputStream] instead of a native descriptor.
|
||||
*/
|
||||
internal class AiturkTunnelStreamSocket(
|
||||
private val owner: AiturkTunnelSocket,
|
||||
) : Socket(null as SocketImpl?) {
|
||||
private val tunnel: AiturkTunnelChannel get() = owner.channel ?: throw SocketException("Socket is not connected")
|
||||
|
||||
override fun getInputStream(): InputStream = tunnel.input
|
||||
|
||||
override fun getOutputStream(): OutputStream = tunnel.output
|
||||
|
||||
override fun setSoTimeout(timeout: Int) = owner.setSoTimeout(timeout)
|
||||
|
||||
override fun getSoTimeout(): Int = owner.soTimeout
|
||||
|
||||
override fun isConnected(): Boolean = true
|
||||
|
||||
override fun isBound(): Boolean = true
|
||||
|
||||
override fun isClosed(): Boolean = owner.isClosed
|
||||
|
||||
override fun isInputShutdown(): Boolean = false
|
||||
|
||||
override fun isOutputShutdown(): Boolean = false
|
||||
|
||||
override fun getInetAddress(): InetAddress? = owner.inetAddress
|
||||
|
||||
override fun getPort(): Int = owner.port
|
||||
|
||||
override fun getRemoteSocketAddress(): SocketAddress? = owner.remoteSocketAddress
|
||||
|
||||
override fun getLocalAddress(): InetAddress = InetAddress.getByAddress(byteArrayOf(0, 0, 0, 0))
|
||||
|
||||
override fun getLocalPort(): Int = 0
|
||||
|
||||
override fun getLocalSocketAddress(): SocketAddress = InetSocketAddress(localAddress, 0)
|
||||
|
||||
override fun setTcpNoDelay(on: Boolean) = Unit
|
||||
|
||||
override fun getTcpNoDelay(): Boolean = true
|
||||
|
||||
override fun setKeepAlive(on: Boolean) = Unit
|
||||
|
||||
override fun getKeepAlive(): Boolean = false
|
||||
|
||||
override fun setSoLinger(
|
||||
on: Boolean,
|
||||
linger: Int,
|
||||
) = Unit
|
||||
|
||||
override fun getSoLinger(): Int = -1
|
||||
|
||||
override fun setSendBufferSize(size: Int) = Unit
|
||||
|
||||
override fun getSendBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
|
||||
|
||||
override fun setReceiveBufferSize(size: Int) = Unit
|
||||
|
||||
override fun getReceiveBufferSize(): Int = AiturkTunnel.MAX_FRAME_BYTES
|
||||
|
||||
override fun shutdownInput() = Unit
|
||||
|
||||
override fun shutdownOutput() = Unit
|
||||
|
||||
override fun close() = owner.close()
|
||||
|
||||
override fun toString(): String = "AiturkTunnelStreamSocket"
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a TLS socket factory so that TLS over a tunnel is layered on the
|
||||
* descriptor-less [AiturkTunnelStreamSocket]. Every other socket is passed to
|
||||
* [delegate] untouched.
|
||||
*/
|
||||
internal class AiturkTunnelSslSocketFactory(
|
||||
private val delegate: SSLSocketFactory,
|
||||
) : SSLSocketFactory() {
|
||||
override fun createSocket(
|
||||
s: Socket?,
|
||||
host: String?,
|
||||
port: Int,
|
||||
autoClose: Boolean,
|
||||
): Socket =
|
||||
if (s is AiturkTunnelSocket && s.channel != null) {
|
||||
// Closing the TLS socket must always close the tunnel stream.
|
||||
delegate.createSocket(AiturkTunnelStreamSocket(s), host, port, true)
|
||||
} else {
|
||||
delegate.createSocket(s, host, port, autoClose)
|
||||
}
|
||||
|
||||
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
|
||||
|
||||
override fun getSupportedCipherSuites(): Array<String> = delegate.supportedCipherSuites
|
||||
|
||||
override fun createSocket(): Socket = delegate.createSocket()
|
||||
|
||||
override fun createSocket(
|
||||
host: String?,
|
||||
port: Int,
|
||||
): Socket = delegate.createSocket(host, port)
|
||||
|
||||
override fun createSocket(
|
||||
host: String?,
|
||||
port: Int,
|
||||
localHost: InetAddress?,
|
||||
localPort: Int,
|
||||
): Socket = delegate.createSocket(host, port, localHost, localPort)
|
||||
|
||||
override fun createSocket(
|
||||
host: InetAddress?,
|
||||
port: Int,
|
||||
): Socket = delegate.createSocket(host, port)
|
||||
|
||||
override fun createSocket(
|
||||
address: InetAddress?,
|
||||
port: Int,
|
||||
localAddress: InetAddress?,
|
||||
localPort: Int,
|
||||
): Socket = delegate.createSocket(address, port, localAddress, localPort)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the stream as a WebSocket to the relay with a separate, bare OkHttp
|
||||
* client: platform TLS, no cookie jar, no custom headers, no `Origin`.
|
||||
* Binary frames are raw bytes in both directions.
|
||||
*/
|
||||
class WebSocketTunnelConnector(
|
||||
private val relayWsBase: String = AiturkTunnel.DEFAULT_RELAY_WS,
|
||||
client: OkHttpClient? = null,
|
||||
) : AiturkTunnelConnector {
|
||||
private val client: OkHttpClient by lazy {
|
||||
client ?: OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.pingInterval(PING_SECONDS, TimeUnit.SECONDS)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun open(
|
||||
relayId: String,
|
||||
timeoutMs: Int,
|
||||
): AiturkTunnelChannel {
|
||||
// Only a validated relay id is ever placed in the URL.
|
||||
val id = AiturkRelay.parse(relayId) ?: throw AiturkTunnelException(AiturkTunnelException.UNKNOWN)
|
||||
val channel = WebSocketTunnelChannel()
|
||||
val socket = client.newWebSocket(Request.Builder().url(relayWsBase + id).build(), channel.listener)
|
||||
channel.attach(socket)
|
||||
val opened =
|
||||
try {
|
||||
channel.awaitOpen(timeoutMs.toLong())
|
||||
} catch (e: InterruptedException) {
|
||||
socket.cancel()
|
||||
Thread.currentThread().interrupt()
|
||||
throw AiturkTunnelException(AiturkTunnelException.NETWORK, cause = e)
|
||||
}
|
||||
if (!opened) {
|
||||
socket.cancel()
|
||||
throw channel.failure ?: AiturkTunnelException(AiturkTunnelException.NETWORK)
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PING_SECONDS = 25L
|
||||
}
|
||||
}
|
||||
|
||||
internal class WebSocketTunnelChannel : AiturkTunnelChannel {
|
||||
private val incoming = LinkedBlockingQueue<ByteString>()
|
||||
private val openLatch = CountDownLatch(1)
|
||||
private val flow = Object()
|
||||
|
||||
@Volatile
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var isOpen = false
|
||||
|
||||
@Volatile
|
||||
private var ended = false
|
||||
|
||||
@Volatile
|
||||
private var closedLocally = false
|
||||
|
||||
@Volatile
|
||||
internal var failure: AiturkTunnelException? = null
|
||||
private set
|
||||
|
||||
private var queuedBytes = 0L
|
||||
|
||||
@Volatile
|
||||
override var soTimeoutMs: Int = 0
|
||||
|
||||
override val isClosed: Boolean get() = closedLocally
|
||||
|
||||
fun attach(webSocket: WebSocket) {
|
||||
socket = webSocket
|
||||
}
|
||||
|
||||
/** True when the relay accepted the stream; false when it failed or timed out. */
|
||||
fun awaitOpen(timeoutMs: Long): Boolean = openLatch.await(timeoutMs, TimeUnit.MILLISECONDS) && isOpen
|
||||
|
||||
val listener: WebSocketListener =
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
isOpen = true
|
||||
openLatch.countDown()
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
bytes: ByteString,
|
||||
) {
|
||||
if (bytes.size == 0) return
|
||||
synchronized(flow) {
|
||||
// Applies back-pressure to the relay connection instead of
|
||||
// buffering without bound when the reader is slow.
|
||||
while (queuedBytes > MAX_BUFFERED_BYTES && !ended && !closedLocally) flow.wait(FLOW_WAIT_MS)
|
||||
queuedBytes += bytes.size
|
||||
}
|
||||
incoming.put(bytes)
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
// The stream is binary only.
|
||||
end(AiturkTunnelException(AiturkTunnelException.REJECTED))
|
||||
webSocket.cancel()
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
|
||||
webSocket.close(NORMAL_CLOSE, null)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) = end(AiturkTunnelException.kindForCloseCode(code)?.let { AiturkTunnelException(it, code) })
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
// An HTTP answer instead of the upgrade means the relay refused
|
||||
// the stream; anything else is the network.
|
||||
val kind = if (response != null) AiturkTunnelException.REJECTED else AiturkTunnelException.NETWORK
|
||||
end(AiturkTunnelException(kind, cause = t))
|
||||
}
|
||||
}
|
||||
|
||||
private fun end(error: AiturkTunnelException?) {
|
||||
synchronized(flow) {
|
||||
if (ended) return
|
||||
if (error != null && !closedLocally) failure = error
|
||||
ended = true
|
||||
flow.notifyAll()
|
||||
}
|
||||
incoming.offer(END)
|
||||
openLatch.countDown()
|
||||
}
|
||||
|
||||
override val input: InputStream =
|
||||
object : InputStream() {
|
||||
private var current: ByteString? = null
|
||||
private var position = 0
|
||||
|
||||
override fun read(): Int {
|
||||
val one = ByteArray(1)
|
||||
return if (read(one, 0, 1) == -1) -1 else one[0].toInt() and BYTE_MASK
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int {
|
||||
if (len == 0) return 0
|
||||
var chunk = current
|
||||
if (chunk == null) {
|
||||
chunk = next() ?: return -1
|
||||
current = chunk
|
||||
position = 0
|
||||
}
|
||||
val count = minOf(len, chunk.size - position)
|
||||
chunk.copyInto(position, b, off, count)
|
||||
position += count
|
||||
if (position == chunk.size) current = null
|
||||
synchronized(flow) {
|
||||
queuedBytes -= count
|
||||
flow.notifyAll()
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
override fun available(): Int = current?.let { it.size - position } ?: 0
|
||||
|
||||
override fun close() = this@WebSocketTunnelChannel.close()
|
||||
|
||||
/** The next chunk, or null at a clean end of stream. */
|
||||
private fun next(): ByteString? {
|
||||
if (closedLocally) throw SocketException("Socket closed")
|
||||
val timeout = soTimeoutMs
|
||||
val chunk =
|
||||
try {
|
||||
if (timeout > 0) incoming.poll(timeout.toLong(), TimeUnit.MILLISECONDS) else incoming.take()
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw java.io.InterruptedIOException("interrupted")
|
||||
} ?: throw SocketTimeoutException("Read timed out")
|
||||
if (chunk !== END) return chunk
|
||||
incoming.offer(END)
|
||||
if (closedLocally) throw SocketException("Socket closed")
|
||||
failure?.let { throw AiturkTunnelException(it.kind, it.closeCode, it) }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override val output: OutputStream =
|
||||
object : OutputStream() {
|
||||
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
|
||||
|
||||
override fun write(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
) {
|
||||
var offset = off
|
||||
var remaining = len
|
||||
while (remaining > 0) {
|
||||
val count = minOf(remaining, AiturkTunnel.MAX_FRAME_BYTES)
|
||||
send(b.toByteString(offset, count))
|
||||
offset += count
|
||||
remaining -= count
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() = this@WebSocketTunnelChannel.close()
|
||||
}
|
||||
|
||||
private fun send(frame: ByteString) {
|
||||
val webSocket = socket ?: throw SocketException("Socket is not connected")
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(WRITE_STALL_MS)
|
||||
// Keep the outbound queue small: the relay drops a stream whose
|
||||
// pending data grows past its limit.
|
||||
while (webSocket.queueSize() > MAX_PENDING_WRITE_BYTES) {
|
||||
checkWritable()
|
||||
if (System.nanoTime() > deadline) throw SocketTimeoutException("Write timed out")
|
||||
try {
|
||||
Thread.sleep(WRITE_POLL_MS)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
throw java.io.InterruptedIOException("interrupted")
|
||||
}
|
||||
}
|
||||
checkWritable()
|
||||
if (!webSocket.send(frame)) {
|
||||
checkWritable()
|
||||
throw AiturkTunnelException(AiturkTunnelException.CLOSED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkWritable() {
|
||||
if (closedLocally) throw SocketException("Socket closed")
|
||||
if (ended) throw failure?.let { AiturkTunnelException(it.kind, it.closeCode, it) } ?: SocketException("Connection closed by peer")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(flow) {
|
||||
if (closedLocally) return
|
||||
closedLocally = true
|
||||
flow.notifyAll()
|
||||
}
|
||||
val webSocket = socket
|
||||
if (webSocket != null && !webSocket.close(NORMAL_CLOSE, null)) webSocket.cancel()
|
||||
incoming.offer(END)
|
||||
openLatch.countDown()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val END: ByteString = ByteString.of(0)
|
||||
const val NORMAL_CLOSE = 1000
|
||||
const val BYTE_MASK = 0xFF
|
||||
const val MAX_BUFFERED_BYTES = 1L * 1024 * 1024
|
||||
const val MAX_PENDING_WRITE_BYTES = 256L * 1024
|
||||
const val FLOW_WAIT_MS = 50L
|
||||
const val WRITE_POLL_MS = 2L
|
||||
const val WRITE_STALL_MS = 30_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the tunnel last said about the computer behind a paired connection, for
|
||||
* the chat's connection banner: "reconnecting…" alone does not tell the user
|
||||
* that AITURK IDE is closed.
|
||||
*/
|
||||
object AiturkRelayStatus {
|
||||
private val _hostOffline = MutableStateFlow(false)
|
||||
val hostOffline: StateFlow<Boolean> = _hostOffline.asStateFlow()
|
||||
|
||||
/** [error] is what ended (or prevented) the app's WebSocket; null clears the note. */
|
||||
fun onSocketEnded(error: Throwable?) {
|
||||
_hostOffline.value = AiturkTunnelException.find(error)?.kind == AiturkTunnelException.HOST_OFFLINE
|
||||
}
|
||||
|
||||
fun onSocketOpened() {
|
||||
_hostOffline.value = false
|
||||
AiturkCompat.onSocketOpened()
|
||||
}
|
||||
}
|
||||
@@ -799,8 +799,10 @@ object AuthManager {
|
||||
val raw = serverStore.getLatestState().wsAuthParam
|
||||
val authParam = if (raw.isBlank()) "token" else raw
|
||||
return endpointForBuild().webSocketUrl(
|
||||
path = "api/ws",
|
||||
authParameter = authParam,
|
||||
credential = getToken().orEmpty(),
|
||||
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageProvenance
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
import com.m57.hermescontrol.ui.chat.normalizedGatewayNotice
|
||||
|
||||
/**
|
||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||
@@ -44,7 +45,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
messageProvenance =
|
||||
MessageProvenance.entries.firstOrNull { it.name == messageProvenance }
|
||||
?: MessageProvenance.UNKNOWN,
|
||||
)
|
||||
).normalizedGatewayNotice()
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
ChatMessageEntity(
|
||||
|
||||
@@ -76,7 +76,7 @@ data class MessagingPlatformUpdateResponse(
|
||||
|
||||
@Serializable
|
||||
data class TelegramOnboardingStartRequest(
|
||||
@SerialName("bot_name") val botName: String = "Hermes Agent",
|
||||
@SerialName("bot_name") val botName: String = "AITURK",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.model
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
@@ -100,20 +101,10 @@ data class SessionMessage(
|
||||
}
|
||||
|
||||
val contentText: String
|
||||
get() =
|
||||
when (content) {
|
||||
is JsonPrimitive -> content.content
|
||||
null -> ""
|
||||
else -> content.toString()
|
||||
}
|
||||
get() = content?.transcriptText().orEmpty()
|
||||
|
||||
val displayContentText: String?
|
||||
get() =
|
||||
when (display_content) {
|
||||
is JsonPrimitive -> display_content.content
|
||||
null -> null
|
||||
else -> display_content.toString()
|
||||
}
|
||||
get() = display_content?.transcriptText()
|
||||
|
||||
val reasoningText: String
|
||||
get() =
|
||||
@@ -160,4 +151,25 @@ data class SessionMessage(
|
||||
get() = tool_call_id.orEmpty()
|
||||
}
|
||||
|
||||
// #1432: model-facing multipart payloads contain inline image bytes, not displayable JSON.
|
||||
private fun JsonElement.transcriptText(): String =
|
||||
when (this) {
|
||||
is JsonPrimitive -> {
|
||||
content
|
||||
}
|
||||
|
||||
is JsonArray -> {
|
||||
mapNotNull { part ->
|
||||
val block = part as? JsonObject ?: return@mapNotNull null
|
||||
val type = block["type"]
|
||||
if (type != null && (type as? JsonPrimitive)?.content != "text") return@mapNotNull null
|
||||
(block["text"] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
else -> {
|
||||
toString()
|
||||
}
|
||||
}
|
||||
|
||||
private val WHITESPACE = Regex("\\s+")
|
||||
|
||||
@@ -195,6 +195,7 @@ object ApiClient {
|
||||
.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.addInterceptor(CatalogTimeoutInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Cold provider discovery can exceed the ordinary 30-second screen timeout. */
|
||||
object CatalogTimeoutInterceptor : Interceptor {
|
||||
const val TIMEOUT_MS = 120_000L
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val catalog = request.method == "GET" && request.url.encodedPath.endsWith("/api/model/options")
|
||||
val next = if (catalog) chain.withReadTimeout(TIMEOUT_MS.toInt(), TimeUnit.MILLISECONDS) else chain
|
||||
return next.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
|
||||
@Synchronized
|
||||
fun refresh(): String? =
|
||||
try {
|
||||
if (isAiturkPaired()) return null
|
||||
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||
AuthManager.setToken(token)
|
||||
token
|
||||
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
|
||||
suspend fun refreshAsync(): String? =
|
||||
refreshMutex.withLock {
|
||||
try {
|
||||
if (isAiturkPaired()) return@withLock null
|
||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
||||
AuthManager.setToken(token)
|
||||
token
|
||||
@@ -37,6 +40,11 @@ internal object DashboardSessionTokenRefresher {
|
||||
}
|
||||
}
|
||||
|
||||
// AITURK: a paired IDE issues a long-lived device token and its LAN proxy
|
||||
// answers 404 on `/` (the dashboard page must never leave the computer), so
|
||||
// there is nothing to refresh and the stored token must stay as it is.
|
||||
private fun isAiturkPaired(): Boolean = AiturkLanTrust.isPairedUrl(AuthManager.baseUrl())
|
||||
|
||||
internal suspend fun fetch(
|
||||
baseUrl: String,
|
||||
client: OkHttpClient,
|
||||
|
||||
@@ -47,6 +47,10 @@ fun mapHttpError(
|
||||
if (code == 401) {
|
||||
return NetworkError.AuthExpired()
|
||||
}
|
||||
// AITURK: the IDE's phone proxy refusing a route, or the IDE not having it, gets a plain sentence.
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
.httpErrorMessage(code, errorBody)
|
||||
?.let { return NetworkError.Http(code, it) }
|
||||
// Try to parse FastAPI-style error detail from response body
|
||||
val detail =
|
||||
if (errorBody != null) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -39,6 +40,9 @@ object OkHttpProvider {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
// AITURK: a paired IDE's host:port is verified by certificate
|
||||
// fingerprint; every other address keeps the platform TLS checks.
|
||||
.let(AiturkLanTrust::applyTo)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,10 @@ object UpdateNoticeManager {
|
||||
* disabled in debug builds (BuildConfig.DEBUG) so daily dev builds never
|
||||
* hit the GitHub API or nag about updates. Tests flip this to true.
|
||||
*/
|
||||
var enabled: Boolean = !BuildConfig.DEBUG
|
||||
// AITURK: off in every build. The upstream checker installs Hy4ri/hermes-mobile
|
||||
// release APKs (different package id and signing key); AITURK Mobil builds are
|
||||
// published at https://turkservis.online/indir.
|
||||
var enabled: Boolean = false
|
||||
internal set
|
||||
|
||||
/** Avoid duplicate foreground checks in one process; a cold launch always rechecks. */
|
||||
|
||||
@@ -279,7 +279,7 @@ object EventParser {
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
WsEvent.StatusUpdate(status, payload, sessionId)
|
||||
}
|
||||
|
||||
"error" -> {
|
||||
|
||||
@@ -179,7 +179,8 @@ class KanbanEventsClient {
|
||||
path = KANBAN_EVENTS_PATH,
|
||||
authParameter = authParam,
|
||||
credential = credential,
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board) +
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(AuthManager.activeProfileId.value),
|
||||
)
|
||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CatalogTimeoutInterceptor
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
},
|
||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params, timeoutMs = CatalogTimeoutInterceptor.TIMEOUT_MS)
|
||||
},
|
||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
||||
emitEvent(finalEvent)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Disconnect/profile switch cancels replay; it is not a
|
||||
// remote failure and must not request a stale transcript.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
onReplayFailure(sid, e)
|
||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -110,6 +111,11 @@ internal class RpcChannel(
|
||||
suppressErrorEvent: Boolean = false,
|
||||
): CompletableDeferred<Any?> {
|
||||
val deferred = CompletableDeferred<Any?>()
|
||||
// AITURK: a method the paired IDE already answered "unknown method" for is not sent again.
|
||||
if (AiturkCompat.isKnownMissing(method)) {
|
||||
deferred.completeExceptionally(AiturkCompat.missingMethodError())
|
||||
return deferred
|
||||
}
|
||||
val id =
|
||||
sendRequest(method, params) { reqId ->
|
||||
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||
@@ -191,7 +197,7 @@ internal class RpcChannel(
|
||||
if (error != null) {
|
||||
call.deferred.completeExceptionally(
|
||||
HermesWsClient.HermesRpcException(
|
||||
message = error.message,
|
||||
message = AiturkCompat.rpcExceptionMessage(error.code, error.message),
|
||||
code = error.code,
|
||||
data = error.data,
|
||||
),
|
||||
@@ -332,7 +338,12 @@ internal class RpcChannel(
|
||||
|
||||
is WsEvent.RpcError -> {
|
||||
onError(event.id)
|
||||
if (pendingCalls[event.id]?.suppressErrorEvent == true) {
|
||||
// AITURK: an awaited call the paired IDE does not know fails through its
|
||||
// deferred only; the raw "unknown method" never reaches the chat banner.
|
||||
val pending = pendingCalls[event.id]
|
||||
AiturkCompat.noteRpcError(pending?.method, event.error.code)
|
||||
val quiet = pending != null && AiturkCompat.quietRpcError(event.error.code)
|
||||
if (quiet || pending?.suppressErrorEvent == true) {
|
||||
// Opt-in suppression: the caller already handles the
|
||||
// failure through the CompletableDeferred, so the event
|
||||
// copy would only surface a duplicate UI banner for
|
||||
|
||||
@@ -205,6 +205,7 @@ sealed class WsEvent {
|
||||
data class StatusUpdate(
|
||||
val status: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
data class SessionUpdated(
|
||||
|
||||
@@ -659,6 +659,8 @@ internal class WsTransport(
|
||||
outboundDrainJob?.cancel()
|
||||
outboundDrainJob = null
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||
.onSocketOpened()
|
||||
connected.set(true)
|
||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||
reconnectPolicy.reset()
|
||||
@@ -730,6 +732,8 @@ internal class WsTransport(
|
||||
// Do NOT log [reason] — it may carry server-side context. The
|
||||
// reason is still inspected internally to detect auth failures.
|
||||
Log.i(TAG, "WebSocket closed: $code")
|
||||
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||
.onSocketEnded(null)
|
||||
connected.set(false)
|
||||
ActiveSessionHolder.clear()
|
||||
connectionHealth.stop()
|
||||
@@ -764,6 +768,10 @@ internal class WsTransport(
|
||||
connectionHealth.stop()
|
||||
rpcChannel.clearCapabilityRequests()
|
||||
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 ||
|
||||
t.message?.contains("401") == true ||
|
||||
t.message?.contains("4401") == true ||
|
||||
|
||||
@@ -11,9 +11,21 @@ data class SessionCreateParams(
|
||||
@SerialName("hidden") val hidden: Boolean? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* The `source` this client declares for every session it mints or resumes.
|
||||
*
|
||||
* Mobile presents the desktop surface, so it claims `"desktop"` on BOTH `session.create` and
|
||||
* `session.resume`. The gateway resolves an omitted `source` from the host environment
|
||||
* (`_resolve_session_source`), which on a headless backend is `"tui"`; a resumed runtime then
|
||||
* reads as a surface switch and loses the `desktop_ui` toolset (#1450). One constant so the
|
||||
* create and resume call sites cannot drift apart again.
|
||||
*/
|
||||
const val DESKTOP_SESSION_SOURCE = "desktop"
|
||||
|
||||
@Serializable
|
||||
data class SessionResumeParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("source") val source: String? = null,
|
||||
@SerialName("omit_messages") val omitMessages: Boolean? = null,
|
||||
@SerialName("profile") val profile: String? = null,
|
||||
)
|
||||
|
||||
@@ -726,6 +726,11 @@ object NotificationHelper {
|
||||
) {
|
||||
ChatNotificationService.setAppForeground(foreground)
|
||||
HermesWsClient.setAppForeground(foreground)
|
||||
// AITURK: reopen the socket that was closed while idle in the background.
|
||||
if (foreground) {
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
.onAppForeground()
|
||||
}
|
||||
if (!foreground) {
|
||||
BackgroundConnectionController.default.reconcileState()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
@@ -61,7 +62,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
NotificationCompat
|
||||
.Builder(context, ChatNotificationService.CHAT_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("Hermes")
|
||||
.setContentTitle("AITURK")
|
||||
.setContentText("Replied")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
@@ -157,6 +158,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = storedSessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||
import com.m57.hermescontrol.FilesScreen
|
||||
import com.m57.hermescontrol.KeysScreen
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.SettingsVault
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
|
||||
/**
|
||||
* Screens a paired AITURK IDE cannot serve, hidden from the drawer and the
|
||||
* settings list while that pairing is the active connection:
|
||||
*
|
||||
* - Account connectors, Credential vault: the IDE's agent has no
|
||||
* `connectors.*` / `vault.*` methods;
|
||||
* - Keys, Files: the IDE's phone proxy refuses `/api/env` and `/api/files`
|
||||
* on purpose (secrets and the computer's files stay on the computer).
|
||||
*
|
||||
* A plain Hermes dashboard connection shows everything, as upstream does.
|
||||
*/
|
||||
object AiturkFeatureGate {
|
||||
val HIDDEN_WHEN_LINKED: Set<NavKey> = setOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)
|
||||
|
||||
fun shows(
|
||||
key: NavKey,
|
||||
linked: Boolean = AiturkCompat.isLinked(),
|
||||
): Boolean = !(linked && key in HIDDEN_WHEN_LINKED)
|
||||
}
|
||||
|
||||
/** Shown instead of a hidden screen that is still reached (restored back stack, shortcut). */
|
||||
@Composable
|
||||
fun AiturkUnavailableScreen(
|
||||
@StringRes titleRes: Int,
|
||||
onOpenDrawer: (() -> Unit)?,
|
||||
) {
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(titleRes)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
) { padding ->
|
||||
EmptyState(
|
||||
title = stringResource(R.string.aiturk_feature_unavailable),
|
||||
subtitle = stringResource(R.string.aiturk_feature_unavailable_hint),
|
||||
icon = Icons.Filled.Computer,
|
||||
modifier = Modifier.padding(padding).testTag("aiturk_unavailable"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.m57.hermescontrol.AiturkPairScreen
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Hands an `aiturk://pair?...` VIEW intent to the pairing screen. The link is
|
||||
* only parked here; the screen asks the user to confirm before connecting,
|
||||
* because any app or web page can fire this intent.
|
||||
*/
|
||||
object AiturkPairDeepLink {
|
||||
private val _pending = MutableStateFlow<String?>(null)
|
||||
val pending: StateFlow<String?> = _pending.asStateFlow()
|
||||
|
||||
/** @return true when [intent] was an AITURK pairing link. */
|
||||
fun offer(intent: Intent?): Boolean {
|
||||
if (intent?.action != Intent.ACTION_VIEW) return false
|
||||
val raw = intent.dataString
|
||||
if (!AiturkPairing.looksLikePairingLink(raw)) return false
|
||||
// Consume it so a recreated activity does not replay the same link.
|
||||
intent.data = null
|
||||
_pending.value = raw
|
||||
return true
|
||||
}
|
||||
|
||||
fun take(): String? = _pending.value.also { _pending.value = null }
|
||||
}
|
||||
|
||||
/** Opens the pairing screen whenever a deep link is waiting. */
|
||||
@Composable
|
||||
fun AiturkPairDeepLinkEffect() {
|
||||
val pending by AiturkPairDeepLink.pending.collectAsState()
|
||||
LaunchedEffect(pending) {
|
||||
if (pending != null) NavigationController.navigateTo(AiturkPairScreen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Computer
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
||||
|
||||
/** Any RFC1918 address: pairing targets are always private IPv4, so the answer is the same. */
|
||||
private const val LAN_PROBE_URL = "https://192.168.0.1/"
|
||||
private const val CODE_GROUP = 4
|
||||
|
||||
@Composable
|
||||
fun AiturkPairScreen(
|
||||
onConnected: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AiturkPairViewModel = viewModel { AiturkPairViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scanPrompt = stringResource(R.string.aiturk_pair_scan_prompt)
|
||||
val scanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
viewModel.onQrScanned(result.contents, lanPermissionGranted(context))
|
||||
}
|
||||
val launchScanner = {
|
||||
scanLauncher.launch(
|
||||
ScanOptions()
|
||||
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
.setCaptureActivity(AiturkQrCaptureActivity::class.java)
|
||||
.setPrompt(scanPrompt)
|
||||
.setBeepEnabled(false)
|
||||
.setBarcodeImageEnabled(false)
|
||||
.setOrientationLocked(true),
|
||||
)
|
||||
}
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) launchScanner() else viewModel.onCameraPermissionDenied()
|
||||
}
|
||||
val lanPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
viewModel.onLanPermissionResult(granted)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.lanPermissionNeeded) {
|
||||
if (state.lanPermissionNeeded) lanPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
// The connection is already saved; the dialog only shows the security code
|
||||
// (the same 8 characters AITURK IDE shows under the QR code) before leaving.
|
||||
if (state.connectionSuccess) {
|
||||
val securityCode = state.securityCode
|
||||
if (securityCode == null) {
|
||||
LaunchedEffect(Unit) { onConnected() }
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = onConnected,
|
||||
title = { Text(stringResource(R.string.aiturk_pair_security_title)) },
|
||||
text = { Text(stringResource(R.string.aiturk_pair_security_message, securityCode)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConnected) {
|
||||
Text(stringResource(R.string.aiturk_pair_security_ok))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
val deepLink by AiturkPairDeepLink.pending.collectAsState()
|
||||
LaunchedEffect(deepLink) {
|
||||
if (deepLink != null) AiturkPairDeepLink.take()?.let(viewModel::onDeepLink)
|
||||
}
|
||||
// The ViewModel is Activity-scoped: drop one-shot flags when the screen leaves.
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { viewModel.clearTransientState() }
|
||||
}
|
||||
|
||||
state.confirmAddress?.let { address ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissDeepLink,
|
||||
title = { Text(stringResource(R.string.aiturk_pair_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.aiturk_pair_confirm_message, address)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.confirmDeepLink(lanPermissionGranted(context)) }) {
|
||||
Text(stringResource(R.string.aiturk_pair_connect))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::dismissDeepLink) {
|
||||
Text(stringResource(R.string.aiturk_pair_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Computer,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_title),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (hasPermission(context, Manifest.permission.CAMERA)) {
|
||||
launchScanner()
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.aiturk_pair_scan_qr), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_or),
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
HorizontalDivider(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
label = { Text(stringResource(R.string.aiturk_pair_code_label)) },
|
||||
placeholder = { Text(stringResource(R.string.aiturk_pair_code_placeholder)) },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
isError = state.errorRes != null,
|
||||
visualTransformation = PairingCodeTransformation,
|
||||
textStyle =
|
||||
MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
autoCorrectEnabled = false,
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { viewModel.submitCode(lanPermissionGranted(context)) }),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.submitCode(lanPermissionGranted(context)) },
|
||||
enabled = !state.busy && state.code.length == AiturkPairing.CODE_LENGTH,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.aiturk_pair_connect), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
if (state.busy) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text =
|
||||
stringResource(
|
||||
if (state.step == AiturkPairStep.RESOLVING) {
|
||||
R.string.aiturk_pair_progress_resolve
|
||||
} else {
|
||||
R.string.aiturk_pair_progress_connect
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.errorRes?.let { res ->
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
// Diagnostic code for support: fixed words only, never a token, code or address.
|
||||
if (state.errorCode.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_pair_error_code, state.errorCode),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||
Text(stringResource(R.string.aiturk_pair_back))
|
||||
}
|
||||
|
||||
// So the user can tell which build is installed when support asks.
|
||||
Text(
|
||||
text = stringResource(R.string.aiturk_app_version, AiturkCompat.versionLabel()),
|
||||
modifier = Modifier.testTag("aiturk_app_version"),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermission(
|
||||
context: Context,
|
||||
permission: String,
|
||||
): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Android 17+ gates LAN hosts behind ACCESS_LOCAL_NETWORK; older releases need nothing. */
|
||||
private fun lanPermissionGranted(context: Context): Boolean =
|
||||
!requiresLocalNetworkPermission(Build.VERSION.SDK_INT, LAN_PROBE_URL) ||
|
||||
hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
|
||||
/** Shows `ABCDEFGH` as `ABCD-EFGH` without storing the dash. */
|
||||
private object PairingCodeTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val raw = text.text
|
||||
val shown = if (raw.length > CODE_GROUP) raw.substring(0, CODE_GROUP) + "-" + raw.substring(CODE_GROUP) else raw
|
||||
val mapping =
|
||||
object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int = if (offset > CODE_GROUP) offset + 1 else offset
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int = if (offset > CODE_GROUP) offset - 1 else offset
|
||||
}
|
||||
return TransformedText(AnnotatedString(shown), mapping)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkConnectionRegistrar
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkDiag
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairResult
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPairingPayload
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkPayloadResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class AiturkPairStep { IDLE, RESOLVING, CONNECTING }
|
||||
|
||||
data class AiturkPairUiState(
|
||||
val code: String = "",
|
||||
val step: AiturkPairStep = AiturkPairStep.IDLE,
|
||||
@param:StringRes val errorRes: Int? = null,
|
||||
/** Short diagnostic (`relay:host_offline`, `lan:timeout` …) shown in small print under the error. */
|
||||
val errorCode: String = "",
|
||||
/** Address of a deep-link pairing waiting for the user's confirmation. */
|
||||
val confirmAddress: String? = null,
|
||||
val lanPermissionNeeded: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
/** First 8 characters of the pinned certificate fingerprint; the IDE shows the same. */
|
||||
val securityCode: String? = null,
|
||||
) {
|
||||
val busy: Boolean get() = step != AiturkPairStep.IDLE
|
||||
}
|
||||
|
||||
class AiturkPairViewModel(
|
||||
private val client: AiturkPairingClient = AiturkPairingClient(),
|
||||
private val register: suspend (AiturkPairedDevice) -> Unit = AiturkConnectionRegistrar::register,
|
||||
private val deviceName: String = defaultDeviceName(),
|
||||
) : ViewModel() {
|
||||
private sealed interface Request {
|
||||
data class Code(
|
||||
val code: String,
|
||||
) : Request
|
||||
|
||||
data class Payload(
|
||||
val payload: AiturkPairingPayload,
|
||||
) : Request
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(AiturkPairUiState())
|
||||
val uiState: StateFlow<AiturkPairUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var pending: AiturkPairingPayload? = null
|
||||
private var confirmPayload: AiturkPairingPayload? = null
|
||||
|
||||
fun onCodeChange(raw: String) {
|
||||
_uiState.update { it.copy(code = AiturkPairing.cleanCodeInput(raw), errorRes = null, errorCode = "") }
|
||||
}
|
||||
|
||||
fun submitCode(lanPermissionGranted: Boolean) {
|
||||
val code = AiturkPairing.normalizeCode(_uiState.value.code)
|
||||
if (code == null) {
|
||||
showError(R.string.aiturk_pair_error_code_invalid)
|
||||
return
|
||||
}
|
||||
start(Request.Code(code), lanPermissionGranted)
|
||||
}
|
||||
|
||||
/** [raw] is null when the user backed out of the scanner. */
|
||||
fun onQrScanned(
|
||||
raw: String?,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (raw == null) return
|
||||
when (val result = AiturkPairing.parse(raw)) {
|
||||
is AiturkPayloadResult.Ok -> start(Request.Payload(result.payload), lanPermissionGranted)
|
||||
is AiturkPayloadResult.Invalid -> showError(payloadErrorRes(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
/** A link from outside the app is never acted on without an explicit confirmation. */
|
||||
fun onDeepLink(raw: String) {
|
||||
if (_uiState.value.busy) return
|
||||
when (val result = AiturkPairing.parse(raw)) {
|
||||
is AiturkPayloadResult.Ok -> {
|
||||
confirmPayload = result.payload
|
||||
// A relay-only link (the default) has no LAN address to show.
|
||||
val address =
|
||||
result.payload.offer.endpoints
|
||||
.firstOrNull()
|
||||
?.hostPort ?: RELAY_DISPLAY_NAME
|
||||
_uiState.update { it.copy(confirmAddress = address, errorRes = null, errorCode = "") }
|
||||
}
|
||||
|
||||
is AiturkPayloadResult.Invalid -> {
|
||||
showError(payloadErrorRes(result.error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun confirmDeepLink(lanPermissionGranted: Boolean) {
|
||||
val payload = confirmPayload ?: return
|
||||
dismissDeepLink()
|
||||
start(Request.Payload(payload), lanPermissionGranted)
|
||||
}
|
||||
|
||||
fun dismissDeepLink() {
|
||||
confirmPayload = null
|
||||
_uiState.update { it.copy(confirmAddress = null) }
|
||||
}
|
||||
|
||||
fun onCameraPermissionDenied() = showError(R.string.aiturk_pair_error_camera_denied)
|
||||
|
||||
fun onLanPermissionResult(granted: Boolean) {
|
||||
_uiState.update { it.copy(lanPermissionNeeded = false) }
|
||||
val payload = pending
|
||||
pending = null
|
||||
// A denied permission no longer stops the pairing: the tunnel needs no
|
||||
// local-network access. Only the LAN candidates are skipped, and if
|
||||
// nothing else works the result says so (`lan:permission`).
|
||||
if (payload != null) run(payload, lanAllowed = granted)
|
||||
}
|
||||
|
||||
/** The ViewModel outlives the screen; reset what must not replay on re-entry. */
|
||||
fun clearTransientState() {
|
||||
pending = null
|
||||
confirmPayload = null
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorRes = null,
|
||||
errorCode = "",
|
||||
confirmAddress = null,
|
||||
lanPermissionNeeded = false,
|
||||
connectionSuccess = false,
|
||||
securityCode = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun start(
|
||||
request: Request,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
when (request) {
|
||||
is Request.Payload -> {
|
||||
connect(request.payload, lanPermissionGranted)
|
||||
}
|
||||
|
||||
is Request.Code -> {
|
||||
_uiState.update { it.copy(step = AiturkPairStep.RESOLVING, errorRes = null, errorCode = "") }
|
||||
viewModelScope.launch {
|
||||
val resolved =
|
||||
try {
|
||||
client.resolve(request.code)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||
}
|
||||
_uiState.update { it.copy(step = AiturkPairStep.IDLE) }
|
||||
when (resolved) {
|
||||
is AiturkPairResult.Success -> {
|
||||
connect(AiturkPairingPayload(resolved.value, request.code), lanPermissionGranted)
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
showFailure(resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local-network permission is asked for only when a LAN candidate may
|
||||
* actually be tried. A pairing that carries only a relay id (the default)
|
||||
* goes through the tunnel and never asks.
|
||||
*/
|
||||
private fun connect(
|
||||
payload: AiturkPairingPayload,
|
||||
lanPermissionGranted: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
if (!lanPermissionGranted && payload.offer.endpoints.isNotEmpty()) {
|
||||
pending = payload
|
||||
_uiState.update { it.copy(lanPermissionNeeded = true, errorRes = null, errorCode = "") }
|
||||
return
|
||||
}
|
||||
run(payload, lanAllowed = lanPermissionGranted)
|
||||
}
|
||||
|
||||
private fun run(
|
||||
payload: AiturkPairingPayload,
|
||||
lanAllowed: Boolean,
|
||||
) {
|
||||
if (_uiState.value.busy) return
|
||||
_uiState.update { it.copy(step = AiturkPairStep.CONNECTING, errorRes = null, errorCode = "") }
|
||||
viewModelScope.launch {
|
||||
val outcome =
|
||||
try {
|
||||
client.pair(payload.offer, payload.code, deviceName, lanAllowed)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE, "app:exception")
|
||||
}
|
||||
when (outcome) {
|
||||
is AiturkPairResult.Success -> {
|
||||
val saved = runCatching { register(outcome.value) }.isSuccess
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AiturkPairStep.IDLE,
|
||||
code = "",
|
||||
connectionSuccess = saved,
|
||||
securityCode = if (saved) securityCode(outcome.value.fingerprint) else null,
|
||||
errorRes = if (saved) null else R.string.aiturk_pair_error_save_failed,
|
||||
errorCode = if (saved) "" else "app:save_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is AiturkPairResult.Failure -> {
|
||||
showFailure(outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFailure(failure: AiturkPairResult.Failure) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
step = AiturkPairStep.IDLE,
|
||||
errorRes = pairErrorRes(failure.error),
|
||||
errorCode = AiturkDiag.sanitize(failure.code),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(
|
||||
@StringRes res: Int,
|
||||
) {
|
||||
_uiState.update { it.copy(errorRes = res, errorCode = "") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SECURITY_CODE_LENGTH = 8
|
||||
private const val RELAY_DISPLAY_NAME = "turkservis.online"
|
||||
|
||||
/** What both screens show so the user can compare them. */
|
||||
fun securityCode(fingerprint: String): String = fingerprint.take(SECURITY_CODE_LENGTH)
|
||||
|
||||
@StringRes
|
||||
fun payloadErrorRes(error: AiturkPayloadError): Int =
|
||||
when (error) {
|
||||
AiturkPayloadError.NOT_AITURK -> R.string.aiturk_pair_error_qr_not_aiturk
|
||||
|
||||
AiturkPayloadError.UNSUPPORTED_VERSION -> R.string.aiturk_pair_error_update_app
|
||||
|
||||
AiturkPayloadError.MALFORMED,
|
||||
AiturkPayloadError.BAD_FINGERPRINT,
|
||||
AiturkPayloadError.BAD_ADDRESS,
|
||||
AiturkPayloadError.BAD_ADDRESS_COUNT,
|
||||
AiturkPayloadError.BAD_RELAY,
|
||||
AiturkPayloadError.BAD_CODE,
|
||||
-> R.string.aiturk_pair_error_qr_invalid
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun pairErrorRes(error: AiturkPairError): Int =
|
||||
when (error) {
|
||||
AiturkPairError.CODE_NOT_FOUND -> R.string.aiturk_pair_error_code_not_found
|
||||
|
||||
AiturkPairError.RATE_LIMITED -> R.string.aiturk_pair_error_rate_limited
|
||||
|
||||
AiturkPairError.GATEWAY_UNAVAILABLE -> R.string.aiturk_pair_error_gateway
|
||||
|
||||
AiturkPairError.PC_UNREACHABLE -> R.string.aiturk_pair_error_pc_unreachable
|
||||
|
||||
AiturkPairError.HOST_OFFLINE -> R.string.aiturk_pair_error_host_offline
|
||||
|
||||
AiturkPairError.HOST_TIMEOUT -> R.string.aiturk_pair_error_host_timeout
|
||||
|
||||
AiturkPairError.RELAY_BUSY -> R.string.aiturk_pair_error_relay_busy
|
||||
|
||||
AiturkPairError.RELAY_UNKNOWN -> R.string.aiturk_pair_error_relay_unknown
|
||||
|
||||
AiturkPairError.TLS_PIN -> R.string.aiturk_pair_error_tls_pin
|
||||
|
||||
AiturkPairError.NO_INTERNET -> R.string.aiturk_pair_error_no_internet
|
||||
|
||||
AiturkPairError.RELAY_ERROR -> R.string.aiturk_pair_error_relay
|
||||
|
||||
AiturkPairError.LAN_PERMISSION -> R.string.aiturk_pair_error_lan_permission
|
||||
|
||||
AiturkPairError.WRONG_CODE -> R.string.aiturk_pair_error_wrong_code
|
||||
|
||||
AiturkPairError.PAIRING_CLOSED -> R.string.aiturk_pair_error_pairing_closed
|
||||
|
||||
AiturkPairError.PAIRING_LOCKED -> R.string.aiturk_pair_error_pairing_locked
|
||||
|
||||
AiturkPairError.INVALID_REQUEST,
|
||||
AiturkPairError.UNEXPECTED_RESPONSE,
|
||||
-> R.string.aiturk_pair_error_unexpected
|
||||
}
|
||||
|
||||
private fun defaultDeviceName(): String {
|
||||
val maker = android.os.Build.MANUFACTURER.orEmpty()
|
||||
val model = android.os.Build.MODEL.orEmpty()
|
||||
val name = if (model.startsWith(maker, ignoreCase = true)) model else "$maker $model"
|
||||
return AiturkPairingClient.sanitizeDeviceName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.m57.hermescontrol.ui.aiturkpair
|
||||
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
/**
|
||||
* The ZXing capture screen under our own manifest entry, so it follows the
|
||||
* device orientation instead of the library default (landscape only).
|
||||
*/
|
||||
class AiturkQrCaptureActivity : CaptureActivity()
|
||||
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.ProfilesConfigureParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProfilesListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
@@ -564,7 +565,7 @@ class GroupChatViewModel(
|
||||
method = RpcMethods.SESSION_CREATE,
|
||||
params =
|
||||
SessionCreateParams(
|
||||
source = "desktop",
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
profile = bot.name,
|
||||
title = title,
|
||||
hidden = true,
|
||||
|
||||
@@ -322,7 +322,7 @@ class ChannelsViewModel :
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.startTelegramOnboarding(
|
||||
TelegramOnboardingStartRequest(botName = "Hermes Agent"),
|
||||
TelegramOnboardingStartRequest(botName = "AITURK"),
|
||||
)
|
||||
}
|
||||
_uiState.update {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/** Captured before asynchronous clipboard access; never retarget a late image to a new chat. */
|
||||
internal data class ChatAttachmentTarget(
|
||||
val sessionId: String,
|
||||
val generation: Long,
|
||||
val baseUrl: String,
|
||||
val connectionProfileId: String?,
|
||||
val agentProfileId: String?,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||
|
||||
/**
|
||||
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
|
||||
@@ -56,7 +57,7 @@ fun toolCallMilestones(messages: List<ChatMessage>): Map<String, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[message.id] = toolCount
|
||||
@@ -79,7 +80,7 @@ fun toolCallMilestoneIndices(messages: List<ChatMessage>): Map<Int, Int> {
|
||||
toolCount = 0
|
||||
}
|
||||
|
||||
message.role == MessageRole.TOOL -> {
|
||||
message.role == MessageRole.TOOL && !message.isHiddenTool() -> {
|
||||
toolCount += 1
|
||||
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
|
||||
milestones[index] = toolCount
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
|
||||
private const val PASTED_IMAGE_DIRECTORY = "pasted_images"
|
||||
private const val PASTED_IMAGE_MAX_AGE_MS = 7L * 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Startup-only sweep (#1444). Keep recent files for transcript previews/retries;
|
||||
* never run during a live session where an old attachment may still be uploading.
|
||||
*/
|
||||
internal fun cleanStalePastedImages(
|
||||
cacheDir: File,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
val directory = File(cacheDir, PASTED_IMAGE_DIRECTORY)
|
||||
val files = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||
val cutoff = nowMs - PASTED_IMAGE_MAX_AGE_MS
|
||||
for (file in files) {
|
||||
runCatching {
|
||||
// Do not traverse subdirectories or touch any other cache namespace.
|
||||
if (file.isFile && file.lastModified() < cutoff) file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PastedImageTooLargeException : IOException("Pasted image exceeds attachment limit")
|
||||
|
||||
/** A private cache file owned by the caller after [ChatPastedImageStore.importImage] returns. */
|
||||
class PastedImage(
|
||||
val attachment: Attachment,
|
||||
val file: File,
|
||||
) {
|
||||
/** Call if the import cannot be queued (for example, after a session switch). */
|
||||
fun discard() {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit clipboard/IME image importer; it never observes the clipboard itself. */
|
||||
class ChatPastedImageStore(
|
||||
private val context: Context,
|
||||
) {
|
||||
/** Returns null for unsupported, missing, or empty input; oversized streams fail explicitly. */
|
||||
suspend fun importImage(
|
||||
uri: Uri,
|
||||
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||
): PastedImage? {
|
||||
var pending: PastedImage? = null
|
||||
var delivered = false
|
||||
try {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (uri.scheme != "content") return@withContext null
|
||||
val resolver = context.contentResolver
|
||||
val mimeType =
|
||||
resolver
|
||||
.getType(uri)
|
||||
?.takeIf { it.startsWith("image/", ignoreCase = true) && it.length > "image/".length }
|
||||
?: return@withContext null
|
||||
val extension =
|
||||
MimeTypeMap
|
||||
.getSingleton()
|
||||
.getExtensionFromMimeType(mimeType)
|
||||
?.takeIf { it.matches(Regex("[A-Za-z0-9]{1,10}")) }
|
||||
?: "img"
|
||||
val input = resolver.openInputStream(uri) ?: return@withContext null
|
||||
val activeContext = currentCoroutineContext()
|
||||
val file =
|
||||
stagePastedImage(
|
||||
input = input,
|
||||
directory = File(context.cacheDir, PASTED_IMAGE_DIRECTORY),
|
||||
extension = extension,
|
||||
maxBytes = maxBytes,
|
||||
checkActive = { activeContext.ensureActive() },
|
||||
) ?: return@withContext null
|
||||
try {
|
||||
activeContext.ensureActive()
|
||||
val privateUri =
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file,
|
||||
)
|
||||
PastedImage(
|
||||
attachment =
|
||||
Attachment(
|
||||
uri = privateUri.toString(),
|
||||
name = file.name,
|
||||
mimeType = mimeType,
|
||||
size = file.length(),
|
||||
),
|
||||
file = file,
|
||||
).also { pending = it }
|
||||
} catch (failure: Throwable) {
|
||||
file.delete()
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
delivered = true
|
||||
return result
|
||||
} finally {
|
||||
// withContext may discard its completed IO result if the parent was cancelled
|
||||
// during dispatch back; in that case ownership never reached the caller.
|
||||
if (!delivered) pending?.discard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM-testable, exclusive-create bounded copy. Null means empty input. */
|
||||
internal fun stagePastedImage(
|
||||
input: InputStream,
|
||||
directory: File,
|
||||
extension: String,
|
||||
maxBytes: Long = MAX_CHAT_ATTACHMENT_BYTES,
|
||||
checkActive: () -> Unit = {},
|
||||
newId: () -> String = { UUID.randomUUID().toString() },
|
||||
): File? {
|
||||
require(maxBytes > 0) { "maxBytes must be positive" }
|
||||
val effectiveMaxBytes = minOf(maxBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||
require(extension.matches(Regex("[A-Za-z0-9]{1,10}"))) { "Invalid extension" }
|
||||
var owned: File? = null
|
||||
var keep = false
|
||||
try {
|
||||
return input
|
||||
.use source@{ source ->
|
||||
if (!directory.isDirectory && !directory.mkdirs() && !directory.isDirectory) {
|
||||
throw IOException("Cannot create pasted image cache directory")
|
||||
}
|
||||
val file = File(directory, "${UUID.fromString(newId())}.$extension")
|
||||
if (!file.createNewFile()) throw IOException("Pasted image cache name already exists")
|
||||
owned = file
|
||||
var total = 0L
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
FileOutputStream(file).use { output ->
|
||||
while (true) {
|
||||
checkActive()
|
||||
// Probe one byte beyond the limit without trusting provider-reported size.
|
||||
val remaining = effectiveMaxBytes - total
|
||||
val requested = if (remaining < buffer.size) (remaining + 1).toInt() else buffer.size
|
||||
val count = source.read(buffer, 0, requested)
|
||||
if (count < 0) break
|
||||
if (count > remaining) throw PastedImageTooLargeException()
|
||||
if (count == 0) {
|
||||
val byte = source.read()
|
||||
if (byte < 0) break
|
||||
if (remaining == 0L) throw PastedImageTooLargeException()
|
||||
output.write(byte)
|
||||
total++
|
||||
continue
|
||||
}
|
||||
output.write(buffer, 0, count)
|
||||
total += count
|
||||
}
|
||||
checkActive()
|
||||
}
|
||||
if (total == 0L) null else file
|
||||
}.also { keep = it != null }
|
||||
} finally {
|
||||
if (!keep) owned?.delete()
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -70,7 +72,6 @@ import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -78,6 +79,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.HistoryScreen
|
||||
@@ -93,6 +95,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
||||
@@ -118,9 +121,12 @@ import com.m57.hermescontrol.ui.chat.components.SpeechRequest
|
||||
import com.m57.hermescontrol.ui.chat.components.SpeechText
|
||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatImagePaste
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatMediaLaunchers
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatSpeech
|
||||
import com.m57.hermescontrol.ui.chat.components.replaceComposerDraft
|
||||
import com.m57.hermescontrol.ui.chat.components.restoreRejectedComposerDraft
|
||||
import com.m57.hermescontrol.ui.chat.components.shouldShowProgressChip
|
||||
import com.m57.hermescontrol.ui.chat.components.tailContentKey
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList
|
||||
@@ -338,20 +344,18 @@ fun ChatScreen(
|
||||
!timelineState.isHistorical && scrollController.showFab(displayedMessages.isNotEmpty())
|
||||
}
|
||||
}
|
||||
var inputFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(""))
|
||||
}
|
||||
val inputState = rememberTextFieldState()
|
||||
|
||||
LaunchedEffect(state.pendingPrefillText) {
|
||||
val prefill = state.pendingPrefillText
|
||||
if (prefill != null) {
|
||||
inputFieldValue = ChatInputPolicy.commandFieldValue(prefill)
|
||||
inputState.replaceComposerDraft(prefill)
|
||||
viewModel.consumePendingPrefill()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.composerTextToRestore) {
|
||||
state.composerTextToRestore?.let { text ->
|
||||
inputFieldValue = ChatInputPolicy.restoreRejectedText(text, inputFieldValue)
|
||||
inputState.restoreRejectedComposerDraft(text)
|
||||
viewModel.consumeComposerTextRestore()
|
||||
}
|
||||
}
|
||||
@@ -364,6 +368,10 @@ fun ChatScreen(
|
||||
}
|
||||
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val imagePaste =
|
||||
rememberChatImagePaste(viewModel) { message ->
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(message) }
|
||||
}
|
||||
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
|
||||
ExternalActivityLifecycleGuard.launchExternalActivity(
|
||||
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
|
||||
@@ -411,8 +419,7 @@ fun ChatScreen(
|
||||
|
||||
val mediaLaunchers =
|
||||
rememberChatMediaLaunchers(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputFieldValueChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
onAddAttachment = { uri, name, mimeType, size ->
|
||||
viewModel.addAttachment(uri, name, mimeType, size)
|
||||
},
|
||||
@@ -596,20 +603,23 @@ fun ChatScreen(
|
||||
modifier = Modifier.testTag("chat_menu_search"),
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Extension,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showSessionMenu = false
|
||||
connectorsViewModel.show()
|
||||
},
|
||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||
)
|
||||
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
|
||||
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Extension,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showSessionMenu = false
|
||||
connectorsViewModel.show()
|
||||
},
|
||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||
)
|
||||
}
|
||||
ChatBackgroundConnectionMenuItem(
|
||||
checked = settingsState.keepConnectedInBackground,
|
||||
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
||||
@@ -785,7 +795,7 @@ fun ChatScreen(
|
||||
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||
},
|
||||
onShare = { details ->
|
||||
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||
val report = "AITURK Mobil ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||
val intent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
@@ -859,15 +869,18 @@ fun ChatScreen(
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendAgain = viewModel::sendQueuedNow,
|
||||
onDiscard = viewModel::discardPendingSend,
|
||||
onOpenAttachment = viewModel::openAttachment,
|
||||
onImageClick = { viewingImage = it },
|
||||
)
|
||||
}
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
inputState = inputState,
|
||||
receiveContentListener = imagePaste,
|
||||
isReceivingContent = imagePaste.isReceiving,
|
||||
onSend = {
|
||||
if (viewModel.sendMessage(inputFieldValue.text)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
if (!imagePaste.isReceiving && viewModel.sendMessage(inputState.text.toString())) {
|
||||
inputState.clearText()
|
||||
// Jump only after an accepted send. A readiness race keeps the draft intact.
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isHiddenTool
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isSyntheticSystemRow
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
|
||||
@@ -100,7 +101,7 @@ class ChatSearchController {
|
||||
}
|
||||
|
||||
MessageRole.TOOL -> {
|
||||
scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
if (!message.isHiddenTool()) scan(idx, searchableToolText(message), SearchTarget.TOOL)
|
||||
}
|
||||
|
||||
MessageRole.SYSTEM -> {
|
||||
|
||||
@@ -61,6 +61,25 @@ internal fun pendingSendIdsConfirmedByDurableAliases(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* #1427: the `prompt.submit` `user_row_id` is the exact gateway row for that send, so a history
|
||||
* page containing it proves delivery even when the merge could not alias the local bubble.
|
||||
*/
|
||||
internal fun pendingSendIdsConfirmedByRowIds(
|
||||
pageRowIds: Set<Long>,
|
||||
pending: List<PendingSend>,
|
||||
): Set<String> =
|
||||
pending
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.state in setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||
}.filter { it.userRowId != null && it.userRowId in pageRowIds }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
|
||||
/** #1427: a receipt holding a gateway `user_row_id` is stored server-side and must never become UNKNOWN. */
|
||||
internal fun canDemoteAcceptedReceipt(receipt: PendingSend): Boolean = receipt.userRowId == null
|
||||
|
||||
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
||||
class ChatSendStore(
|
||||
private val prefs: SharedPreferences? = null,
|
||||
@@ -158,9 +177,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
|
||||
val recoveryIds =
|
||||
pending
|
||||
.filter {
|
||||
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
|
||||
it.state == PendingSendState.REJECTED
|
||||
it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
|
||||
}.mapTo(mutableSetOf()) { it.id }
|
||||
if (recoveryIds.isEmpty()) return messages
|
||||
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
||||
}
|
||||
|
||||
/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */
|
||||
internal val PendingSend.needsRecovery: Boolean
|
||||
get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED
|
||||
|
||||
@@ -232,7 +232,9 @@ internal fun mapServerMessages(
|
||||
rawContent
|
||||
}
|
||||
var attachments: List<Attachment>? = null
|
||||
if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
if (role == MessageRole.USER) {
|
||||
attachments = userImageAttachments(finalContent, mediaUrl).takeIf { it.isNotEmpty() }
|
||||
} else if (role == MessageRole.ASSISTANT && rawContent.contains("MEDIA:")) {
|
||||
val items = HostMediaExtractor.extract(rawContent)
|
||||
if (items.isNotEmpty()) {
|
||||
finalContent = HostMediaExtractor.strip(rawContent)
|
||||
@@ -275,7 +277,7 @@ internal fun mapServerMessages(
|
||||
completionId = completionId,
|
||||
serverRowId = msg.id?.toLong()?.takeIf { it > 0L },
|
||||
reactions = msg.reactions,
|
||||
),
|
||||
).normalizedGatewayNotice(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -417,6 +417,12 @@ internal fun mergeCachedTranscriptPage(
|
||||
match.id to
|
||||
rich.copy(
|
||||
id = match.id,
|
||||
attachments =
|
||||
if (rich.role == MessageRole.USER) {
|
||||
rich.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments
|
||||
} else {
|
||||
rich.attachments
|
||||
},
|
||||
content = preservedContent ?: rich.content,
|
||||
restId = match.canonicalRestId ?: message.canonicalRestId,
|
||||
serverRowId = match.serverRowId ?: message.serverRowId,
|
||||
@@ -457,6 +463,15 @@ internal fun mergeTranscriptWithLive(
|
||||
when {
|
||||
match?.role == MessageRole.USER -> {
|
||||
match.copy(
|
||||
// #1432: cached user rows lack attachment metadata; hydrate from REST,
|
||||
// while preserving richer optimistic/local attachments when present.
|
||||
attachments = match.attachments?.takeIf { it.isNotEmpty() } ?: message.attachments,
|
||||
content =
|
||||
if (match.isHistoricalCache && !message.attachments.isNullOrEmpty()) {
|
||||
message.content
|
||||
} else {
|
||||
match.content
|
||||
},
|
||||
restId = (message.canonicalRestId ?: match.canonicalRestId).takeUnless { it == match.id },
|
||||
serverRowId = message.serverRowId ?: match.serverRowId,
|
||||
reactions = message.reactions.ifEmpty { match.reactions },
|
||||
@@ -531,14 +546,21 @@ internal fun mergeTranscriptWithLive(
|
||||
.mapNotNull { index ->
|
||||
matches[index]?.id?.let { id -> incoming[index].canonicalOrder?.let { id to it } }
|
||||
}.toMap()
|
||||
return dedupeCachedMessages(transcript.inTranscriptOrder(current, resolvedOrders), confirmedOnly = true)
|
||||
.reconcileReasoningRows()
|
||||
return dedupeCachedMessages(
|
||||
transcript.inTranscriptOrder(
|
||||
previous = current,
|
||||
resolvedOrders = resolvedOrders,
|
||||
observedSuccessorAnchors = current,
|
||||
),
|
||||
confirmedOnly = true,
|
||||
).reconcileReasoningRows()
|
||||
}
|
||||
|
||||
/** Keep server order; place local notices and commands after their last preceding confirmed message, not at the transcript tail. */
|
||||
private fun List<ChatMessage>.inTranscriptOrder(
|
||||
previous: List<ChatMessage>,
|
||||
resolvedOrders: Map<String, Long>,
|
||||
observedSuccessorAnchors: List<ChatMessage>? = null,
|
||||
): List<ChatMessage> {
|
||||
val latestCanonical = mapNotNull { it.canonicalOrder }.maxOrNull() ?: -1L
|
||||
// Session-start markers use -1 and must remain before unresolved cached history.
|
||||
@@ -549,6 +571,21 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
var pendingLocalOrder: Long? = null
|
||||
val localAnchors = mutableMapOf<String, Long>()
|
||||
val pendingOrderByLocal = mutableMapOf<String, Long>()
|
||||
// #1451: an observed live block must stay before its later confirmed USER successor,
|
||||
// even when its own REST echoes are absent. Successor anchors derive only from genuinely
|
||||
// observed current lists (never cache concatenations) and only from later USER prompts.
|
||||
val nextCanonicalOrder = mutableMapOf<String, Long>()
|
||||
val anchorSource = observedSuccessorAnchors ?: emptyList()
|
||||
var followingUserCanonical: Long? = null
|
||||
for (i in anchorSource.indices.reversed()) {
|
||||
val message = anchorSource[i]
|
||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||
if (order != null && message.role == MessageRole.USER) {
|
||||
followingUserCanonical = order
|
||||
} else if (followingUserCanonical != null) {
|
||||
nextCanonicalOrder[message.id] = followingUserCanonical
|
||||
}
|
||||
}
|
||||
previous.forEach { message ->
|
||||
val order = resolvedOrders[message.id] ?: message.canonicalOrder
|
||||
if (order != null) {
|
||||
@@ -563,7 +600,7 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else if (message.isPermanentlyLocal()) {
|
||||
localAnchors[message.id] =
|
||||
if (hasPendingPredecessor) {
|
||||
Long.MAX_VALUE
|
||||
nextCanonicalOrder[message.id]?.let { it - 1L } ?: Long.MAX_VALUE
|
||||
} else {
|
||||
precedingCanonical?.takeIf { it >= 0L }
|
||||
?: if (message.role == MessageRole.USER) {
|
||||
@@ -581,6 +618,9 @@ private fun List<ChatMessage>.inTranscriptOrder(
|
||||
} else {
|
||||
hasPendingPredecessor = true
|
||||
pendingLocalOrder = message.localOrder
|
||||
nextCanonicalOrder[message.id]?.let { successor ->
|
||||
localAnchors[message.id] = successor - 1L
|
||||
}
|
||||
}
|
||||
}
|
||||
val previousIndices = previous.withIndex().associate { it.value.id to it.index }
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.DataScope
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
@@ -41,6 +42,7 @@ import com.m57.hermescontrol.data.ws.contract.CommandDispatchParams
|
||||
import com.m57.hermescontrol.data.ws.contract.CommandsCatalogParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.DESKTOP_SESSION_SOURCE
|
||||
import com.m57.hermescontrol.data.ws.contract.FileAttachParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ImageAttachBytesParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||
@@ -165,7 +167,7 @@ data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
val sessions: List<SessionUi> = emptyList(),
|
||||
val chatTitle: String = "Hermes",
|
||||
val chatTitle: String = "AITURK",
|
||||
val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
|
||||
val isAgentTyping: Boolean = false,
|
||||
val isMainTurnBusy: Boolean = false,
|
||||
@@ -543,6 +545,7 @@ class ChatViewModel(
|
||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
|
||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||
private val pendingSendReservationLock = Any()
|
||||
@@ -918,7 +921,7 @@ class ChatViewModel(
|
||||
.collect { _ ->
|
||||
pendingGoneSessionNotice = false
|
||||
sessionHasServerPresence = false
|
||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||
resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
|
||||
}
|
||||
}
|
||||
// Same wipe when the CONNECTION profile changes (different server):
|
||||
@@ -930,7 +933,7 @@ class ChatViewModel(
|
||||
.collect { _ ->
|
||||
pendingGoneSessionNotice = false
|
||||
sessionHasServerPresence = false
|
||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||
resetSessionState(sessionId = null, title = "AITURK", isLoading = true)
|
||||
}
|
||||
}
|
||||
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||
@@ -1005,7 +1008,7 @@ class ChatViewModel(
|
||||
// even when the route looks already active).
|
||||
_uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) }
|
||||
cancelResumeRetry()
|
||||
addSystemMessage("Connected to Hermes")
|
||||
addSystemMessage("Connected to AITURK")
|
||||
loadSessions()
|
||||
fetchCommandCatalog()
|
||||
modelSwitchDelegate.preloadModelOptions()
|
||||
@@ -1510,6 +1513,10 @@ class ChatViewModel(
|
||||
viewModelScope.launch { fetchContextUsage() }
|
||||
}
|
||||
|
||||
is ReducerEffect.DeleteLocalMessage -> {
|
||||
viewModelScope.launch(ioDispatcher) { repo.deleteMessage(effect.messageId) }
|
||||
}
|
||||
|
||||
is ReducerEffect.AttachHostMedia -> {
|
||||
// Issue #724: turn host-path MEDIA: directives into real
|
||||
// attachments (images inline, every other file tappable)
|
||||
@@ -1702,6 +1709,17 @@ class ChatViewModel(
|
||||
}
|
||||
when {
|
||||
accepted -> {
|
||||
// #1427: a gateway-queued prompt belongs to a future turn, not the
|
||||
// completion currently on screen. Keep it live until that turn is verified.
|
||||
val receipt = sendStore.all().firstOrNull { it.id == messageId }
|
||||
acceptedTurnEpochById[messageId] =
|
||||
if (status == "queued" ||
|
||||
(!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt))
|
||||
) {
|
||||
mainTurnEpoch + 1
|
||||
} else {
|
||||
mainTurnEpoch
|
||||
}
|
||||
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
||||
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
||||
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
||||
@@ -1786,12 +1804,14 @@ class ChatViewModel(
|
||||
isSessionReady = true,
|
||||
isLoading = false,
|
||||
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
|
||||
chatTitle = "Hermes",
|
||||
chatTitle = "AITURK",
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
)
|
||||
}
|
||||
publishPendingSends()
|
||||
@@ -1853,7 +1873,7 @@ class ChatViewModel(
|
||||
val generation =
|
||||
resetSessionState(
|
||||
sessionId = storageId,
|
||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "Hermes",
|
||||
title = (resultMap["title"] as? String)?.takeIf { it.isNotBlank() } ?: "AITURK",
|
||||
isLoading = false,
|
||||
)
|
||||
runtimeSessionId = runtimeId
|
||||
@@ -2298,7 +2318,7 @@ class ChatViewModel(
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error ($method): $errorMsg",
|
||||
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
|
||||
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
||||
)
|
||||
}
|
||||
@@ -2631,12 +2651,14 @@ class ChatViewModel(
|
||||
state: PendingSendState,
|
||||
) {
|
||||
sendStore.update(messageId) { it.copy(state = state) }
|
||||
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
|
||||
publishPendingSends()
|
||||
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
||||
if (state != PendingSendState.SENDING) drainPendingQueue()
|
||||
}
|
||||
|
||||
private fun removePendingSend(messageId: String) {
|
||||
acceptedTurnEpochById.remove(messageId)
|
||||
sendStore.remove(messageId)
|
||||
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
||||
publishPendingSends()
|
||||
@@ -3662,6 +3684,34 @@ class ChatViewModel(
|
||||
|
||||
fun addAttachments(attachments: List<Attachment>) = attachmentsDelegate.addAttachments(attachments)
|
||||
|
||||
/** Clipboard reads are asynchronous: bind them to this exact session generation and server. */
|
||||
internal fun captureAttachmentTarget(): ChatAttachmentTarget? {
|
||||
val state = _uiState.value
|
||||
val sessionId = state.currentSessionId ?: return null
|
||||
// Connection status is combined into public uiState, not written to _uiState.
|
||||
if (wsClient.connectionStatus.value != ConnectionStatus.CONNECTED ||
|
||||
!state.isSessionReady || _timelineState.value.isHistorical
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return ChatAttachmentTarget(
|
||||
sessionId = sessionId,
|
||||
generation = sessionGeneration,
|
||||
baseUrl = AuthManager.getBaseUrl(),
|
||||
connectionProfileId = AuthManager.getSelectedProfileId(),
|
||||
agentProfileId = AuthManager.activeProfileId.value,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun addPastedAttachments(
|
||||
target: ChatAttachmentTarget,
|
||||
attachments: List<Attachment>,
|
||||
): Boolean {
|
||||
if (captureAttachmentTarget() != target) return false
|
||||
attachmentsDelegate.addAttachments(attachments)
|
||||
return true
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) = attachmentsDelegate.removeAttachment(index)
|
||||
|
||||
fun openAttachment(attachment: Attachment) = mediaDelegate.openAttachment(attachment)
|
||||
@@ -4113,44 +4163,46 @@ class ChatViewModel(
|
||||
val arg = parts.getOrElse(1) { "" }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
try {
|
||||
// Primary path: command.dispatch handles quick/plugin/bundle/
|
||||
// skill commands + a few hardcoded ones. It returns a hard 4018
|
||||
// "not a ... command" for everything that lives only in the TUI
|
||||
// slash worker (the 29 commands that 4018'd on mobile — issue
|
||||
// #576). For those we fall back to slash.exec, which runs the
|
||||
// full COMMAND_REGISTRY through the worker.
|
||||
// Desktop parity: slash.exec owns generic commands. Handle errors here,
|
||||
// not again through the shared RpcError banner (/usage regression).
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||
RpcMethods.SLASH_EXEC,
|
||||
SlashExecParams(sessionId = sessionId, command = command.removePrefix("/")),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
handleDispatchResult(result.toAny())
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
val msg = e.message.orEmpty()
|
||||
// Registry miss on command.dispatch: the backend emits exactly
|
||||
// "not a quick/plugin/bundle/skill command: <name>" (tui_gateway
|
||||
// server.py L12408). Match that precise phrase so unrelated
|
||||
// errors can't accidentally trigger the slash.exec fallback.
|
||||
if (msg.contains("not a quick/plugin/bundle/skill command")) {
|
||||
// Registry miss on command.dispatch -> retry via slash.exec,
|
||||
// which routes the full CLI command set through the worker.
|
||||
try {
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.SLASH_EXEC,
|
||||
SlashExecParams(
|
||||
sessionId = sessionId,
|
||||
command = "/$name${if (arg.isNotEmpty()) " $arg" else ""}",
|
||||
),
|
||||
)
|
||||
val output = (result.toAny() as? Map<*, *>)?.get("output") as? String
|
||||
if (!output.isNullOrBlank()) addAssistantMessage(output)
|
||||
} catch (e2: HermesWsClient.HermesRpcException) {
|
||||
addAssistantMessage("/$name: ${e2.message}")
|
||||
}
|
||||
val map = result.toAny() as? Map<*, *>
|
||||
if (map?.get("type") is String) {
|
||||
handleDispatchResult(map)
|
||||
} else {
|
||||
// Legit error from command.dispatch (busy, no history, etc.)
|
||||
addAssistantMessage("/$name: ${e.message}")
|
||||
val output = (map?.get("output") as? String).orEmpty().ifBlank { "/$name: no output" }
|
||||
val warning = (map?.get("warning") as? String).orEmpty()
|
||||
addAssistantMessage(if (warning.isBlank()) output else "warning: $warning\n$output")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val result =
|
||||
wsClient.call(
|
||||
RpcMethods.COMMAND_DISPATCH,
|
||||
CommandDispatchParams(name = name, arg = arg, sessionId = sessionId),
|
||||
suppressErrorEvent = true,
|
||||
)
|
||||
val map = result.toAny() as? Map<*, *>
|
||||
if (map?.get("type") is String) {
|
||||
handleDispatchResult(map)
|
||||
} else {
|
||||
addAssistantMessage("/$name: invalid response: command.dispatch")
|
||||
}
|
||||
} catch (fallback: CancellationException) {
|
||||
throw fallback
|
||||
} catch (fallback: Exception) {
|
||||
val registryMiss =
|
||||
Regex("not a quick/plugin/(?:bundle/)?skill command", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(fallback.message.orEmpty())
|
||||
val failure = if (registryMiss) e else fallback
|
||||
addAssistantMessage("/$name: ${failure.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4516,11 +4568,11 @@ class ChatViewModel(
|
||||
fun createNewSession(setLoading: Boolean = true) {
|
||||
// A fresh create has no persisted row until the first prompt.
|
||||
sessionHasServerPresence = false
|
||||
val generation = resetSessionState(sessionId = null, title = "Hermes", isLoading = setLoading)
|
||||
val generation = resetSessionState(sessionId = null, title = "AITURK", isLoading = setLoading)
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
wsClient.send(
|
||||
RpcMethods.SESSION_CREATE,
|
||||
SessionCreateParams(source = "desktop"),
|
||||
SessionCreateParams(source = DESKTOP_SESSION_SOURCE),
|
||||
onSent = { id -> trackSessionRequest(id, WsMethods.SESSION_CREATE, generation) },
|
||||
)
|
||||
}
|
||||
@@ -4904,7 +4956,7 @@ class ChatViewModel(
|
||||
val title =
|
||||
_uiState.value.sessions
|
||||
.find { it.id == sessionId }
|
||||
?.title ?: "Hermes"
|
||||
?.title ?: "AITURK"
|
||||
val generation = resetSessionState(sessionId, title, isLoading = true)
|
||||
AuthManager.setLastOpenedSessionId(sessionId)
|
||||
viewModelScope.launch {
|
||||
@@ -5056,7 +5108,22 @@ class ChatViewModel(
|
||||
val page = withContext(historyDispatcher) { repo.loadPage(sessionId, before, MESSAGE_PAGE_SIZE) }
|
||||
val valid = { isCurrent() && cacheCursor == before }
|
||||
if (!valid()) return false
|
||||
mergeHistoryPage(valid, cached = true) { page.messages } ?: return false
|
||||
mergeHistoryPage(valid, cached = true) {
|
||||
// #1432: Room retains host references, not connection-bound download URLs.
|
||||
page.messages.map { message ->
|
||||
if (message.role == MessageRole.USER && message.attachments.isNullOrEmpty()) {
|
||||
message.copy(
|
||||
attachments =
|
||||
userImageAttachments(
|
||||
message.content,
|
||||
::gatewayMediaUrl,
|
||||
).takeIf { it.isNotEmpty() },
|
||||
)
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
} ?: return false
|
||||
if (!valid()) return false
|
||||
cacheCursor = page.cursor
|
||||
cacheHasOlder = page.hasOlder
|
||||
@@ -5202,7 +5269,11 @@ class ChatViewModel(
|
||||
val scope = sendScope()
|
||||
val scopedPending =
|
||||
sendStore.all().filter { it.scope == scope && it.sessionId == sessionId }
|
||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending).forEach(::removePendingSend)
|
||||
val pageRowIds = page.mapNotNull { it.serverRowId }.toSet()
|
||||
(
|
||||
pendingSendIdsConfirmedByDurableAliases(aliases, scopedPending) +
|
||||
pendingSendIdsConfirmedByRowIds(pageRowIds, scopedPending)
|
||||
).forEach(::removePendingSend)
|
||||
publishPendingSends()
|
||||
drainPendingQueue()
|
||||
}
|
||||
@@ -5310,6 +5381,9 @@ class ChatViewModel(
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
sessionUsage = null,
|
||||
// #1433: compaction state belongs to the session being left.
|
||||
isCompressing = false,
|
||||
compressionStatus = null,
|
||||
pendingAttachments = emptyList(),
|
||||
composerTextToRestore = null,
|
||||
reactionKind = null,
|
||||
@@ -5338,6 +5412,7 @@ class ChatViewModel(
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = sessionId,
|
||||
source = DESKTOP_SESSION_SOURCE,
|
||||
omitMessages = true,
|
||||
profile = profile?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
@@ -5648,6 +5723,17 @@ class ChatViewModel(
|
||||
val requestSequence = activeHydrationRequestSequence
|
||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||
val useLatest = latestPaging
|
||||
// #1427: acceptance alone is normal live UX. Only a completed turn followed by
|
||||
// successful history verification can move its still-unmatched receipts into recovery.
|
||||
// Capture the rows before suspending so a later prompt/retry cannot be quarantined.
|
||||
val completionAt = lastMainCompletionAt
|
||||
val completedTurnEpoch = mainTurnEpoch
|
||||
val unverifiedAccepted =
|
||||
sendStore.all().filter {
|
||||
it.scope == sendScope() && it.sessionId == sessionId &&
|
||||
it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt &&
|
||||
acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true
|
||||
}
|
||||
val nextOffset =
|
||||
if (useLatest) {
|
||||
0
|
||||
@@ -5682,6 +5768,14 @@ class ChatViewModel(
|
||||
)
|
||||
} ?: return@launch
|
||||
persistHistoryPage(page, sessionId)
|
||||
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
||||
unverifiedAccepted.forEach { receipt ->
|
||||
// A gateway-issued user_row_id means the row is stored: never walk back to UNKNOWN.
|
||||
if (canDemoteAcceptedReceipt(receipt) && sendStore.all().any { it == receipt }) {
|
||||
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
||||
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
||||
// request may overlap, but stable IDs remove echoes without skipping any history.
|
||||
@@ -5945,6 +6039,8 @@ class ChatViewModel(
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
// AITURK: an unsaved (empty) chat has no session row yet; skip the 404 poll.
|
||||
if (AiturkCompat.skipSessionDetail(_uiState.value.messages.isEmpty())) return@launch
|
||||
val usedResult =
|
||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||
coroutineContext.ensureActive()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||
import com.m57.hermescontrol.data.model.deltaFrom
|
||||
import com.m57.hermescontrol.data.model.mergeWith
|
||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||
@@ -136,6 +137,7 @@ object ChatWsEventReducer {
|
||||
is WsEvent.VaultSaveLoginExpire -> event.sessionId
|
||||
is WsEvent.VaultCodeRequest -> event.sessionId
|
||||
is WsEvent.VaultCodeExpire -> event.sessionId
|
||||
is WsEvent.StatusUpdate -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && (currentSessionId == null || eventSessionId != currentSessionId)) {
|
||||
@@ -457,6 +459,7 @@ object ChatWsEventReducer {
|
||||
): ReducerResult {
|
||||
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||
if (GatewayNotice.isInterruptSentinel(event.text)) return onInterruptNotice(state, streamingState, event)
|
||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||
@@ -995,7 +998,7 @@ object ChatWsEventReducer {
|
||||
state =
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error: ${event.error}",
|
||||
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
@@ -1052,36 +1055,107 @@ object ChatWsEventReducer {
|
||||
event: WsEvent.StatusUpdate,
|
||||
): ReducerResult {
|
||||
val kind = event.data?.get("kind") as? String
|
||||
val text = event.data?.get("text") as? String
|
||||
val text = (event.data?.get("text") as? String)?.trim()
|
||||
return when (kind) {
|
||||
// In-progress compaction is live state: the tail indicator shows only while it runs.
|
||||
"compressing", "compacting" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = true,
|
||||
compressionStatus = text ?: "⏳ Compressing context...",
|
||||
compressionStatus = text?.ifBlank { null } ?: "⏳ Compressing context...",
|
||||
),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// #1433: the done edge is a transcript event, seated where it happened, never a floating tail row.
|
||||
"compacted" -> {
|
||||
appendGatewayNotice(state.copy(isCompressing = false, compressionStatus = null), streamingState, text)
|
||||
}
|
||||
|
||||
"ready" -> {
|
||||
ReducerResult(
|
||||
state =
|
||||
state.copy(
|
||||
isCompressing = false,
|
||||
compressionStatus = text,
|
||||
),
|
||||
state = state.copy(isCompressing = false, compressionStatus = null),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// Durable notices the desktop/TUI also paint into the transcript.
|
||||
"goal", "loop", "warn", "fallback" -> {
|
||||
appendGatewayNotice(state, streamingState, text)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ReducerResult(state = state, streamingState = streamingState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a gateway notice as a persisted SYSTEM row (#1433). SYSTEM rows are permanently local, so
|
||||
* `inTranscriptOrder` anchors them after their preceding confirmed row instead of the transcript tail.
|
||||
* A replayed event (reconnect `session.events.since`) re-sends the same text within the current turn;
|
||||
* skip it rather than stacking a duplicate.
|
||||
*/
|
||||
private fun appendGatewayNotice(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
text: String?,
|
||||
extraEffects: List<ReducerEffect> = emptyList(),
|
||||
): ReducerResult {
|
||||
if (text.isNullOrBlank()) {
|
||||
return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||
}
|
||||
val turnStart = state.messages.indexOfLast { it.isUserTurnBoundary() } + 1
|
||||
val duplicate =
|
||||
state.messages.drop(turnStart).any { it.role == MessageRole.SYSTEM && it.content == text }
|
||||
if (duplicate) return ReducerResult(state = state, streamingState = streamingState, effects = extraEffects)
|
||||
val notice = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||
val sid = state.currentSessionId
|
||||
return ReducerResult(
|
||||
state = state.copy(messages = state.messages + notice),
|
||||
streamingState = streamingState,
|
||||
effects = extraEffects + listOfNotNull(sid?.let { ReducerEffect.PersistMessage(notice, it) }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn whose final text is a gateway interrupt sentinel ends with a notice, not assistant prose (#1433).
|
||||
* For the repetition-loop sentinel the gateway hides the looped partial; drop it here too, including a
|
||||
* copy already sealed by Stop, or it lingers as a live-only row that jumps to the tail on every merge.
|
||||
*/
|
||||
private fun onInterruptNotice(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.MessageComplete,
|
||||
): ReducerResult {
|
||||
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||
val text = event.text.trim()
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
var messages = usageState.messages
|
||||
if (text == GatewayNotice.REPETITION_LOOP_INTERRUPTED) {
|
||||
val looped = setOfNotNull(streamingState.interruptedMessage?.id, streamingState.streamingMessage?.id)
|
||||
if (looped.isNotEmpty()) {
|
||||
messages = messages.filterNot { it.id in looped }
|
||||
looped.forEach { effects += ReducerEffect.DeleteLocalMessage(it) }
|
||||
}
|
||||
} else {
|
||||
// Other exits keep any real partial the user already saw.
|
||||
streamingState.streamingMessage
|
||||
?.takeIf { it.content.isNotBlank() || it.reasoningText.isNotBlank() }
|
||||
?.copy(isStreaming = false, finishTimestamp = System.currentTimeMillis())
|
||||
?.let { partial ->
|
||||
messages = messages.upsertById(partial)
|
||||
usageState.currentSessionId?.let { effects += ReducerEffect.PersistMessage(partial, it) }
|
||||
}
|
||||
}
|
||||
effects += ReducerEffect.RefreshSessions
|
||||
effects += ReducerEffect.RefreshContextUsage
|
||||
val settled = usageState.copy(messages = messages, isAgentTyping = false, clarifyRequest = null)
|
||||
return appendGatewayNotice(settled, StreamingState(), text, effects)
|
||||
}
|
||||
|
||||
/** Paints a live tapback on the row whose gateway id matches; unmatched rows arrive with the next REST load. */
|
||||
private fun onMessageReaction(
|
||||
state: ChatUiState,
|
||||
@@ -1293,6 +1367,11 @@ sealed class ReducerEffect {
|
||||
|
||||
data object RefreshContextUsage : ReducerEffect()
|
||||
|
||||
/** Drop a never-confirmed local row from Room (the DAO refuses rows that carry a REST identity). */
|
||||
data class DeleteLocalMessage(
|
||||
val messageId: String,
|
||||
) : ReducerEffect()
|
||||
|
||||
/**
|
||||
* Ask the ViewModel to turn any `MEDIA:<host-path>` directives in the
|
||||
* just-finalized assistant message into real [Attachment]s (images inline,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/**
|
||||
* Gateway turn-outcome sentinels that arrive as assistant text but are notices, not prose (#1433).
|
||||
*
|
||||
* Sources in hermes-agent: `agent/repetition_guard.py` (`REPETITION_LOOP_INTERRUPTED`) and the
|
||||
* `interrupt_text` / `close_interrupted_tool_sequence` exits ("Operation interrupted…"). They have no
|
||||
* matching visible REST row (or a model-facing closing row), so as assistant prose they never get a
|
||||
* canonical position and jump to the transcript tail on every merge. They render as system rows instead.
|
||||
*/
|
||||
internal object GatewayNotice {
|
||||
const val REPETITION_LOOP_INTERRUPTED = "[the reply degenerated into a repetition loop and was interrupted]"
|
||||
|
||||
// "Operation interrupted." / "Operation interrupted: <reason>." / "Operation interrupted during retry (…)."
|
||||
private val OPERATION_INTERRUPTED = Regex("""^Operation interrupted(?:\.|(?::| during) [^\n]*\.)$""")
|
||||
|
||||
fun isInterruptSentinel(text: String): Boolean {
|
||||
val trimmed = text.trim()
|
||||
return trimmed == REPETITION_LOOP_INTERRUPTED || OPERATION_INTERRUPTED.matches(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway persists an interrupted turn's closing row as `role=assistant` with the sentinel text. Map it
|
||||
* (REST and Room alike) to the same SYSTEM notice the live path appends, so every copy matches one row.
|
||||
*/
|
||||
internal fun ChatMessage.normalizedGatewayNotice(): ChatMessage =
|
||||
if (role == MessageRole.ASSISTANT && attachments.isNullOrEmpty() && GatewayNotice.isInterruptSentinel(content)) {
|
||||
copy(role = MessageRole.SYSTEM, content = content.trim(), reasoningText = "", completionId = null)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
@@ -98,7 +98,7 @@ object MediaExportHelper {
|
||||
if (length != null && length > 0) {
|
||||
put(MediaStore.MediaColumns.SIZE, length)
|
||||
}
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/Hermes")
|
||||
put(MediaStore.MediaColumns.RELATIVE_PATH, "Download/AITURK")
|
||||
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ object MediaImageStore {
|
||||
val safe = sanitizeName(displayName)
|
||||
val (collection, relativePath) =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/Hermes"
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI to "Download/AITURK"
|
||||
} else {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI to null
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
||||
openFileUriStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("/") -> {
|
||||
File(uriString).isAbsolute -> {
|
||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
||||
val file =
|
||||
runCatching { File(java.net.URI(uriString)) }.getOrElse {
|
||||
val parsed = Uri.parse(uriString).path ?: throw IOException("Invalid file URI: $uriString")
|
||||
File(parsed)
|
||||
}
|
||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||
return file.inputStream().use { inputStream ->
|
||||
|
||||
@@ -106,7 +106,7 @@ sealed class SlashResult {
|
||||
/** Create a new session (client-side immediate). */
|
||||
data object NewSession : SlashResult()
|
||||
|
||||
/** Forward to command.dispatch via WebSocket. */
|
||||
/** Execute through slash.exec, with command.dispatch as the desktop-compatible fallback. */
|
||||
data object RpcDispatch : SlashResult()
|
||||
|
||||
/** Fork the active conversation via the session.branch WebSocket RPC. */
|
||||
|
||||
@@ -25,6 +25,8 @@ import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -58,6 +60,7 @@ import com.m57.hermescontrol.theme.DarkOnSurface
|
||||
import com.m57.hermescontrol.theme.LightOnSurface
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberCopyFeedback
|
||||
import com.m57.hermescontrol.ui.chat.tool.ToolJson
|
||||
import com.m57.hermescontrol.util.BidiUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -81,6 +84,7 @@ fun UserBubble(
|
||||
messageStatsEnabled: Boolean = false,
|
||||
showUserMessageTokens: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
pendingSendState: PendingSendState? = null,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.80f
|
||||
@@ -99,18 +103,36 @@ fun UserBubble(
|
||||
var copied by rememberCopyFeedback()
|
||||
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val deliveryState =
|
||||
when {
|
||||
pendingSendState == PendingSendState.SENDING -> DeliveryState.SENT
|
||||
|
||||
pendingSendState == PendingSendState.ACCEPTED ||
|
||||
message.canonicalRestId != null || message.serverRowId != null -> DeliveryState.DELIVERED
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
// #1432: also protect cached/legacy rows and unexpectedly large plain-string payloads.
|
||||
// Keep the original content for Copy; only the layout input is bounded.
|
||||
val displayContent =
|
||||
remember(message.content, message.attachments) {
|
||||
// #1432: the path is only hidden when its image renders as an attachment below.
|
||||
val visible =
|
||||
if (message.attachments.isNullOrEmpty()) message.content else hideImageRefLines(message.content)
|
||||
ToolJson.clampForDisplay(visible)
|
||||
}
|
||||
val highlightedText =
|
||||
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
|
||||
remember(displayContent, searchQuery, isCurrentMatch, statusColors) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
buildHighlightedString(
|
||||
message.content,
|
||||
displayContent,
|
||||
searchQuery,
|
||||
isCurrentMatch,
|
||||
statusColors,
|
||||
)
|
||||
} else {
|
||||
AnnotatedString(message.content)
|
||||
AnnotatedString(displayContent)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
@@ -152,7 +174,7 @@ fun UserBubble(
|
||||
color = Color.Transparent,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
val isRtl = remember(message.content) { BidiUtils.isRtlText(message.content) }
|
||||
val isRtl = remember(displayContent) { BidiUtils.isRtlText(displayContent) }
|
||||
val bubbleDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||
CompositionLocalProvider(LocalLayoutDirection provides bubbleDirection) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
@@ -218,6 +240,11 @@ fun UserBubble(
|
||||
color = userBubbleTextColor.copy(alpha = 0.6f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
DeliveryChecks(
|
||||
state = deliveryState,
|
||||
tint = userBubbleTextColor,
|
||||
messageId = message.id,
|
||||
)
|
||||
if (messageStatsEnabled && showUserMessageTokens &&
|
||||
message.tokenCount != null && message.tokenCount > 0
|
||||
) {
|
||||
@@ -250,3 +277,24 @@ fun UserBubble(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** WhatsApp-style delivery ticks: one dim check while sending, two once the server has the prompt. */
|
||||
private enum class DeliveryState { SENT, DELIVERED }
|
||||
|
||||
@Composable
|
||||
private fun DeliveryChecks(
|
||||
state: DeliveryState?,
|
||||
tint: Color,
|
||||
messageId: String,
|
||||
) {
|
||||
if (state == null) return
|
||||
val delivered = state == DeliveryState.DELIVERED
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = if (delivered) Icons.Filled.DoneAll else Icons.Filled.Done,
|
||||
contentDescription =
|
||||
stringResource(if (delivered) R.string.chat_send_accepted else R.string.chat_pending_sending),
|
||||
modifier = Modifier.size(14.dp).testTag("user_send_status_$messageId"),
|
||||
tint = tint.copy(alpha = if (delivered) 1f else 0.6f),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
|
||||
/**
|
||||
* Parses user-authored or server-persisted `@image:` directives from message content into
|
||||
* gateway-backed [Attachment]s.
|
||||
*
|
||||
* Requirements:
|
||||
* - Recognizes whole trimmed lines `@image:/absolute/path` (spaces in path allowed).
|
||||
* - Ignores inline prose refs (e.g. "look at @image:/path in the middle of a sentence").
|
||||
* - Ignores empty or relative paths (must start with '/').
|
||||
* - Preserves unique paths across lines (deduplicates repeated references to the same path).
|
||||
* - Resolves each path via [mediaUrl]; drops attachments where [mediaUrl] returns null.
|
||||
* - Constructs [Attachment]s with source = [AttachmentSource.GATEWAY], resolved uri/gatewayUrl,
|
||||
* size = 0, name = [mediaNameFromPath], mimeType = [mediaMimeForPath].
|
||||
*/
|
||||
internal fun userImageAttachments(
|
||||
content: String,
|
||||
mediaUrl: (String) -> String?,
|
||||
): List<Attachment> {
|
||||
if (!content.contains("@image:")) return emptyList()
|
||||
|
||||
val seenPaths = mutableSetOf<String>()
|
||||
val attachments = mutableListOf<Attachment>()
|
||||
|
||||
for (rawLine in content.lines()) {
|
||||
val line = rawLine.trim()
|
||||
if (!line.startsWith("@image:")) continue
|
||||
val path = line.removePrefix("@image:").trim()
|
||||
if (!path.startsWith("/")) continue
|
||||
if (!seenPaths.add(path)) continue
|
||||
|
||||
val url = mediaUrl(path) ?: continue
|
||||
attachments.add(
|
||||
Attachment(
|
||||
uri = url,
|
||||
name = mediaNameFromPath(path),
|
||||
mimeType = mediaMimeForPath(path),
|
||||
size = 0,
|
||||
gatewayUrl = url,
|
||||
source = AttachmentSource.GATEWAY,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
/** Hides whole-line `@image:/abs/path` refs from bubble text once they render as attachments. */
|
||||
internal fun hideImageRefLines(content: String): String {
|
||||
if (!content.contains("@image:")) return content
|
||||
return content
|
||||
.lines()
|
||||
.filterNot { line ->
|
||||
line.trim().let {
|
||||
it.startsWith("@image:") &&
|
||||
it.removePrefix("@image:").trim().startsWith("/")
|
||||
}
|
||||
}.joinToString("\n")
|
||||
.trim()
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.contentReceiver
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -32,6 +35,9 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -64,8 +71,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -86,9 +94,9 @@ import com.m57.hermescontrol.util.BidiUtils
|
||||
* controls row (attach, model/reasoning pill, mic, send) inside it below.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onSend: () -> Unit,
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
@@ -127,15 +135,18 @@ fun ChatInputBar(
|
||||
isRecordingVoice: Boolean = false,
|
||||
amplitudeProvider: () -> Float = { 0f },
|
||||
onStopGeneration: () -> Unit = {},
|
||||
receiveContentListener: ReceiveContentListener? = null,
|
||||
isReceivingContent: Boolean = false,
|
||||
) {
|
||||
val inputText = inputState.text.toString()
|
||||
// Allow sending while the agent is mid-turn or awaiting approval: the
|
||||
// gateway's prompt.submit busy-input policy queues it as the next turn
|
||||
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
|
||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||
val canSend =
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
!isReceivingContent && pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputText, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
@@ -232,6 +243,16 @@ fun ChatInputBar(
|
||||
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) {
|
||||
if (isReceivingContent) {
|
||||
val loadingLabel = stringResource(R.string.chat_image_paste_loading)
|
||||
LinearProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("chat_image_paste_progress")
|
||||
.semantics { contentDescription = loadingLabel },
|
||||
)
|
||||
}
|
||||
// Commands hidden from the suggestion menu — desktop/CLI-only and
|
||||
// TUI-only commands that don't function on mobile (issue #574).
|
||||
// Single source of truth: CommandBlocklist.UNSUPPORTED, which is
|
||||
@@ -245,13 +266,13 @@ fun ChatInputBar(
|
||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = inputFieldValue.text.startsWith("/") && !inputFieldValue.text.contains(" "),
|
||||
visible = inputText.startsWith("/") && !inputText.contains(" "),
|
||||
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
|
||||
) {
|
||||
val filteredCommands =
|
||||
ChatInputPolicy.sortSlashSuggestions(
|
||||
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
|
||||
commandNames.filter { it.startsWith(inputText, ignoreCase = true) },
|
||||
slashUsageCounts,
|
||||
)
|
||||
if (filteredCommands.isNotEmpty()) {
|
||||
@@ -280,7 +301,7 @@ fun ChatInputBar(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
},
|
||||
onClick = { onInputChange(ChatInputPolicy.commandFieldValue(cmd)) },
|
||||
onClick = { inputState.replaceComposerDraft(cmd) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -341,7 +362,7 @@ fun ChatInputBar(
|
||||
stringResource(R.string.chat_input_placeholder_not_connected)
|
||||
}
|
||||
|
||||
ChatInputPolicy.showQueuePlaceholder(inputFieldValue.text, isAgentTyping) -> {
|
||||
ChatInputPolicy.showQueuePlaceholder(inputText, isAgentTyping) -> {
|
||||
stringResource(R.string.chat_input_placeholder_queue)
|
||||
}
|
||||
|
||||
@@ -356,9 +377,9 @@ fun ChatInputBar(
|
||||
|
||||
val ambientLayoutDirection = LocalLayoutDirection.current
|
||||
val inputLayoutDirection =
|
||||
remember(inputFieldValue.text, ambientLayoutDirection) {
|
||||
remember(inputText, ambientLayoutDirection) {
|
||||
BidiUtils.resolveLayoutDirection(
|
||||
inputFieldValue.text,
|
||||
inputText,
|
||||
fallback = ambientLayoutDirection,
|
||||
)
|
||||
}
|
||||
@@ -381,8 +402,7 @@ fun ChatInputBar(
|
||||
.heightIn(min = 42.dp),
|
||||
) {
|
||||
BasicTextField(
|
||||
value = inputFieldValue,
|
||||
onValueChange = { if (!isRecordingVoice) onInputChange(it) },
|
||||
state = inputState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -392,18 +412,28 @@ fun ChatInputBar(
|
||||
.focusRequester(inputFocusRequester)
|
||||
.onFocusChanged { focusState ->
|
||||
inputFocused = focusState.isFocused
|
||||
}.testTag("chat_input"),
|
||||
}.testTag("chat_input")
|
||||
.then(
|
||||
if (receiveContentListener != null) {
|
||||
Modifier.contentReceiver(receiveContentListener)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
enabled = isConnected,
|
||||
inputTransformation =
|
||||
InputTransformation {
|
||||
if (isRecordingVoice) revertAllChanges()
|
||||
},
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyLarge.copy(
|
||||
color = palette.text,
|
||||
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
|
||||
textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
|
||||
),
|
||||
singleLine = false,
|
||||
maxLines = 8,
|
||||
lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 8),
|
||||
cursorBrush = SolidColor(palette.text),
|
||||
decorationBox = { innerTextField ->
|
||||
decorator = { innerTextField ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment =
|
||||
@@ -416,7 +446,7 @@ fun ChatInputBar(
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides inputLayoutDirection,
|
||||
) {
|
||||
if (inputFieldValue.text.isEmpty() && !isRecordingVoice) {
|
||||
if (inputText.isEmpty() && !isRecordingVoice) {
|
||||
Text(
|
||||
text = placeholderText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
|
||||
/** Apply an intentional external draft replacement, never a value mirror during ordinary typing. */
|
||||
internal fun TextFieldState.replaceComposerDraft(text: String) {
|
||||
val replacement = ChatInputPolicy.commandFieldValue(text)
|
||||
edit {
|
||||
replace(0, length, replacement.text)
|
||||
selection = replacement.selection
|
||||
}
|
||||
}
|
||||
|
||||
/** Recheck the live draft when a rejected send is returned; it may have changed since dispatch. */
|
||||
internal fun TextFieldState.restoreRejectedComposerDraft(rejectedText: String) {
|
||||
val current = TextFieldValue(text.toString(), selection)
|
||||
val restored = ChatInputPolicy.restoreRejectedText(rejectedText, current)
|
||||
edit {
|
||||
replace(0, length, restored.text)
|
||||
selection = restored.selection
|
||||
}
|
||||
}
|
||||
|
||||
/** Speech results arrive asynchronously: append to the draft as it exists at result time. */
|
||||
internal fun TextFieldState.appendSpeechComposerDraft(spokenText: String) {
|
||||
if (spokenText.isBlank()) return
|
||||
val current = text.toString()
|
||||
replaceComposerDraft(if (current.isBlank()) spokenText else "$current $spokenText")
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -70,13 +72,20 @@ fun ChatConnectionBanner(
|
||||
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(
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
maxLines = 1,
|
||||
maxLines = if (hostOffline) 3 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
text =
|
||||
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.NO_NETWORK -> stringResource(R.string.chat_status_no_network)
|
||||
ConnectionStatus.AUTH_EXPIRED -> stringResource(R.string.chat_status_auth_expired)
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.content.MediaType
|
||||
import androidx.compose.foundation.content.ReceiveContentListener
|
||||
import androidx.compose.foundation.content.TransferableContent
|
||||
import androidx.compose.foundation.content.consume
|
||||
import androidx.compose.foundation.content.hasMediaType
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatAttachmentTarget
|
||||
import com.m57.hermescontrol.ui.chat.ChatPastedImageStore
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.MAX_CHAT_ATTACHMENT_BYTES
|
||||
import com.m57.hermescontrol.ui.chat.PastedImage
|
||||
import com.m57.hermescontrol.ui.chat.PastedImageTooLargeException
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
|
||||
internal const val MAX_PASTED_IMAGE_COUNT = 8
|
||||
internal const val MAX_PASTED_IMAGE_BATCH_BYTES = 20L * 1024L * 1024L
|
||||
|
||||
private class PastedImageBatchLimitException : IOException("Pasted image batch exceeds its limit")
|
||||
|
||||
/** Receives explicit paste/IME commits only; never reads or observes the system clipboard. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal class ChatImagePasteController(
|
||||
private val scope: CoroutineScope,
|
||||
private val importImage: suspend (Uri, Long) -> PastedImage?,
|
||||
private val captureTarget: () -> ChatAttachmentTarget?,
|
||||
private val commit: (ChatAttachmentTarget, List<Attachment>) -> Boolean,
|
||||
private val onError: (Int) -> Unit,
|
||||
private val isImageUri: (Uri) -> Boolean = { false },
|
||||
) : ReceiveContentListener {
|
||||
var isReceiving by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var closed = false
|
||||
private var job: Job? = null
|
||||
private val retainedPayloads = mutableSetOf<Any>()
|
||||
|
||||
override fun onReceive(transferableContent: TransferableContent): TransferableContent? {
|
||||
if (!transferableContent.hasMediaType(MediaType.Image)) return transferableContent
|
||||
// Drag grants have a different lifecycle; this feature handles paste and IME commits only.
|
||||
if (transferableContent.source != TransferableContent.Source.Clipboard &&
|
||||
transferableContent.source != TransferableContent.Source.Keyboard
|
||||
) {
|
||||
return transferableContent
|
||||
}
|
||||
val clip = transferableContent.clipEntry.clipData
|
||||
val uris = ArrayList<Uri>(MAX_PASTED_IMAGE_COUNT + 1)
|
||||
for (index in 0 until clip.itemCount) {
|
||||
val uri = clip.getItemAt(index).uri
|
||||
if (uri?.scheme == "content" && isImageUri(uri)) {
|
||||
uris.add(uri)
|
||||
if (uris.size > MAX_PASTED_IMAGE_COUNT) break
|
||||
}
|
||||
}
|
||||
if (!receiveImages(uris, transferableContent)) return transferableContent
|
||||
// Only accepted image candidates are consumed. Text and non-image URIs
|
||||
// remain available to the ordinary text field/other receivers.
|
||||
val accepted = uris.toSet()
|
||||
// An item can carry both image URI and text. Keep that item for the text
|
||||
// field after importing its image; consuming it would drop its text.
|
||||
return transferableContent.consume { it.uri in accepted && it.text == null && it.htmlText == null }
|
||||
}
|
||||
|
||||
/** [payload] strongly retains Compose's temporary InputContentInfo permission until IO ends. */
|
||||
internal fun receiveImages(
|
||||
uris: List<Uri>,
|
||||
payload: Any,
|
||||
): Boolean {
|
||||
if (closed || !scope.isActive || uris.isEmpty()) return false
|
||||
if (isReceiving) {
|
||||
onError(R.string.chat_image_paste_busy)
|
||||
return true
|
||||
}
|
||||
if (uris.size > MAX_PASTED_IMAGE_COUNT) {
|
||||
onError(R.string.chat_image_paste_batch_limit)
|
||||
return true
|
||||
}
|
||||
val target = captureTarget()
|
||||
if (target == null) {
|
||||
onError(R.string.chat_session_not_ready)
|
||||
return true
|
||||
}
|
||||
isReceiving = true
|
||||
retainedPayloads.add(payload)
|
||||
job =
|
||||
scope.launch {
|
||||
val images = mutableListOf<PastedImage>()
|
||||
var remainingBytes = MAX_PASTED_IMAGE_BATCH_BYTES
|
||||
var committed = false
|
||||
try {
|
||||
for (uri in uris) {
|
||||
if (remainingBytes == 0L) throw PastedImageBatchLimitException()
|
||||
val cap = minOf(remainingBytes, MAX_CHAT_ATTACHMENT_BYTES)
|
||||
val image = importImage(uri, cap) ?: throw IOException("Unreadable pasted image")
|
||||
images += image
|
||||
remainingBytes -= image.file.length()
|
||||
if (remainingBytes < 0L) throw PastedImageBatchLimitException()
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (!closed && captureTarget() == target) {
|
||||
committed = commit(target, images.map { it.attachment })
|
||||
}
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Exception) {
|
||||
if (!closed && captureTarget() == target) {
|
||||
onError(
|
||||
if (failure is PastedImageBatchLimitException ||
|
||||
(failure is PastedImageTooLargeException && remainingBytes < MAX_CHAT_ATTACHMENT_BYTES)
|
||||
) {
|
||||
R.string.chat_image_paste_batch_limit
|
||||
} else if (failure is PastedImageTooLargeException) {
|
||||
R.string.chat_image_paste_too_large
|
||||
} else {
|
||||
R.string.chat_image_paste_failed
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!committed) {
|
||||
withContext(NonCancellable + Dispatchers.IO) { images.forEach { it.discard() } }
|
||||
}
|
||||
retainedPayloads.remove(payload)
|
||||
isReceiving = false
|
||||
}
|
||||
}
|
||||
// Covers a scope cancelled before the launched coroutine ever starts.
|
||||
job?.invokeOnCompletion { retainedPayloads.remove(payload) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
internal fun rememberChatImagePaste(
|
||||
viewModel: ChatViewModel,
|
||||
onShowMessage: (String) -> Unit,
|
||||
): ChatImagePasteController {
|
||||
val context = LocalContext.current
|
||||
val resources by rememberUpdatedState(LocalResources.current)
|
||||
val scope = rememberCoroutineScope()
|
||||
val showMessage by rememberUpdatedState(onShowMessage)
|
||||
val controller =
|
||||
remember(viewModel, context, scope) {
|
||||
val store = ChatPastedImageStore(context.applicationContext)
|
||||
ChatImagePasteController(
|
||||
scope = scope,
|
||||
importImage = store::importImage,
|
||||
isImageUri = { uri ->
|
||||
try {
|
||||
context.contentResolver.getType(uri)?.let { type ->
|
||||
type.startsWith("image/", ignoreCase = true) && type.length > "image/".length
|
||||
} == true
|
||||
} catch (_: RuntimeException) {
|
||||
// Provider failures must never consume an unclassified URI.
|
||||
false
|
||||
}
|
||||
},
|
||||
captureTarget = viewModel::captureAttachmentTarget,
|
||||
commit = viewModel::addPastedAttachments,
|
||||
onError = { showMessage(resources.getString(it)) },
|
||||
)
|
||||
}
|
||||
DisposableEffect(controller) { onDispose { controller.close() } }
|
||||
return controller
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import android.speech.RecognizerIntent
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -21,13 +22,11 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ChatInputPolicy
|
||||
import com.m57.hermescontrol.ui.chat.SpeechInputHelper
|
||||
import com.m57.hermescontrol.ui.chat.VoiceNoteRecorder
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -55,8 +54,7 @@ class ChatMediaLaunchers(
|
||||
|
||||
@Composable
|
||||
fun rememberChatMediaLaunchers(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputFieldValueChange: (TextFieldValue) -> Unit,
|
||||
inputState: TextFieldState,
|
||||
onAddAttachment: (uri: String, name: String, mimeType: String, size: Long) -> Unit,
|
||||
onAddAttachments: (List<Attachment>) -> Unit,
|
||||
onVoiceNoteRecorded: (file: File) -> Unit,
|
||||
@@ -68,8 +66,7 @@ fun rememberChatMediaLaunchers(
|
||||
var isListening by remember { mutableStateOf(false) }
|
||||
var pendingCameraUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val currentInputFieldValue by rememberUpdatedState(inputFieldValue)
|
||||
val currentOnInputFieldValueChange by rememberUpdatedState(onInputFieldValueChange)
|
||||
val currentInputState by rememberUpdatedState(inputState)
|
||||
val currentOnAddAttachment by rememberUpdatedState(onAddAttachment)
|
||||
val currentOnAddAttachments by rememberUpdatedState(onAddAttachments)
|
||||
val currentOnVoiceNoteRecorded by rememberUpdatedState(onVoiceNoteRecorded)
|
||||
@@ -134,14 +131,7 @@ fun rememberChatMediaLaunchers(
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
if (spokenText.isNotBlank()) {
|
||||
val currentText = currentInputFieldValue.text
|
||||
val merged =
|
||||
if (currentText.isBlank()) {
|
||||
spokenText
|
||||
} else {
|
||||
"$currentText $spokenText"
|
||||
}
|
||||
currentOnInputFieldValueChange(ChatInputPolicy.commandFieldValue(merged))
|
||||
currentInputState.appendSpeechComposerDraft(spokenText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.needsRecovery
|
||||
|
||||
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
|
||||
mainTurnBusy: Boolean,
|
||||
onSendAgain: (String) -> Unit,
|
||||
onDiscard: (String) -> Unit,
|
||||
onOpenAttachment: (Attachment) -> Unit = {},
|
||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||
) {
|
||||
if (sends.isEmpty()) return
|
||||
val recoverySends = sends.filter { it.needsRecovery }
|
||||
if (recoverySends.isEmpty()) return
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var retryId by remember { mutableStateOf<String?>(null) }
|
||||
var discardId by remember { mutableStateOf<String?>(null) }
|
||||
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
||||
) {
|
||||
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
|
||||
Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
|
||||
}
|
||||
if (open) {
|
||||
ModalBottomSheet(onDismissRequest = { open = false }) {
|
||||
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
||||
items(sends, key = { it.id }) { send ->
|
||||
items(recoverySends, key = { it.id }) { send ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (send.attachments.isNotEmpty()) {
|
||||
Text(stringResource(R.string.chat_pending_attachments))
|
||||
send.attachments.forEach { attachment ->
|
||||
if (attachment.isImage || attachment.isAudio || attachment.isVideo) {
|
||||
Text(attachment.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
InlineAttachment(
|
||||
attachment = attachment,
|
||||
textColor = MaterialTheme.colorScheme.onSurface,
|
||||
onOpen = onOpenAttachment,
|
||||
onSave = {},
|
||||
savingPath = null,
|
||||
openingPath = null,
|
||||
canSave = false,
|
||||
onImageClick = onImageClick,
|
||||
)
|
||||
}
|
||||
Row {
|
||||
TextButton(
|
||||
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
|
||||
}
|
||||
}
|
||||
}
|
||||
sends.firstOrNull { it.id == retryId }?.let { send ->
|
||||
recoverySends.firstOrNull { it.id == retryId }?.let { send ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { retryId = null },
|
||||
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
||||
@@ -151,7 +170,7 @@ fun PendingSendRecovery(
|
||||
},
|
||||
)
|
||||
}
|
||||
sends.firstOrNull { it.id == discardId }?.let { send ->
|
||||
recoverySends.firstOrNull { it.id == discardId }?.let { send ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { discardId = null },
|
||||
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.SearchTarget
|
||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||
@@ -57,6 +58,23 @@ private object FullBleedContentType {
|
||||
const val SYSTEM_EVENT: String = "system_event"
|
||||
}
|
||||
|
||||
/** Only show the placeholder when there are no messages or renderable live tail items. */
|
||||
internal fun shouldShowChatEmptyState(
|
||||
transcript: TranscriptUiState,
|
||||
hasReplyError: Boolean,
|
||||
): Boolean =
|
||||
transcript.messages.isEmpty() &&
|
||||
transcript.streamingState.streamingMessage == null &&
|
||||
!transcript.isLoading &&
|
||||
!transcript.isAgentTyping &&
|
||||
!hasReplyError &&
|
||||
transcript.clarifyRequest == null &&
|
||||
transcript.vaultUnlockPrompt == null &&
|
||||
transcript.vaultSaveLoginPrompt == null &&
|
||||
transcript.vaultCodePrompt == null &&
|
||||
!transcript.isCompressing &&
|
||||
transcript.compressionStatus == null
|
||||
|
||||
/**
|
||||
* The chat message list for FULL-BLEED style (issue #866) — the single chat
|
||||
* surface since the bubble renderer was removed. User messages keep their
|
||||
@@ -104,7 +122,7 @@ fun FullBleedChatList(
|
||||
val isCompressing = transcript.isCompressing
|
||||
val compressionStatus = transcript.compressionStatus
|
||||
val speakingMessageId = transcript.speakingMessageId
|
||||
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||
if (shouldShowChatEmptyState(transcript, hasReplyError = replyErrorContent != null)) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -311,6 +329,7 @@ fun FullBleedChatList(
|
||||
onImageClick = actions.onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
pendingSendState = transcript.pendingSendStates[userMessage.id],
|
||||
)
|
||||
if (userMessage.reactions.isNotEmpty()) {
|
||||
MessageReactionChips(
|
||||
@@ -561,6 +580,7 @@ private fun renderUserBubble(
|
||||
onImageClick: (ImageViewerModel) -> Unit,
|
||||
messageStatsEnabled: Boolean,
|
||||
showUserMessageTokens: Boolean,
|
||||
pendingSendState: PendingSendState?,
|
||||
) {
|
||||
UserBubble(
|
||||
message = message,
|
||||
@@ -574,5 +594,6 @@ private fun renderUserBubble(
|
||||
onImageClick = onImageClick,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
showUserMessageTokens = showUserMessageTokens,
|
||||
pendingSendState = pendingSendState,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
|
||||
displayKind == null &&
|
||||
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
|
||||
|
||||
private const val HIDDEN_TOOL_NAME = "react_to_message"
|
||||
|
||||
/** Tool rows that never get a transcript bubble. */
|
||||
internal fun ChatMessage.isHiddenTool(): Boolean = role == MessageRole.TOOL && toolName == HIDDEN_TOOL_NAME
|
||||
|
||||
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
|
||||
|
||||
/**
|
||||
@@ -108,6 +113,10 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
|
||||
agentEntries += AgentEntry.Prose(message)
|
||||
}
|
||||
|
||||
message.isHiddenTool() -> {
|
||||
// Reaction tool calls have no useful bubble; the reaction itself is the feedback.
|
||||
}
|
||||
|
||||
MessageRole.TOOL == message.role -> {
|
||||
agentEntries += AgentEntry.ToolRow(message)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||
@@ -37,6 +38,7 @@ data class TranscriptUiState(
|
||||
val isCompressing: Boolean,
|
||||
val compressionStatus: String?,
|
||||
val speakingMessageId: String?,
|
||||
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
|
||||
) {
|
||||
companion object {
|
||||
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
|
||||
@@ -76,6 +78,8 @@ data class TranscriptUiState(
|
||||
isCompressing = !historical && chat.isCompressing,
|
||||
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
||||
speakingMessageId = speakingMessageId,
|
||||
pendingSendStates =
|
||||
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -16,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
@@ -32,12 +35,16 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -46,9 +53,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -388,6 +397,12 @@ fun CronJobEditorDialog(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast)
|
||||
|
||||
LaunchedEffect(state.isNew) {
|
||||
if (state.isNew && state.schedule.isBlank()) {
|
||||
onFieldChange("schedule", "0 9 * * *")
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (hasChanges && !state.isNew) {
|
||||
@@ -416,6 +431,8 @@ fun CronJobEditorDialog(
|
||||
} else {
|
||||
stringResource(R.string.cron_edit_title)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon =
|
||||
@@ -430,13 +447,13 @@ fun CronJobEditorDialog(
|
||||
),
|
||||
actions = {
|
||||
if (!state.isLoading) {
|
||||
IconButton(
|
||||
TextButton(
|
||||
onClick = onSave,
|
||||
enabled = !state.isSaving,
|
||||
) {
|
||||
if (state.isSaving) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.fillMaxSize(0.6f),
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@@ -504,13 +521,9 @@ fun CronJobEditorDialog(
|
||||
)
|
||||
|
||||
// Schedule (required)
|
||||
OutlinedTextField(
|
||||
value = state.schedule,
|
||||
onValueChange = { onFieldChange("schedule", it) },
|
||||
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
SchedulePickerSection(
|
||||
schedule = state.schedule,
|
||||
onScheduleChange = { onFieldChange("schedule", it) },
|
||||
)
|
||||
|
||||
// Prompt
|
||||
@@ -818,6 +831,7 @@ private fun BlueprintStartFromDropdown(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BlueprintSlotField(
|
||||
field: CronBlueprintField,
|
||||
@@ -837,13 +851,61 @@ private fun BlueprintSlotField(
|
||||
)
|
||||
}
|
||||
|
||||
field.type == "enum" || field.type == "weekdays" -> {
|
||||
ExposedDropdownField(
|
||||
label = field.label,
|
||||
options = field.options,
|
||||
selectedValue = value,
|
||||
onOptionSelected = onValueChange,
|
||||
)
|
||||
field.type == "enum" || field.type == "weekdays" -> {
|
||||
ExposedDropdownField(
|
||||
label = field.label,
|
||||
options = field.options,
|
||||
selectedValue = value,
|
||||
onOptionSelected = onValueChange,
|
||||
)
|
||||
}
|
||||
|
||||
field.type == "time" -> {
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
val timeParts = value.split(":").mapNotNull { it.toIntOrNull() }
|
||||
val curHour = (timeParts.getOrNull(0) ?: 8).coerceIn(0, 23)
|
||||
val curMin = (timeParts.getOrNull(1) ?: 0).coerceIn(0, 59)
|
||||
|
||||
Text(field.label, style = MaterialTheme.typography.labelLarge)
|
||||
TimeDisplayCard(hour = curHour, minute = curMin, onClick = { showTimePicker = true })
|
||||
if (field.help.isNotBlank()) Text(field.help, style = MaterialTheme.typography.bodySmall)
|
||||
|
||||
if (showTimePicker) {
|
||||
val timePickerState =
|
||||
rememberTimePickerState(
|
||||
initialHour = curHour,
|
||||
initialMinute = curMin,
|
||||
is24Hour = true,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onValueChange(String.format(java.util.Locale.ROOT, "%02d:%02d", timePickerState.hour, timePickerState.minute))
|
||||
showTimePicker = false
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTimePicker = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TimePicker(state = timePickerState)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -936,3 +998,528 @@ private fun DeliverField(
|
||||
}
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SchedulePickerSection(
|
||||
schedule: String,
|
||||
onScheduleChange: (String) -> Unit,
|
||||
) {
|
||||
val parsed = remember(schedule) { parseSchedule(schedule) }
|
||||
|
||||
var mode by remember(schedule) { mutableStateOf(parsed.mode) }
|
||||
var hour by remember { mutableStateOf(parsed.hour) }
|
||||
var minute by remember { mutableStateOf(parsed.minute) }
|
||||
var intervalCount by remember(schedule) { mutableStateOf(parsed.intervalCount) }
|
||||
var intervalUnit by remember(schedule) { mutableStateOf(parsed.intervalUnit) }
|
||||
var selectedDays by remember(schedule) { mutableStateOf(parsed.selectedDays) }
|
||||
var dayOfMonth by remember(schedule) { mutableStateOf(parsed.dayOfMonth) }
|
||||
var month by remember(schedule) { mutableStateOf(parsed.month) }
|
||||
var isAdvanced by remember { mutableStateOf(parsed.mode == ScheduleMode.ADVANCED) }
|
||||
var repeatExpanded by remember { mutableStateOf(false) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
fun emitSchedule(
|
||||
newMode: ScheduleMode,
|
||||
newHour: Int = hour,
|
||||
newMinute: Int = minute,
|
||||
newIntervalCount: Int = intervalCount,
|
||||
newIntervalUnit: String = intervalUnit,
|
||||
newDays: Set<Int> = selectedDays,
|
||||
newDom: Int = dayOfMonth,
|
||||
newMonth: Int = month,
|
||||
) {
|
||||
onScheduleChange(ParsedSchedule(
|
||||
mode = newMode, hour = newHour, minute = newMinute,
|
||||
intervalCount = newIntervalCount, intervalUnit = newIntervalUnit,
|
||||
selectedDays = newDays, dayOfMonth = newDom, month = newMonth, raw = schedule,
|
||||
).toSchedule())
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_edit_field_schedule),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
isAdvanced = !isAdvanced
|
||||
if (isAdvanced) {
|
||||
mode = ScheduleMode.ADVANCED
|
||||
} else {
|
||||
val fallback = parseSchedule(schedule)
|
||||
mode = fallback.mode
|
||||
hour = fallback.hour
|
||||
minute = fallback.minute
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAdvanced) Icons.Filled.Schedule else Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text =
|
||||
if (isAdvanced) {
|
||||
stringResource(R.string.cron_schedule_visual_toggle)
|
||||
} else {
|
||||
stringResource(R.string.cron_schedule_advanced_toggle)
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdvanced) {
|
||||
OutlinedTextField(
|
||||
value = schedule,
|
||||
onValueChange = onScheduleChange,
|
||||
label = { Text(stringResource(R.string.cron_edit_field_schedule)) },
|
||||
placeholder = { Text(stringResource(R.string.cron_edit_hint_schedule)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val repeatOptions = listOf(
|
||||
ScheduleMode.DAILY to R.string.cron_schedule_mode_daily,
|
||||
ScheduleMode.HOURLY to R.string.cron_repeat_hourly,
|
||||
ScheduleMode.MINUTELY to R.string.cron_repeat_minutely,
|
||||
ScheduleMode.WEEKLY to R.string.cron_schedule_mode_weekly,
|
||||
ScheduleMode.MONTHLY to R.string.cron_schedule_mode_monthly,
|
||||
ScheduleMode.YEARLY to R.string.cron_repeat_yearly,
|
||||
ScheduleMode.INTERVAL to R.string.cron_schedule_mode_interval,
|
||||
)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = repeatExpanded,
|
||||
onExpandedChange = { repeatExpanded = it },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = stringResource(repeatOptions.firstOrNull { it.first == mode }?.second
|
||||
?: R.string.cron_schedule_advanced_toggle),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.cron_repeat_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = repeatExpanded) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = repeatExpanded, onDismissRequest = { repeatExpanded = false }) {
|
||||
repeatOptions.forEach { (option, label) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(label)) },
|
||||
onClick = {
|
||||
mode = option
|
||||
repeatExpanded = false
|
||||
emitSchedule(option)
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.cron_repeat_seconds_unavailable)) },
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
ScheduleMode.DAILY -> {
|
||||
TimeDisplayCard(
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
onClick = { showTimePicker = true },
|
||||
)
|
||||
}
|
||||
|
||||
ScheduleMode.WEEKLY -> {
|
||||
TimeDisplayCard(
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
onClick = { showTimePicker = true },
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedDays = setOf(1, 2, 3, 4, 5)
|
||||
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.cron_schedule_weekdays_workdays), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedDays = setOf(6, 0)
|
||||
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.cron_schedule_weekdays_weekend), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedDays = setOf(1, 2, 3, 4, 5, 6, 0)
|
||||
emitSchedule(ScheduleMode.WEEKLY, newDays = selectedDays)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.cron_schedule_weekdays_all), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
|
||||
val daysConfig =
|
||||
listOf(
|
||||
1 to R.string.cron_dow_mon,
|
||||
2 to R.string.cron_dow_tue,
|
||||
3 to R.string.cron_dow_wed,
|
||||
4 to R.string.cron_dow_thu,
|
||||
5 to R.string.cron_dow_fri,
|
||||
6 to R.string.cron_dow_sat,
|
||||
0 to R.string.cron_dow_sun,
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
daysConfig.forEach { (d, labelRes) ->
|
||||
val isSelected = selectedDays.contains(d)
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
val nextDays =
|
||||
if (isSelected) {
|
||||
if (selectedDays.size > 1) selectedDays - d else selectedDays
|
||||
} else {
|
||||
selectedDays + d
|
||||
}
|
||||
selectedDays = nextDays
|
||||
emitSchedule(ScheduleMode.WEEKLY, newDays = nextDays)
|
||||
},
|
||||
label = { Text(stringResource(labelRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleMode.INTERVAL -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
ExposedDropdownField(
|
||||
label = stringResource(R.string.cron_repeat_count),
|
||||
options = ((1..120).toList() + intervalCount).distinct().sorted().map { it.toString() },
|
||||
selectedValue = intervalCount.toString(),
|
||||
onOptionSelected = {
|
||||
intervalCount = it.toInt()
|
||||
emitSchedule(mode, newIntervalCount = intervalCount)
|
||||
},
|
||||
)
|
||||
val units = listOf("m", "h", "d")
|
||||
val labels = listOf(stringResource(R.string.cron_unit_minute),
|
||||
stringResource(R.string.cron_unit_hour), stringResource(R.string.cron_unit_day))
|
||||
ExposedDropdownField(
|
||||
label = stringResource(R.string.cron_repeat_unit),
|
||||
options = labels,
|
||||
selectedValue = labels[units.indexOf(intervalUnit)],
|
||||
onOptionSelected = {
|
||||
intervalUnit = units[labels.indexOf(it)]
|
||||
emitSchedule(mode, newIntervalUnit = intervalUnit)
|
||||
},
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.cron_schedule_interval_presets),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val presets =
|
||||
listOf(
|
||||
Triple(1, "m", "1 " + stringResource(R.string.cron_unit_minute)),
|
||||
Triple(15, "m", "15 " + stringResource(R.string.cron_unit_minute)),
|
||||
Triple(30, "m", "30 " + stringResource(R.string.cron_unit_minute)),
|
||||
Triple(1, "h", "1 " + stringResource(R.string.cron_unit_hour)),
|
||||
Triple(2, "h", "2 " + stringResource(R.string.cron_unit_hour)),
|
||||
Triple(6, "h", "6 " + stringResource(R.string.cron_unit_hour)),
|
||||
Triple(12, "h", "12 " + stringResource(R.string.cron_unit_hour)),
|
||||
Triple(1, "d", "1 " + stringResource(R.string.cron_unit_day)),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
presets.forEach { (cnt, unt, title) ->
|
||||
val isSelected = intervalCount == cnt && intervalUnit == unt
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
intervalCount = cnt
|
||||
intervalUnit = unt
|
||||
emitSchedule(ScheduleMode.INTERVAL, newIntervalCount = cnt, newIntervalUnit = unt)
|
||||
},
|
||||
label = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleMode.MONTHLY, ScheduleMode.YEARLY -> {
|
||||
if (mode == ScheduleMode.YEARLY) {
|
||||
val months = java.text.DateFormatSymbols(LocalConfiguration.current.locales[0]).months.take(12)
|
||||
ExposedDropdownField(
|
||||
label = stringResource(R.string.cron_repeat_month),
|
||||
options = months,
|
||||
selectedValue = months[month - 1],
|
||||
onOptionSelected = {
|
||||
month = months.indexOf(it) + 1
|
||||
dayOfMonth = dayOfMonth.coerceAtMost(java.time.Month.of(month).maxLength())
|
||||
emitSchedule(mode, newDom = dayOfMonth, newMonth = month)
|
||||
},
|
||||
)
|
||||
}
|
||||
TimeDisplayCard(
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
onClick = { showTimePicker = true },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_schedule_day_of_month_label, dayOfMonth),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (dayOfMonth > 1) {
|
||||
dayOfMonth--
|
||||
emitSchedule(mode, newDom = dayOfMonth)
|
||||
}
|
||||
},
|
||||
enabled = dayOfMonth > 1,
|
||||
) {
|
||||
Text("-", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Text(
|
||||
text = dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
val maxDay = if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31
|
||||
if (dayOfMonth < maxDay) {
|
||||
dayOfMonth++
|
||||
emitSchedule(mode, newDom = dayOfMonth)
|
||||
}
|
||||
},
|
||||
enabled = dayOfMonth < (if (mode == ScheduleMode.YEARLY) java.time.Month.of(month).maxLength() else 31),
|
||||
) {
|
||||
Text("+", style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleMode.HOURLY -> {
|
||||
ExposedDropdownField(
|
||||
label = stringResource(R.string.cron_repeat_minute),
|
||||
options = (0..59).map { it.toString().padStart(2, '0') },
|
||||
selectedValue = minute.toString().padStart(2, '0'),
|
||||
onOptionSelected = { minute = it.toInt(); emitSchedule(mode, newMinute = minute) },
|
||||
)
|
||||
}
|
||||
ScheduleMode.MINUTELY, ScheduleMode.ADVANCED -> Unit
|
||||
}
|
||||
if ((mode == ScheduleMode.MONTHLY && dayOfMonth > 28) ||
|
||||
(mode == ScheduleMode.YEARLY && month == 2 && dayOfMonth == 29)) {
|
||||
Text(stringResource(R.string.cron_repeat_calendar_note), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
// Live Summary Card
|
||||
Text(
|
||||
stringResource(R.string.cron_schedule_timezone_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = scheduleSummary(parsed, schedule),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
val timePickerState =
|
||||
rememberTimePickerState(
|
||||
initialHour = hour,
|
||||
initialMinute = minute,
|
||||
is24Hour = true,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = { showTimePicker = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
hour = timePickerState.hour
|
||||
minute = timePickerState.minute
|
||||
showTimePicker = false
|
||||
emitSchedule(mode, newHour = timePickerState.hour, newMinute = timePickerState.minute)
|
||||
},
|
||||
) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showTimePicker = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TimePicker(state = timePickerState)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeDisplayCard(
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
OutlinedCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.cron_schedule_select_time),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = String.format(java.util.Locale.ROOT, "%02d:%02d", hour, minute),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.cron_action_edit),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun scheduleSummary(parsed: ParsedSchedule, raw: String): String {
|
||||
val time = java.lang.String.format(java.util.Locale.ROOT, "%02d:%02d", parsed.hour, parsed.minute)
|
||||
return when (parsed.mode) {
|
||||
ScheduleMode.DAILY -> stringResource(R.string.cron_summary_daily, time)
|
||||
ScheduleMode.HOURLY -> stringResource(R.string.cron_summary_hourly, parsed.minute)
|
||||
ScheduleMode.MINUTELY -> stringResource(R.string.cron_repeat_minutely)
|
||||
ScheduleMode.WEEKLY -> {
|
||||
val days = listOf(R.string.cron_dow_sun, R.string.cron_dow_mon, R.string.cron_dow_tue,
|
||||
R.string.cron_dow_wed, R.string.cron_dow_thu, R.string.cron_dow_fri, R.string.cron_dow_sat)
|
||||
val names = parsed.selectedDays.sorted().map { stringResource(days[it]) }.joinToString(", ")
|
||||
stringResource(R.string.cron_summary_weekly, names, time)
|
||||
}
|
||||
ScheduleMode.MONTHLY -> stringResource(R.string.cron_summary_monthly, parsed.dayOfMonth, time)
|
||||
ScheduleMode.YEARLY -> stringResource(R.string.cron_summary_yearly, parsed.dayOfMonth, parsed.month, time)
|
||||
ScheduleMode.INTERVAL -> {
|
||||
val unit = stringResource(when (parsed.intervalUnit) {
|
||||
"m" -> R.string.cron_unit_minute
|
||||
"h" -> R.string.cron_unit_hour
|
||||
else -> R.string.cron_unit_day
|
||||
})
|
||||
stringResource(R.string.cron_summary_interval, parsed.intervalCount, unit)
|
||||
}
|
||||
ScheduleMode.ADVANCED -> raw
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,8 @@ class CronJobsViewModel :
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
// The server may have started the job even if its reply is lost.
|
||||
safeApiCall(retries = 0) { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.instantiateBlueprint(
|
||||
InstantiateBlueprintRequest(
|
||||
blueprint = editor.selectedBlueprintKey,
|
||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
||||
*/
|
||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||
val createdResult =
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.createCronJob(
|
||||
CreateCronJobRequest(
|
||||
name = editor.name,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
internal enum class ScheduleMode {
|
||||
HOURLY,
|
||||
MINUTELY,
|
||||
YEARLY,
|
||||
DAILY,
|
||||
WEEKLY,
|
||||
INTERVAL,
|
||||
MONTHLY,
|
||||
ADVANCED,
|
||||
}
|
||||
|
||||
internal data class ParsedSchedule(
|
||||
val mode: ScheduleMode,
|
||||
val hour: Int = 9,
|
||||
val minute: Int = 0,
|
||||
val intervalCount: Int = 1,
|
||||
val intervalUnit: String = "h",
|
||||
val selectedDays: Set<Int> = setOf(1),
|
||||
val dayOfMonth: Int = 1,
|
||||
val month: Int = 1,
|
||||
val raw: String = "",
|
||||
)
|
||||
|
||||
internal fun parseSchedule(schedule: String): ParsedSchedule {
|
||||
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
||||
if (clean.isBlank()) {
|
||||
return ParsedSchedule(mode = ScheduleMode.DAILY, hour = 9, minute = 0)
|
||||
}
|
||||
|
||||
val everyMatch = Regex("""^every\s+(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(clean)
|
||||
if (everyMatch != null) {
|
||||
val count = everyMatch.groupValues[1].toIntOrNull()
|
||||
?: return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||
if (count <= 0) return ParsedSchedule(ScheduleMode.ADVANCED, raw = schedule)
|
||||
val unit = everyMatch.groupValues[2].lowercase()
|
||||
return ParsedSchedule(
|
||||
mode = ScheduleMode.INTERVAL,
|
||||
intervalCount = count,
|
||||
intervalUnit = unit,
|
||||
)
|
||||
}
|
||||
|
||||
val minStepMatch = Regex("""^\*/(\d+)\s+\*\s+\*\s+\*\s+\*$""").find(clean)
|
||||
if (minStepMatch != null) {
|
||||
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||
}
|
||||
|
||||
val hourStepMatch = Regex("""^0\s+\*/(\d+)\s+\*\s+\*\s+\*$""").find(clean)
|
||||
if (hourStepMatch != null) {
|
||||
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||
}
|
||||
|
||||
val parts = clean.split(" ")
|
||||
if (clean == "* * * * *") return ParsedSchedule(mode = ScheduleMode.MINUTELY)
|
||||
if (parts.size == 5 && parts.drop(1).all { it == "*" }) {
|
||||
val minute = parts[0].toIntOrNull()
|
||||
if (minute != null && minute in 0..59) return ParsedSchedule(mode = ScheduleMode.HOURLY, minute = minute)
|
||||
}
|
||||
if (parts.size == 5) {
|
||||
val minute = parts[0].toIntOrNull()
|
||||
val hour = parts[1].toIntOrNull()
|
||||
val dom = parts[2]
|
||||
val month = parts[3]
|
||||
val dow = parts[4]
|
||||
|
||||
if (minute != null && hour != null && minute in 0..59 && hour in 0..23) {
|
||||
val monthNumber = month.toIntOrNull()
|
||||
val dayNumber = dom.toIntOrNull()
|
||||
if (monthNumber != null && monthNumber in 1..12 && dayNumber != null &&
|
||||
dayNumber in 1..java.time.Month.of(monthNumber).maxLength() && dow == "*") {
|
||||
return ParsedSchedule(ScheduleMode.YEARLY, hour = hour, minute = minute,
|
||||
dayOfMonth = dayNumber, month = monthNumber)
|
||||
}
|
||||
if (dom == "*" && month == "*" && dow == "*") {
|
||||
return ParsedSchedule(
|
||||
mode = ScheduleMode.DAILY,
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
)
|
||||
}
|
||||
if (dom == "*" && month == "*" && dow != "*") {
|
||||
val days = parseDowList(dow)
|
||||
if (days.isNotEmpty()) {
|
||||
return ParsedSchedule(
|
||||
mode = ScheduleMode.WEEKLY,
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
selectedDays = days.toSet(),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (dom != "*" && month == "*" && dow == "*") {
|
||||
val domInt = dom.toIntOrNull()
|
||||
if (domInt != null && domInt in 1..31) {
|
||||
return ParsedSchedule(
|
||||
mode = ScheduleMode.MONTHLY,
|
||||
hour = hour,
|
||||
minute = minute,
|
||||
dayOfMonth = domInt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedSchedule(mode = ScheduleMode.ADVANCED, raw = schedule)
|
||||
}
|
||||
|
||||
internal fun ParsedSchedule.toSchedule(): String {
|
||||
require(hour in 0..23 && minute in 0..59)
|
||||
require(dayOfMonth in 1..31 && month in 1..12)
|
||||
return when (mode) {
|
||||
ScheduleMode.DAILY -> "$minute $hour * * *"
|
||||
ScheduleMode.HOURLY -> "$minute * * * *"
|
||||
ScheduleMode.MINUTELY -> "* * * * *"
|
||||
ScheduleMode.WEEKLY -> {
|
||||
require(selectedDays.isNotEmpty() && selectedDays.all { it in 0..6 })
|
||||
"$minute $hour * * ${selectedDays.sorted().joinToString(",")}"
|
||||
}
|
||||
ScheduleMode.MONTHLY -> "$minute $hour $dayOfMonth * *"
|
||||
ScheduleMode.YEARLY -> {
|
||||
require(dayOfMonth <= java.time.Month.of(month).maxLength())
|
||||
"$minute $hour $dayOfMonth $month *"
|
||||
}
|
||||
ScheduleMode.INTERVAL -> {
|
||||
require(intervalCount > 0 && intervalUnit in listOf("m", "h", "d"))
|
||||
"every $intervalCount$intervalUnit"
|
||||
}
|
||||
ScheduleMode.ADVANCED -> raw
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDowList(dow: String): List<Int> {
|
||||
if (dow.contains("-")) {
|
||||
val bounds = dow.split("-")
|
||||
if (bounds.size == 2) {
|
||||
val start = bounds[0].toIntOrNull() ?: return emptyList()
|
||||
val end = bounds[1].toIntOrNull() ?: return emptyList()
|
||||
if (start in 0..7 && end in 0..7 && start <= end) {
|
||||
return (start..end).map { if (it == 7) 0 else it }.distinct().sorted()
|
||||
}
|
||||
}
|
||||
}
|
||||
val values = dow.split(",").map { it.trim().toIntOrNull() ?: return emptyList() }
|
||||
if (values.any { it !in 0..7 }) return emptyList()
|
||||
return values
|
||||
.map { if (it == 7) 0 else it }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,6 +32,7 @@ import com.m57.hermescontrol.R
|
||||
fun LandingScreen(
|
||||
onAuthLogin: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onAiturkPair: () -> Unit = {},
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
@@ -88,6 +90,16 @@ fun LandingScreen(
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onAiturkPair,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.landing_action_aiturk_pair),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onAuthLogin,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) {
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.m57.hermescontrol.SettingsConnection
|
||||
import com.m57.hermescontrol.SettingsLanguage
|
||||
import com.m57.hermescontrol.SettingsVault
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
||||
@@ -74,7 +75,7 @@ fun SettingsScreen(
|
||||
) {
|
||||
SettingsCategoryCard(
|
||||
items =
|
||||
listOf(
|
||||
listOfNotNull(
|
||||
SettingsRow(
|
||||
icon = Icons.AutoMirrored.Filled.ListAlt,
|
||||
label = stringResource(R.string.settings_sec_connection),
|
||||
@@ -94,7 +95,7 @@ fun SettingsScreen(
|
||||
label = stringResource(R.string.screen_account_connectors),
|
||||
summary = stringResource(R.string.account_connectors_summary),
|
||||
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
||||
),
|
||||
).takeIf { AiturkFeatureGate.shows(AccountConnectorsScreen) },
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Palette,
|
||||
label = stringResource(R.string.settings_sec_appearance),
|
||||
@@ -136,7 +137,7 @@ fun SettingsScreen(
|
||||
label = stringResource(R.string.settings_sec_vault),
|
||||
summary = stringResource(R.string.settings_summary_vault),
|
||||
onClick = { NavigationController.navigateTo(SettingsVault) },
|
||||
),
|
||||
).takeIf { AiturkFeatureGate.shows(SettingsVault) },
|
||||
SettingsRow(
|
||||
icon = Icons.Filled.Info,
|
||||
label = stringResource(R.string.settings_sec_about),
|
||||
|
||||
@@ -24,9 +24,12 @@ import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.update.AppUpdateState
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.ui.settings.InfoRow
|
||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
|
||||
private const val AITURK_DOWNLOAD_URL = "https://turkservis.online/indir"
|
||||
|
||||
@Composable
|
||||
internal fun AboutSection(
|
||||
updateState: AppUpdateState = AppUpdateState.Idle,
|
||||
@@ -55,21 +58,31 @@ internal fun AboutSection(
|
||||
label = stringResource(R.string.settings_about_version),
|
||||
value = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
)
|
||||
UpdateRow(
|
||||
state = updateState,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
ReleaseCandidateRow(
|
||||
checked = checkReleaseCandidateUpdates,
|
||||
// Mid-download / mid-install the channel must not flip under the
|
||||
// in-flight APK.
|
||||
enabled =
|
||||
updateState !is AppUpdateState.Downloading &&
|
||||
updateState !is AppUpdateState.Installing,
|
||||
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
||||
)
|
||||
// AITURK: the upstream GitHub self-updater is switched off
|
||||
// (UpdateNoticeManager.enabled = false); new builds ship from the
|
||||
// AITURK download page instead.
|
||||
if (UpdateNoticeManager.enabled) {
|
||||
UpdateRow(
|
||||
state = updateState,
|
||||
onCheckUpdate = onCheckUpdate,
|
||||
onStartUpdate = onStartUpdate,
|
||||
onOpenInstallSettings = onOpenInstallSettings,
|
||||
)
|
||||
ReleaseCandidateRow(
|
||||
checked = checkReleaseCandidateUpdates,
|
||||
// Mid-download / mid-install the channel must not flip under the
|
||||
// in-flight APK.
|
||||
enabled =
|
||||
updateState !is AppUpdateState.Downloading &&
|
||||
updateState !is AppUpdateState.Installing,
|
||||
onCheckedChange = onCheckReleaseCandidateUpdatesChange,
|
||||
)
|
||||
} else {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.settings_about_download),
|
||||
value = AITURK_DOWNLOAD_URL,
|
||||
)
|
||||
}
|
||||
InfoRow(
|
||||
label = stringResource(R.string.settings_about_build),
|
||||
value =
|
||||
@@ -87,6 +100,14 @@ internal fun AboutSection(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_attribution),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "https://github.com/Hy4ri/hermes-mobile",
|
||||
style =
|
||||
|
||||
@@ -32,6 +32,7 @@ internal fun supportedLanguages(): List<Pair<String, String>> =
|
||||
listOf(
|
||||
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
|
||||
"en" to stringResource(R.string.language_english),
|
||||
"tr" to stringResource(R.string.language_turkish),
|
||||
"ar" to stringResource(R.string.language_arabic),
|
||||
"zh" to stringResource(R.string.language_chinese),
|
||||
"ko" to stringResource(R.string.language_korean),
|
||||
|
||||
@@ -24,6 +24,22 @@ object CronExpressionFormatter {
|
||||
|
||||
fun cronToHumanReadable(schedule: String): String {
|
||||
val clean = schedule.trim().replace("\\s+".toRegex(), " ")
|
||||
if (clean.startsWith("every ", ignoreCase = true)) {
|
||||
val rest = clean.substring(6).trim()
|
||||
val match = Regex("""^(\d+)\s*([mhd])$""", RegexOption.IGNORE_CASE).find(rest)
|
||||
if (match != null) {
|
||||
val num = match.groupValues[1].toIntOrNull() ?: 1
|
||||
val unit = match.groupValues[2].lowercase()
|
||||
val unitStr = when (unit) {
|
||||
"m" -> if (num == 1) "minute" else "minutes"
|
||||
"h" -> if (num == 1) "hour" else "hours"
|
||||
"d" -> if (num == 1) "day" else "days"
|
||||
else -> unit
|
||||
}
|
||||
return "Every " + num + " " + unitStr
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
val parts = clean.split(" ")
|
||||
if (parts.size != 5) {
|
||||
return schedule
|
||||
@@ -134,9 +150,13 @@ object CronExpressionFormatter {
|
||||
}
|
||||
|
||||
val dows = parseField(dowPart, 0, 7)
|
||||
if (dows.size == 1) {
|
||||
val dowName = dowNames[dows[0]]
|
||||
return "Every $dowName at $timeStr"
|
||||
if (dows.isNotEmpty()) {
|
||||
val names = dows.map { dowNames[it] }.distinct()
|
||||
return if (names.size == 1) {
|
||||
"Every " + names[0] + " at " + timeStr
|
||||
} else {
|
||||
"Every " + names.joinToString(", ") + " at " + timeStr
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Solid brand background -->
|
||||
<!-- Solid AITURK brand background -->
|
||||
<path
|
||||
android:fillColor="#1B1B2F"
|
||||
android:fillColor="#081525"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
@@ -3,23 +3,18 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Left pillar of H -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M30,22 L46,22 Q48,22 48,24 L48,84 Q48,86 46,86 L30,86 Q28,86 28,84 L28,24 Q28,22 30,22 Z" />
|
||||
|
||||
<!-- Right pillar of H — extends upward into a wing/swoop -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M66,30 L80,30 Q82,30 82,32 L82,84 Q82,86 80,86 L66,86 Q64,86 64,84 L64,32 Q64,30 66,30 Z" />
|
||||
|
||||
<!-- Crossbar of H -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M38,48 L72,48 Q74,48 74,50 L74,58 Q74,60 72,60 L38,60 Q36,60 36,58 L36,50 Q36,48 38,48 Z" />
|
||||
|
||||
<!-- Wing swoop — extends from right pillar top, curving upward and outward -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M72,30 Q84,14 96,10 Q100,8 98,14 Q94,26 82,30 Z" />
|
||||
<!-- AITURK "A" mark (source: AITURK IDE aiturk_icon, 256 viewport), scaled
|
||||
into the 66dp adaptive-icon safe zone and centred. -->
|
||||
<group
|
||||
android:scaleX="0.238"
|
||||
android:scaleY="0.238"
|
||||
android:translateX="23.536"
|
||||
android:translateY="24.488">
|
||||
<path
|
||||
android:fillColor="#24D4C6"
|
||||
android:pathData="M128,30L230,218H185L164,178H92L71,218H26L128,30ZM128,109L106,151H150L128,109Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M128,58L148,96H108L128,58Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
<string name="chat_send_desc">إرسال</string>
|
||||
<string name="chat_mic_listening">جارٍ الاستماع…</string>
|
||||
<string name="stt_permission_denied">يلزم الحصول على إذن الميكروفون للإدخال الصوتي</string>
|
||||
@@ -57,7 +57,7 @@
|
||||
<string name="billing_plan">الخطة</string>
|
||||
<string name="billing_topup">شحن الرصيد</string>
|
||||
<string name="press_back_again_to_exit">اضغط رجوع مرة أخرى للخروج</string>
|
||||
<string name="nav_drawer_title">Hermes</string>
|
||||
<string name="nav_drawer_title">AITURK</string>
|
||||
<string name="nav_drawer_subtitle">التحكم في وكيل الذكاء الاصطناعي</string>
|
||||
<string name="nav_drawer_section_converse">محادثة</string>
|
||||
<string name="nav_drawer_section_automate">أتمتة</string>
|
||||
@@ -127,27 +127,27 @@
|
||||
<string name="screen_analytics">التحليلات</string>
|
||||
<string name="screen_billing">الفواتير والاشتراكات</string>
|
||||
<string name="screen_files">الملفات</string>
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">بانتظار ردود Hermes</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">بانتظار ردود AITURK</string>
|
||||
<string name="notif_connected_in_background">متصل في الخلفية</string>
|
||||
<string name="notif_action_turn_off">إيقاف</string>
|
||||
<string name="notif_connecting">جارٍ الاتصال…</string>
|
||||
<string name="notif_waiting_network">في انتظار الشبكة</string>
|
||||
<string name="notif_reconnecting">جارٍ إعادة الاتصال</string>
|
||||
<string name="notif_new_message">رسالة جديدة</string>
|
||||
<string name="notif_clarification_needed">يحتاج Hermes إلى توضيح</string>
|
||||
<string name="notif_clarification_needed">يحتاج AITURK إلى توضيح</string>
|
||||
<string name="notif_reply_placeholder">اكتب ردك\u2026</string>
|
||||
<string name="notif_channel_service_name">خدمة Hermes في الخلفية</string>
|
||||
<string name="notif_channel_service_name">خدمة AITURK في الخلفية</string>
|
||||
<string name="notif_channel_service_desc">تحافظ على استمرار الاتصال في الخلفية</string>
|
||||
<string name="notif_channel_chat_name">ردود محادثة Hermes</string>
|
||||
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من Hermes</string>
|
||||
<string name="notif_channel_chat_name">ردود محادثة AITURK</string>
|
||||
<string name="notif_channel_chat_desc">تنبيهات عند استلام ردود جديدة من AITURK</string>
|
||||
<string name="action_reply">رد</string>
|
||||
<string name="bot_chat_never_resets_notice">محادثات البوت هي محادثة مستمرة واحدة — يتم ضغطها بدلاً من ذلك. للحصول على جلسة مؤقتة، أنشئ محادثة قياسية.</string>
|
||||
<string name="chat_action_search">بحث</string>
|
||||
<string name="chat_action_close_search">إغلاق البحث</string>
|
||||
<string name="chat_action_reconnect">إعادة الاتصال</string>
|
||||
<string name="chat_action_relogin">إعادة تسجيل الدخول</string>
|
||||
<string name="chat_relogin_title">إعادة تسجيل الدخول إلى Hermes</string>
|
||||
<string name="chat_relogin_title">إعادة تسجيل الدخول إلى AITURK</string>
|
||||
<string name="chat_relogin_username">اسم المستخدم</string>
|
||||
<string name="chat_relogin_password">كلمة المرور</string>
|
||||
<string name="chat_relogin_submit">تسجيل الدخول</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="chat_relogin_error_empty">لا يمكن ترك اسم المستخدم وكلمة المرور فارغين</string>
|
||||
<string name="chat_empty_title">جاهز للدردشة</string>
|
||||
<string name="chat_empty_subtitle">أرسل رسالة لبدء محادثة</string>
|
||||
<string name="chat_status_connecting">جارٍ الاتصال بـ Hermes\u2026</string>
|
||||
<string name="chat_status_connecting">جارٍ الاتصال بـ AITURK\u2026</string>
|
||||
<string name="chat_resume_error_title">تعذر الاتصال بهذه الجلسة</string>
|
||||
<string name="chat_status_reconnecting">جارٍ إعادة الاتصال\u2026</string>
|
||||
<string name="chat_status_disconnected">تم قطع الاتصال</string>
|
||||
@@ -227,9 +227,7 @@
|
||||
<string name="settings_action_test_connection">اختبار الاتصال</string>
|
||||
<string name="settings_sec_about">حول التطبيق</string>
|
||||
<string name="settings_sec_language">اللغة</string>
|
||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
||||
<string name="settings_support_title">ادعم Hermes Mobile</string>
|
||||
<string name="settings_support_message">إذا وفّر لك Hermes Mobile الوقت، يمكنك دعم تطويره.</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">ادعمني على Ko-fi</string>
|
||||
<string name="settings_about_version">الإصدار</string>
|
||||
<string name="settings_about_build">رقم البنية</string>
|
||||
@@ -289,7 +287,7 @@
|
||||
<string name="connect_selected_profile">الملف التعريفي: %1$s</string>
|
||||
<string name="connect_action_select_profile">تحديد ملف تعريفي محفوظ</string>
|
||||
<string name="connect_health_title">حالة الخادم الخلفي</string>
|
||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||
<string name="connect_health_auth_gated">محمي (OAuth)</string>
|
||||
<string name="connect_health_auth_token">Token</string>
|
||||
<string name="connect_error_token_required">رمز Token مطلوب</string>
|
||||
@@ -297,7 +295,7 @@
|
||||
<string name="connect_error_403">تم رفض الوصول (403 Forbidden)</string>
|
||||
<string name="connect_error_http_code">استجاب الخادم برمز HTTP %1$d</string>
|
||||
<string name="connect_error_timeout">انتهت مهلة الاتصال</string>
|
||||
<string name="connect_error_refused">تم رفض الاتصال – هل خادم Hermes قيد التشغيل؟</string>
|
||||
<string name="connect_error_refused">تم رفض الاتصال – هل خادم AITURK قيد التشغيل؟</string>
|
||||
<string name="connect_error_resolve">تعذر حل اسم المضيف</string>
|
||||
<string name="connect_error_connection_failed">فشل الاتصال: %1$s</string>
|
||||
<string name="connect_save_profile">حفظ الملف التعريفي للاتصال</string>
|
||||
@@ -500,7 +498,7 @@
|
||||
<string name="skills_hub_source_offline">غير متصل</string>
|
||||
<string name="skills_hub_source_via_index">عبر الفهرس</string>
|
||||
<string name="skills_hub_featured_title">مهارات مميزة</string>
|
||||
<string name="skills_hub_featured_subtitle">من فهرس Hermes — ابحث أعلاه للمزيد</string>
|
||||
<string name="skills_hub_featured_subtitle">من فهرس AITURK — ابحث أعلاه للمزيد</string>
|
||||
<string name="skills_hub_landing_hint">ابحث في المركز أعلاه لتصفح المهارات القابلة للتثبيت من المصادر المتصلة</string>
|
||||
<string name="skills_hub_scan">فحص</string>
|
||||
<string name="skills_hub_scan_verdict_label">النتيجة: %1$s</string>
|
||||
@@ -529,7 +527,7 @@
|
||||
<string name="content_desc_update_skills_hub">تحديث المهارات من المركز</string>
|
||||
<string name="content_desc_save_changes">حفظ التغييرات</string>
|
||||
<string name="logs_empty_title">لا توجد سجلات متاحة</string>
|
||||
<string name="logs_empty_desc">ستظهر مخرجات السجل من Hermes هنا.</string>
|
||||
<string name="logs_empty_desc">ستظهر مخرجات السجل من AITURK هنا.</string>
|
||||
<string name="logs_action_pause">إيقاف مؤقت</string>
|
||||
<string name="logs_action_resume">استئناف</string>
|
||||
<string name="logs_action_export">مشاركة</string>
|
||||
@@ -552,7 +550,7 @@
|
||||
<string name="system_label_arch">المعمارية</string>
|
||||
<string name="system_label_hostname">اسم المضيف</string>
|
||||
<string name="system_label_python">Python</string>
|
||||
<string name="system_label_hermes_version">Hermes</string>
|
||||
<string name="system_label_hermes_version">AITURK</string>
|
||||
<string name="system_label_cpu">وحدة المعالجة المركزية (CPU)</string>
|
||||
<string name="system_label_memory">الذاكرة (RAM)</string>
|
||||
<string name="system_label_disk">القرص</string>
|
||||
@@ -566,7 +564,7 @@
|
||||
<string name="system_version_behind">متأخر بمقدار %1$d</string>
|
||||
<string name="system_action_check_updates">التحقق من وجود تحديثات</string>
|
||||
<string name="system_action_update_now">تحديث الآن</string>
|
||||
<string name="system_update_confirm_title">هل تريد تحديث Hermes؟</string>
|
||||
<string name="system_update_confirm_title">هل تريد تحديث AITURK؟</string>
|
||||
<string name="system_update_confirm_desc">سيؤدي هذا إلى تشغيل أمر hermes update وإعادة تشغيل البوابة.</string>
|
||||
<string name="system_update_progress_title">تحديث البرنامج</string>
|
||||
<string name="system_sec_portal">بوابة Nous Portal</string>
|
||||
@@ -642,10 +640,10 @@
|
||||
<string name="system_sec_restore">استعادة من ملف تم رفعه</string>
|
||||
<string name="system_op_restore_upload">رفع واستعادة</string>
|
||||
<string name="system_op_restore_pick">اختيار أرشيف النسخة الاحتياطية</string>
|
||||
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ Hermes؟</string>
|
||||
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات Hermes الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||
<string name="system_op_import_confirm_title">هل تريد استعادة نسخة احتياطية كاملة لـ AITURK؟</string>
|
||||
<string name="system_op_import_confirm_desc">سيؤدي هذا إلى الكتابة فوق إعدادات AITURK الحالية والمهارات والجلسات والبيانات. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||
<string name="system_sec_debug_share">مشاركة بيانات تصحيح الأخطاء</string>
|
||||
<string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق Hermes. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string>
|
||||
<string name="system_debug_share_description">يرفع معلومات النظام والسجلات إلى خدمة لصق عامة ويُرجع روابط لإرسالها إلى فريق AITURK. يتم حذف النصوص الملصقة تلقائيًا بعد 6 ساعات.</string>
|
||||
<string name="system_debug_share_generate">إنشاء رابط المشاركة</string>
|
||||
<string name="system_debug_share_uploading">جارٍ الرفع…</string>
|
||||
<string name="system_debug_share_redact">تنقيح الرموز الشبيهة ببيانات الاعتماد قبل الرفع (موصى به)</string>
|
||||
@@ -704,7 +702,7 @@
|
||||
<item quantity="other">%1$d نموذج</item>
|
||||
</plurals>
|
||||
<string name="history_empty_title">لا يوجد سجل</string>
|
||||
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
|
||||
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من AITURK هنا.</string>
|
||||
<string name="history_untitled">بلا عنوان</string>
|
||||
<string name="history_message_count">%1$d رسالة</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -849,7 +847,7 @@
|
||||
<string name="config_action_save">حفظ</string>
|
||||
<string name="config_action_save_yaml">حفظ YAML</string>
|
||||
<string name="profiles_empty_title">لا توجد ملفات تعريف متاحة</string>
|
||||
<string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من Hermes هنا.</string>
|
||||
<string name="profiles_empty_desc">ستظهر ملفات تعريف الاتصال من AITURK هنا.</string>
|
||||
<string name="profiles_label_model">النموذج: %1$s</string>
|
||||
<string name="profiles_label_provider">المزود: %1$s</string>
|
||||
<string name="profiles_label_skills">%1$d مهارات</string>
|
||||
@@ -895,7 +893,7 @@
|
||||
<string name="profiles_delete_warning">سيؤدي هذا إلى حذف الملف الشخصي \'%1$s\' وبياناته نهائيًا.</string>
|
||||
<string name="profiles_delete_path">المسار: %1$s</string>
|
||||
<string name="profiles_delete_gateway_warning">يحتوي هذا الملف الشخصي على بوابة قيد التشغيل — سيؤدي حذفه إلى إيقافها.</string>
|
||||
<string name="profiles_setup_command_hint">شغّل هذا الأمر في Hermes CLI لتهيئة هذا الملف الشخصي.</string>
|
||||
<string name="profiles_setup_command_hint">شغّل هذا الأمر في AITURK CLI لتهيئة هذا الملف الشخصي.</string>
|
||||
<string name="profiles_builder_step_indicator">الخطوة %1$d من 5</string>
|
||||
<string name="profiles_builder_step_identity">الهوية</string>
|
||||
<string name="profiles_builder_step_model">تهيئة النموذج</string>
|
||||
@@ -913,7 +911,7 @@
|
||||
<string name="profiles_builder_skills_default_desc">تضمين أدوات النظام الشائعة تلقائيًا</string>
|
||||
<string name="profiles_builder_skills_local">تحديد المهارات المحلية</string>
|
||||
<string name="profiles_builder_skills_search_hub">البحث في الـ Hub</string>
|
||||
<string name="profiles_builder_skills_hub_title">إضافة مهارات من Hermes Hub</string>
|
||||
<string name="profiles_builder_skills_hub_title">إضافة مهارات من AITURK Hub</string>
|
||||
<string name="profiles_builder_skills_added">المهارات المضافة:</string>
|
||||
<string name="profiles_builder_skills_search_results">نتائج البحث:</string>
|
||||
<string name="profiles_builder_mcp_title">خوادم MCP المهيأة</string>
|
||||
@@ -1049,7 +1047,7 @@
|
||||
<string name="pressure_banner_memory_oom">أعاد الوكيل التشغيل بشكل غير متوقع — على الأرجح بسبب نفاد الذاكرة</string>
|
||||
<string name="pressure_banner_memory_elevated">ذاكرة الوكيل منخفضة</string>
|
||||
<string name="cron_empty_title">لا توجد مهام cron</string>
|
||||
<string name="cron_empty_desc">ستظهر المهام المجدولة من Hermes هنا.</string>
|
||||
<string name="cron_empty_desc">ستظهر المهام المجدولة من AITURK هنا.</string>
|
||||
<string name="cron_status_active">نشط</string>
|
||||
<string name="cron_status_paused">متوقف مؤقتاً</string>
|
||||
<string name="cron_action_pause">إيقاف مؤقت</string>
|
||||
@@ -1124,7 +1122,7 @@
|
||||
<string name="achievements_reset_title">هل تريد إعادة ضبط الإنجازات؟</string>
|
||||
<string name="achievements_reset_desc">سيؤدي هذا إلى مسح جميع بيانات الفتح والاستكشاف. ستتم إعادة فحص الإنجازات من البداية في الفحص التالي. لا يمكن التراجع عن هذا الإجراء.</string>
|
||||
<string name="keys_empty_title">لا توجد مفاتيح بيئة مُبلّغ عنها</string>
|
||||
<string name="keys_empty_desc">ستظهر مفاتيح البيئة من Hermes هنا.</string>
|
||||
<string name="keys_empty_desc">ستظهر مفاتيح البيئة من AITURK هنا.</string>
|
||||
<string name="keys_label_not_configured">غير مهيأ</string>
|
||||
<string name="keys_action_toggle_visibility">تبديل مستوى الرؤية</string>
|
||||
<string name="keys_search_placeholder">بحث في المفاتيح…</string>
|
||||
@@ -1165,7 +1163,7 @@
|
||||
<string name="files_dialog_delete_title">هل تريد الحذف؟</string>
|
||||
<string name="files_dialog_delete_message">هل أنت متأكد من رغبتك في حذف \"%1$s\"؟ لا يمكن التراجع عن هذا الإجراء.</string>
|
||||
<string name="channels_empty_title">لا توجد قنوات مهيأة</string>
|
||||
<string name="channels_empty_desc">أضف منصات المراسلة في Hermes لعرضها هنا.</string>
|
||||
<string name="channels_empty_desc">أضف منصات المراسلة في AITURK لعرضها هنا.</string>
|
||||
<string name="channels_action_configure">تهيئة</string>
|
||||
<string name="channels_sec_settings">إعدادات التهيئة</string>
|
||||
<string name="channels_action_save_settings">حفظ الإعدادات</string>
|
||||
@@ -1188,7 +1186,7 @@
|
||||
<string name="settings_saved_profiles">الملفات المحفوظة (%1$d)</string>
|
||||
<string name="settings_logout">تسجيل الخروج</string>
|
||||
<string name="auth_login_title">تسجيل الدخول والمصادقة</string>
|
||||
<string name="auth_login_desc">الاتصال بلوحة تحكم Hermes</string>
|
||||
<string name="auth_login_desc">الاتصال بلوحة تحكم AITURK</string>
|
||||
<string name="auth_login_base_url_label">رابط الخادم (URL)</string>
|
||||
<string name="auth_login_token_label">رمز الجلسة (Session Token)</string>
|
||||
<string name="auth_login_username_label">اسم المستخدم</string>
|
||||
@@ -1201,11 +1199,11 @@
|
||||
<string name="auth_login_error_password_required">كلمة المرور مطلوبة</string>
|
||||
<string name="auth_login_error_unreachable">تعذر الوصول إلى لوحة التحكم — تحقق من عنوان IP والمنفذ</string>
|
||||
<string name="auth_login_error_oauth_unsupported">تسجيل الدخول عبر OAuth (بوابة Nous) غير مدعوم في هذا الإصدار من التطبيق بعد. يرجى تبديل لوحة التحكم إلى المصادقة الأساسية (Basic Auth) أو وضع الرمز (Token). (المشكلة رقم 639#)</string>
|
||||
<string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في Hermes Mobile.</string>
|
||||
<string name="auth_login_oauth_coming_soon">تستخدم لوحة التحكم هذه تسجيل الدخول عبر OAuth (بوابة Nous)، والذي سيتوفر قريباً في AITURK Mobil.</string>
|
||||
<string name="auth_login_existing_profiles_title">استخدام ملف تعريف مسجل دخوله مسبقاً:</string>
|
||||
<string name="auth_login_use_profile">الدخول باسم %1$s</string>
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">لوحة التحكم لـ Hermes Agent</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">لوحة التحكم لـ AITURK</string>
|
||||
<string name="landing_action_auth_login">تسجيل الدخول والمصادقة</string>
|
||||
<string name="auth_init_failed">فشل في تهيئة إعدادات التطبيق</string>
|
||||
<string name="providers_empty_title">لا يوجد موفرو OAuth</string>
|
||||
@@ -1226,7 +1224,7 @@
|
||||
<string name="action_dismiss">تجاهل</string>
|
||||
<string name="action_done">تم</string>
|
||||
<string name="action_close">إغلاق</string>
|
||||
<string name="update_notice_banner_text">يتوفر إصدار جديد من Hermes Mobile %1$s</string>
|
||||
<string name="update_notice_banner_text">يتوفر إصدار جديد من AITURK Mobil %1$s</string>
|
||||
<string name="update_notice_banner_action">تحديث</string>
|
||||
<string name="update_dialog_title">التحديث إلى %1$s</string>
|
||||
<string name="update_dialog_changelog_header">ما الجديد</string>
|
||||
@@ -1316,7 +1314,7 @@
|
||||
<string name="context_window_usage_unavailable">استخدام نافذة السياق غير متوفر</string>
|
||||
<string name="gif_play">تشغيل GIF</string>
|
||||
<string name="kanban_filter_placeholder">تصفية المهام حسب العنوان أو الحالة أو المسؤول...</string>
|
||||
<string name="logs_clipboard_label">سجلات Hermes</string>
|
||||
<string name="logs_clipboard_label">سجلات AITURK</string>
|
||||
<string name="logs_search_placeholder">البحث في السجلات...</string>
|
||||
<string name="mcp_no_match">لا توجد خوادم تطابق \"%1$s\"</string>
|
||||
<string name="mcp_search_placeholder">البحث في خوادم MCP...</string>
|
||||
@@ -1554,7 +1552,7 @@
|
||||
<string name="connection_setup_open_browser">فتح في المتصفح</string>
|
||||
<string name="connection_setup_required">%1$s مطلوب</string>
|
||||
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
||||
<string name="connection_setup_pending">بانتظار Hermes…</string>
|
||||
<string name="connection_setup_pending">بانتظار AITURK…</string>
|
||||
<string name="connection_catalog_title">تثبيت %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">إضافة</string>
|
||||
<string name="connection_catalog_kind_skill">مهارة</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">헤르메스</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">전송</string>
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<!-- Navigation & Drawer -->
|
||||
<string name="press_back_again_to_exit">종료하려면 뒤로가기를 한 번 더 누르세요</string>
|
||||
<string name="nav_drawer_title">헤르메스</string>
|
||||
<string name="nav_drawer_title">AITURK</string>
|
||||
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
|
||||
<string name="nav_drawer_section_converse">대화</string>
|
||||
<string name="nav_drawer_section_automate">자동화</string>
|
||||
@@ -124,20 +124,20 @@
|
||||
<string name="screen_files">파일</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_title">헤르메스</string>
|
||||
<string name="notif_waiting_replies">헤르메스 응답 대기 중</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">AITURK 응답 대기 중</string>
|
||||
<string name="notif_connected_in_background">백그라운드에서 연결됨</string>
|
||||
<string name="notif_action_turn_off">끄기</string>
|
||||
<string name="notif_connecting">연결 중…</string>
|
||||
<string name="notif_waiting_network">네트워크 대기 중</string>
|
||||
<string name="notif_reconnecting">재연결 중</string>
|
||||
<string name="notif_new_message">새 메시지</string>
|
||||
<string name="notif_clarification_needed">헤르메스의 확인이 필요합니다</string>
|
||||
<string name="notif_clarification_needed">AITURK의 확인이 필요합니다</string>
|
||||
<string name="notif_reply_placeholder">답변 입력…</string>
|
||||
<string name="notif_channel_service_name">헤르메스 백그라운드 서비스</string>
|
||||
<string name="notif_channel_service_name">AITURK 백그라운드 서비스</string>
|
||||
<string name="notif_channel_service_desc">백그라운드에서 연결 유지</string>
|
||||
<string name="notif_channel_chat_name">헤르메스 채팅 응답</string>
|
||||
<string name="notif_channel_chat_desc">헤르메스의 새 응답 알림</string>
|
||||
<string name="notif_channel_chat_name">AITURK 채팅 응답</string>
|
||||
<string name="notif_channel_chat_desc">AITURK의 새 응답 알림</string>
|
||||
<string name="action_reply">답장</string>
|
||||
|
||||
<!-- Chat Screen & Bubble -->
|
||||
@@ -146,7 +146,7 @@
|
||||
<string name="chat_action_close_search">검색 닫기</string>
|
||||
<string name="chat_action_reconnect">재접속</string>
|
||||
<string name="chat_action_relogin">재로그인</string>
|
||||
<string name="chat_relogin_title">헤르메스 재로그인</string>
|
||||
<string name="chat_relogin_title">AITURK 재로그인</string>
|
||||
<string name="chat_relogin_username">사용자명</string>
|
||||
<string name="chat_relogin_password">비밀번호</string>
|
||||
<string name="chat_relogin_submit">로그인</string>
|
||||
@@ -154,7 +154,7 @@
|
||||
<string name="chat_relogin_error_empty">사용자명과 비밀번호를 입력해주세요</string>
|
||||
<string name="chat_empty_title">채팅 준비 완료</string>
|
||||
<string name="chat_empty_subtitle">메시지를 보내 대화를 시작하세요</string>
|
||||
<string name="chat_status_connecting">헤르메스에 연결 중…</string>
|
||||
<string name="chat_status_connecting">AITURK에 연결 중…</string>
|
||||
<string name="chat_status_reconnecting">재연결 중…</string>
|
||||
<string name="chat_status_disconnected">연결 끊김</string>
|
||||
<string name="chat_status_no_network">네트워크 없음</string>
|
||||
@@ -188,7 +188,7 @@
|
||||
<string name="settings_sec_connection">연결</string>
|
||||
<string name="settings_field_token">인증 토큰</string>
|
||||
<string name="settings_keep_connected_in_background_title">백그라운드에서 연결 유지</string>
|
||||
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 Hermes 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
|
||||
<string name="settings_keep_connected_in_background_desc">다른 앱을 사용하는 동안 AITURK 연결을 유지합니다. 상시 알림이 표시되며 배터리를 더 소모할 수 있습니다.</string>
|
||||
<string name="settings_sec_behavior">동작</string>
|
||||
<string name="settings_item_auto_reconnect">자동 재연결</string>
|
||||
<string name="settings_desc_auto_reconnect">연결 끊김 시 자동 재연결</string>
|
||||
@@ -229,9 +229,7 @@
|
||||
<string name="settings_chat_preview_agent">완료되었습니다! 여기에서 글꼴 크기를 조정할 수 있습니다.</string>
|
||||
<string name="settings_action_test_connection">연결 테스트</string>
|
||||
<string name="settings_sec_about">정보</string>
|
||||
<string name="settings_about_app_name">헤르메스 모바일</string>
|
||||
<string name="settings_support_title">Hermes Mobile 후원</string>
|
||||
<string name="settings_support_message">Hermes Mobile이 시간을 절약해 준다면 개발을 후원해 주세요.</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">Ko-fi에서 후원하기</string>
|
||||
<string name="settings_about_version">버전</string>
|
||||
<string name="settings_about_build">빌드</string>
|
||||
@@ -286,7 +284,7 @@
|
||||
<string name="connect_selected_profile">프로필: %1$s</string>
|
||||
<string name="connect_action_select_profile">저장된 프로필 선택</string>
|
||||
<string name="connect_health_title">백엔드 상태</string>
|
||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||
<string name="connect_health_auth_gated">게이트(OAuth)</string>
|
||||
<string name="connect_health_auth_token">토큰</string>
|
||||
|
||||
@@ -296,13 +294,13 @@
|
||||
<string name="connect_error_403">접근 거부 (403 Forbidden)</string>
|
||||
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</string>
|
||||
<string name="connect_error_timeout">연결 시간 초과</string>
|
||||
<string name="connect_error_refused">연결 거부됨 – Hermes가 실행 중인가요?</string>
|
||||
<string name="connect_error_refused">연결 거부됨 – AITURK가 실행 중인가요?</string>
|
||||
<string name="connect_error_resolve">호스트를 찾을 수 없음</string>
|
||||
<string name="connect_error_connection_failed">연결 실패: %1$s</string>
|
||||
|
||||
<string name="connect_save_profile">연결 프로필 저장</string>
|
||||
<string name="connect_profile_name">프로필 이름</string>
|
||||
<string name="connect_placeholder_profile_name">예: 집 헤르메스, 사무실 서버</string>
|
||||
<string name="connect_placeholder_profile_name">예: 집 AITURK, 사무실 서버</string>
|
||||
<string name="connect_action_hide_manual">수동 입력 숨기기</string>
|
||||
<string name="connect_action_show_manual">수동으로 입력</string>
|
||||
<string name="connect_placeholder_token">API 토큰</string>
|
||||
@@ -512,7 +510,7 @@
|
||||
|
||||
<!-- Logs Screen -->
|
||||
<string name="logs_empty_title">로그 없음</string>
|
||||
<string name="logs_empty_desc">Hermes의 로그 출력이 여기에 표시됩니다.</string>
|
||||
<string name="logs_empty_desc">AITURK의 로그 출력이 여기에 표시됩니다.</string>
|
||||
<string name="logs_action_pause">일시정지</string>
|
||||
<string name="logs_action_resume">재개</string>
|
||||
<string name="logs_action_export">공유</string>
|
||||
@@ -539,7 +537,7 @@
|
||||
<string name="system_label_arch">아키텍처</string>
|
||||
<string name="system_label_hostname">호스트명</string>
|
||||
<string name="system_label_python">파이썬</string>
|
||||
<string name="system_label_hermes_version">헤르메스</string>
|
||||
<string name="system_label_hermes_version">AITURK</string>
|
||||
<string name="system_label_cpu">CPU</string>
|
||||
<string name="system_label_memory">메모리</string>
|
||||
<string name="system_label_disk">디스크</string>
|
||||
@@ -553,8 +551,8 @@
|
||||
<string name="system_version_behind">%1$d 버전 뒤짐</string>
|
||||
<string name="system_action_check_updates">업데이트 확인</string>
|
||||
<string name="system_action_update_now">지금 업데이트</string>
|
||||
<string name="system_update_confirm_title">Hermes 업데이트?</string>
|
||||
<string name="system_update_confirm_desc">Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||
<string name="system_update_confirm_title">AITURK 업데이트?</string>
|
||||
<string name="system_update_confirm_desc">AITURK 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다.</string>
|
||||
<string name="system_migration_title">게이트웨이 마이그레이션</string>
|
||||
<string name="system_migration_description">프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요.</string>
|
||||
<string name="system_migration_profiles">프로필</string>
|
||||
@@ -703,7 +701,7 @@
|
||||
|
||||
<!-- Profiles Screen -->
|
||||
<string name="profiles_empty_title">프로필이 없습니다</string>
|
||||
<string name="profiles_empty_desc">Hermes의 연결 프로필이 여기에 표시됩니다.</string>
|
||||
<string name="profiles_empty_desc">AITURK의 연결 프로필이 여기에 표시됩니다.</string>
|
||||
<string name="profiles_label_model">모델: %1$s</string>
|
||||
<string name="profiles_label_provider">제공자: %1$s</string>
|
||||
<string name="profiles_label_skills">%1$d개 스킬</string>
|
||||
@@ -745,7 +743,7 @@
|
||||
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
|
||||
<string name="profiles_delete_path">경로: %1$s</string>
|
||||
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
|
||||
<string name="profiles_setup_command_hint">Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
|
||||
<string name="profiles_setup_command_hint">AITURK CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
|
||||
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
|
||||
<string name="profiles_builder_step_identity">아이덴티티</string>
|
||||
<string name="profiles_builder_step_model">모델 설정</string>
|
||||
@@ -763,7 +761,7 @@
|
||||
<string name="profiles_builder_skills_default_desc">공통 시스템 도구 자동 포함</string>
|
||||
<string name="profiles_builder_skills_local">로컬 스킬 선택</string>
|
||||
<string name="profiles_builder_skills_search_hub">허브 검색</string>
|
||||
<string name="profiles_builder_skills_hub_title">Hermes 허브에서 스킬 추가</string>
|
||||
<string name="profiles_builder_skills_hub_title">AITURK 허브에서 스킬 추가</string>
|
||||
<string name="profiles_builder_skills_added">추가된 스킬:</string>
|
||||
<string name="profiles_builder_skills_search_results">검색 결과:</string>
|
||||
<string name="profiles_builder_mcp_title">설정된 MCP 서버</string>
|
||||
@@ -963,7 +961,7 @@
|
||||
|
||||
<!-- Keys Screen -->
|
||||
<string name="keys_empty_title">환경 변수 키가 없습니다</string>
|
||||
<string name="keys_empty_desc">Hermes의 환경 변수 키가 여기에 표시됩니다.</string>
|
||||
<string name="keys_empty_desc">AITURK의 환경 변수 키가 여기에 표시됩니다.</string>
|
||||
<string name="keys_label_not_configured">설정 안 됨</string>
|
||||
<string name="keys_action_toggle_visibility">표시 전환</string>
|
||||
<string name="keys_search_placeholder">키 검색…</string>
|
||||
@@ -978,7 +976,7 @@
|
||||
|
||||
<!-- Channels Screen -->
|
||||
<string name="channels_empty_title">설정된 채널이 없습니다</string>
|
||||
<string name="channels_empty_desc">Hermes에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string>
|
||||
<string name="channels_empty_desc">AITURK에서 메시징 플랫폼을 추가하면 여기에 표시됩니다.</string>
|
||||
<string name="channels_action_configure">설정</string>
|
||||
<string name="channels_sec_settings">설정</string>
|
||||
<string name="channels_action_save_settings">설정 저장</string>
|
||||
@@ -1007,7 +1005,7 @@
|
||||
|
||||
<!-- Auth Login Screen -->
|
||||
<string name="auth_login_title">인증 로그인</string>
|
||||
<string name="auth_login_desc">Hermes 대시보드에 연결</string>
|
||||
<string name="auth_login_desc">AITURK 대시보드에 연결</string>
|
||||
<string name="auth_login_token_label">세션 토큰</string>
|
||||
<string name="auth_login_username_label">사용자명</string>
|
||||
<string name="auth_login_password_label">비밀번호</string>
|
||||
@@ -1022,8 +1020,8 @@
|
||||
<string name="auth_login_use_profile">%1$s(으)로 접속</string>
|
||||
|
||||
<!-- Landing Screen -->
|
||||
<string name="landing_title">헤르메스 모바일</string>
|
||||
<string name="landing_subtitle">Hermes Agent 제어판</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">AITURK 제어판</string>
|
||||
<string name="landing_action_auth_login">인증 로그인</string>
|
||||
|
||||
<!-- Loading / Startup State (issue #1171) -->
|
||||
@@ -1210,7 +1208,7 @@
|
||||
<string name="connection_setup_open_browser">브라우저에서 열기</string>
|
||||
<string name="connection_setup_required">%1$s은(는) 필수입니다</string>
|
||||
<string name="connection_setup_request_failed">설정 응답을 보낼 수 없습니다. 다시 시도하세요.</string>
|
||||
<string name="connection_setup_pending">Hermes를 기다리는 중…</string>
|
||||
<string name="connection_setup_pending">AITURK를 기다리는 중…</string>
|
||||
<string name="connection_catalog_title">%1$s 설치</string>
|
||||
<string name="connection_catalog_kind_plugin">플러그인</string>
|
||||
<string name="connection_catalog_kind_skill">스킬</string>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chat_image_paste_loading">Yapıştırılan görseller ekleniyor…</string>
|
||||
<string name="chat_image_paste_failed">Görsel yapıştırılamadı. Erişimi kontrol edip yeniden deneyin.</string>
|
||||
<string name="chat_image_paste_too_large">Görsel 10 MB ek sınırını aşıyor.</string>
|
||||
<string name="chat_image_paste_batch_limit">Toplamı 20 MB\'ı geçmeyen en fazla 8 görsel yapıştırılabilir.</string>
|
||||
<string name="chat_image_paste_busy">Yeniden yapıştırmadan önce görsellerin yüklenmesini bekleyin.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chat_reply_failed_title">AITURK bu yanıtı tamamlayamadı</string>
|
||||
<string name="chat_reply_failed_description">AITURK yanıt verirken bir sorun oluştu. Ne olduğunu anlamak için ayrıntıları ya da günlükleri açın.</string>
|
||||
<string name="chat_reply_failed_details">Ayrıntılar</string>
|
||||
<string name="chat_reply_failed_details_collapsed">Daraltıldı</string>
|
||||
<string name="chat_reply_failed_details_expanded">Genişletildi</string>
|
||||
<string name="chat_reply_failed_copy">Hata ayrıntılarını kopyala</string>
|
||||
<string name="chat_reply_failed_logs">Günlükleri aç</string>
|
||||
<string name="chat_reply_failed_share">Tanılama bilgisini paylaş</string>
|
||||
<string name="chat_reply_failed_share_note">Paylaşmadan önce ayrıntıları gözden geçirin. Tanılama bilgisi bu hatayı ve uygulama sürümünü içerir; konuşmanızı ya da eklerinizi içermez.</string>
|
||||
<string name="chat_reply_failed_dismiss">Hatayı kapat</string>
|
||||
|
||||
<string name="chat_reply_failed_copied">Hata ayrıntıları kopyalandı</string>
|
||||
<string name="chat_reply_failed_share_unavailable">Tanılama bilgisini paylaşacak uygulama yok</string>
|
||||
</resources>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<!-- Simplified Chinese (zh) translation -->
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
<string name="chat_send_desc">发送</string>
|
||||
<string name="chat_mic_listening">正在聆听…</string>
|
||||
<string name="stt_permission_denied">语音输入需要麦克风权限</string>
|
||||
@@ -57,7 +57,7 @@
|
||||
<string name="billing_plan">套餐</string>
|
||||
<string name="billing_topup">充值</string>
|
||||
<string name="press_back_again_to_exit">再次返回以退出应用</string>
|
||||
<string name="nav_drawer_title">Hermes</string>
|
||||
<string name="nav_drawer_title">AITURK</string>
|
||||
<string name="nav_drawer_subtitle">AI Agent 控制台</string>
|
||||
<string name="nav_drawer_section_converse">对话</string>
|
||||
<string name="nav_drawer_section_automate">自动化</string>
|
||||
@@ -127,27 +127,27 @@
|
||||
<string name="screen_analytics">分析</string>
|
||||
<string name="screen_billing">计费</string>
|
||||
<string name="screen_files">文件</string>
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">等待 Hermes 回复</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">等待 AITURK 回复</string>
|
||||
<string name="notif_connected_in_background">在后台保持连接</string>
|
||||
<string name="notif_action_turn_off">关闭</string>
|
||||
<string name="notif_connecting">正在连接…</string>
|
||||
<string name="notif_waiting_network">等待网络连接</string>
|
||||
<string name="notif_reconnecting">正在重新连接</string>
|
||||
<string name="notif_new_message">新消息</string>
|
||||
<string name="notif_clarification_needed">Hermes 需要确认</string>
|
||||
<string name="notif_clarification_needed">AITURK 需要确认</string>
|
||||
<string name="notif_reply_placeholder">输入回复…</string>
|
||||
<string name="notif_channel_service_name">Hermes 后台服务</string>
|
||||
<string name="notif_channel_service_name">AITURK 后台服务</string>
|
||||
<string name="notif_channel_service_desc">在后台保持连接</string>
|
||||
<string name="notif_channel_chat_name">Hermes 对话回复</string>
|
||||
<string name="notif_channel_chat_desc">Hermes 新回复提醒</string>
|
||||
<string name="notif_channel_chat_name">AITURK 对话回复</string>
|
||||
<string name="notif_channel_chat_desc">AITURK 新回复提醒</string>
|
||||
<string name="action_reply">回复</string>
|
||||
<string name="bot_chat_never_resets_notice">Bot 聊天是连续对话 — 正在压缩替代。如需临时会话,请使用普通聊天。</string>
|
||||
<string name="chat_action_search">搜索</string>
|
||||
<string name="chat_action_close_search">关闭搜索</string>
|
||||
<string name="chat_action_reconnect">重新连接</string>
|
||||
<string name="chat_action_relogin">重新登录</string>
|
||||
<string name="chat_relogin_title">重新登录 Hermes</string>
|
||||
<string name="chat_relogin_title">重新登录 AITURK</string>
|
||||
<string name="chat_relogin_username">用户名</string>
|
||||
<string name="chat_relogin_password">密码</string>
|
||||
<string name="chat_relogin_submit">登录</string>
|
||||
@@ -155,7 +155,7 @@
|
||||
<string name="chat_relogin_error_empty">用户名和密码不能为空</string>
|
||||
<string name="chat_empty_title">准备就绪</string>
|
||||
<string name="chat_empty_subtitle">发送消息开始对话</string>
|
||||
<string name="chat_status_connecting">正在连接 Hermes…</string>
|
||||
<string name="chat_status_connecting">正在连接 AITURK…</string>
|
||||
<string name="chat_resume_error_title">无法连接此会话</string>
|
||||
<string name="chat_status_reconnecting">重新连接中…</string>
|
||||
<string name="chat_status_disconnected">已断开</string>
|
||||
@@ -185,7 +185,7 @@
|
||||
<string name="settings_sec_connection">连接</string>
|
||||
<string name="settings_field_token">认证令牌</string>
|
||||
<string name="settings_keep_connected_in_background_title">在后台保持连接</string>
|
||||
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 Hermes 连接。会显示常驻通知,并可能消耗更多电量。</string>
|
||||
<string name="settings_keep_connected_in_background_desc">在使用其他应用时保持 AITURK 连接。会显示常驻通知,并可能消耗更多电量。</string>
|
||||
<string name="settings_sec_behavior">行为</string>
|
||||
<string name="settings_item_auto_reconnect">自动重连</string>
|
||||
<string name="settings_desc_auto_reconnect">断开后自动重新连接</string>
|
||||
@@ -227,9 +227,7 @@
|
||||
<string name="settings_action_test_connection">测试连接</string>
|
||||
<string name="settings_sec_about">关于</string>
|
||||
<string name="settings_sec_language">语言</string>
|
||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
||||
<string name="settings_support_title">支持 Hermes Mobile</string>
|
||||
<string name="settings_support_message">如果 Hermes Mobile 为你节省了时间,欢迎支持它的开发。</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_support_action">在 Ko-fi 上支持我</string>
|
||||
<string name="settings_about_version">版本</string>
|
||||
<string name="settings_about_build">构建</string>
|
||||
@@ -289,7 +287,7 @@
|
||||
<string name="connect_selected_profile">配置:%1$s</string>
|
||||
<string name="connect_action_select_profile">选择已保存配置</string>
|
||||
<string name="connect_health_title">后端健康状态</string>
|
||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||
<string name="connect_health_auth_gated">需登录(OAuth)</string>
|
||||
<string name="connect_health_auth_token">令牌</string>
|
||||
<string name="connect_error_token_required">需要填写令牌</string>
|
||||
@@ -297,7 +295,7 @@
|
||||
<string name="connect_error_403">访问被拒绝(403 禁止访问)</string>
|
||||
<string name="connect_error_http_code">服务器返回 HTTP %1$d</string>
|
||||
<string name="connect_error_timeout">连接超时</string>
|
||||
<string name="connect_error_refused">连接被拒绝——Hermes 是否在运行?</string>
|
||||
<string name="connect_error_refused">连接被拒绝——AITURK 是否在运行?</string>
|
||||
<string name="connect_error_resolve">无法解析主机名</string>
|
||||
<string name="connect_error_connection_failed">连接失败:%1$s</string>
|
||||
<string name="connect_save_profile">保存连接配置</string>
|
||||
@@ -499,7 +497,7 @@
|
||||
<string name="skills_hub_source_offline">离线</string>
|
||||
<string name="skills_hub_source_via_index">通过索引</string>
|
||||
<string name="skills_hub_featured_title">精选技能</string>
|
||||
<string name="skills_hub_featured_subtitle">来自 Hermes 索引——搜索获取更多</string>
|
||||
<string name="skills_hub_featured_subtitle">来自 AITURK 索引——搜索获取更多</string>
|
||||
<string name="skills_hub_landing_hint">在上方搜索可安装的技能</string>
|
||||
<string name="skills_hub_scan">扫描</string>
|
||||
<string name="skills_hub_scan_verdict_label">判定:%1$s</string>
|
||||
@@ -520,7 +518,7 @@
|
||||
<string name="content_desc_update_skills_hub">从中心更新技能</string>
|
||||
<string name="content_desc_save_changes">保存更改</string>
|
||||
<string name="logs_empty_title">暂无日志</string>
|
||||
<string name="logs_empty_desc">Hermes 的日志输出将显示在这里。</string>
|
||||
<string name="logs_empty_desc">AITURK 的日志输出将显示在这里。</string>
|
||||
<string name="logs_action_pause">暂停</string>
|
||||
<string name="logs_action_resume">继续</string>
|
||||
<string name="logs_action_export">分享</string>
|
||||
@@ -543,7 +541,7 @@
|
||||
<string name="system_label_arch">架构</string>
|
||||
<string name="system_label_hostname">主机名</string>
|
||||
<string name="system_label_python">Python</string>
|
||||
<string name="system_label_hermes_version">Hermes</string>
|
||||
<string name="system_label_hermes_version">AITURK</string>
|
||||
<string name="system_label_cpu">CPU</string>
|
||||
<string name="system_label_memory">内存</string>
|
||||
<string name="system_label_disk">磁盘</string>
|
||||
@@ -557,7 +555,7 @@
|
||||
<string name="system_version_behind">落后 %1$d 个版本</string>
|
||||
<string name="system_action_check_updates">检查更新</string>
|
||||
<string name="system_action_update_now">立即更新</string>
|
||||
<string name="system_update_confirm_title">更新 Hermes?</string>
|
||||
<string name="system_update_confirm_title">更新 AITURK?</string>
|
||||
<string name="system_update_confirm_desc">这将运行 hermes update 并重启网关。</string>
|
||||
<string name="system_update_progress_title">软件更新</string>
|
||||
<string name="system_sec_portal">Nous Portal</string>
|
||||
@@ -633,10 +631,10 @@
|
||||
<string name="system_sec_restore">从上传恢复</string>
|
||||
<string name="system_op_restore_upload">上传恢复包</string>
|
||||
<string name="system_op_restore_pick">选择备份文件</string>
|
||||
<string name="system_op_import_confirm_title">恢复完整 Hermes 备份?</string>
|
||||
<string name="system_op_import_confirm_desc">这将覆盖当前的 Hermes 配置、技能、会话和数据。此操作无法撤销。</string>
|
||||
<string name="system_op_import_confirm_title">恢复完整 AITURK 备份?</string>
|
||||
<string name="system_op_import_confirm_desc">这将覆盖当前的 AITURK 配置、技能、会话和数据。此操作无法撤销。</string>
|
||||
<string name="system_sec_debug_share">调试分享</string>
|
||||
<string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 Hermes 团队。粘贴内容 6 小时后自动删除。</string>
|
||||
<string name="system_debug_share_description">将系统信息和日志上传到公开粘贴服务,返回链接发给 AITURK 团队。粘贴内容 6 小时后自动删除。</string>
|
||||
<string name="system_debug_share_generate">生成分享链接</string>
|
||||
<string name="system_debug_share_uploading">上传中…</string>
|
||||
<string name="system_debug_share_redact">上传前脱敏凭据类令牌(推荐)</string>
|
||||
@@ -691,7 +689,7 @@
|
||||
<item quantity="other">%1$d 个模型</item>
|
||||
</plurals>
|
||||
<string name="history_empty_title">暂无历史</string>
|
||||
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
||||
<string name="history_empty_desc">之前的 AITURK 对话会话将显示在这里。</string>
|
||||
<string name="history_untitled">未命名</string>
|
||||
<string name="history_message_count">%1$d 条消息</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -820,7 +818,7 @@
|
||||
<string name="config_action_save">保存</string>
|
||||
<string name="config_action_save_yaml">保存 YAML</string>
|
||||
<string name="profiles_empty_title">没有可用配置</string>
|
||||
<string name="profiles_empty_desc">Hermes 的连接配置将显示在这里。</string>
|
||||
<string name="profiles_empty_desc">AITURK 的连接配置将显示在这里。</string>
|
||||
<string name="profiles_label_model">模型:%1$s</string>
|
||||
<string name="profiles_label_provider">提供商:%1$s</string>
|
||||
<string name="profiles_label_skills">%1$d 个技能</string>
|
||||
@@ -862,7 +860,7 @@
|
||||
<string name="profiles_delete_warning">这将永久删除配置"%1$s"及其数据。</string>
|
||||
<string name="profiles_delete_path">路径:%1$s</string>
|
||||
<string name="profiles_delete_gateway_warning">此配置有正在运行的网关——删除它会将其停止。</string>
|
||||
<string name="profiles_setup_command_hint">在 Hermes CLI 中运行此命令以配置此配置文件。</string>
|
||||
<string name="profiles_setup_command_hint">在 AITURK CLI 中运行此命令以配置此配置文件。</string>
|
||||
<string name="profiles_builder_step_indicator">第 %1$d 步,共 5 步</string>
|
||||
<string name="profiles_builder_step_identity">身份</string>
|
||||
<string name="profiles_builder_step_model">模型配置</string>
|
||||
@@ -880,7 +878,7 @@
|
||||
<string name="profiles_builder_skills_default_desc">自动打包常用系统工具</string>
|
||||
<string name="profiles_builder_skills_local">选择本地技能</string>
|
||||
<string name="profiles_builder_skills_search_hub">搜索中心</string>
|
||||
<string name="profiles_builder_skills_hub_title">从 Hermes 中心添加技能</string>
|
||||
<string name="profiles_builder_skills_hub_title">从 AITURK 中心添加技能</string>
|
||||
<string name="profiles_builder_skills_added">已添加技能:</string>
|
||||
<string name="profiles_builder_skills_search_results">搜索结果:</string>
|
||||
<string name="profiles_builder_mcp_title">已配置的 MCP 服务器</string>
|
||||
@@ -1016,7 +1014,7 @@
|
||||
<string name="pressure_banner_memory_oom">代理意外重启 — 很可能是内存不足导致</string>
|
||||
<string name="pressure_banner_memory_elevated">代理内存不足</string>
|
||||
<string name="cron_empty_title">没有定时任务</string>
|
||||
<string name="cron_empty_desc">Hermes 的定时任务将显示在这里。</string>
|
||||
<string name="cron_empty_desc">AITURK 的定时任务将显示在这里。</string>
|
||||
<string name="cron_status_active">启用</string>
|
||||
<string name="cron_status_paused">已暂停</string>
|
||||
<string name="cron_action_pause">暂停</string>
|
||||
@@ -1091,7 +1089,7 @@
|
||||
<string name="achievements_reset_title">重置成就?</string>
|
||||
<string name="achievements_reset_desc">这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。</string>
|
||||
<string name="keys_empty_title">没有环境密钥数据</string>
|
||||
<string name="keys_empty_desc">Hermes 的环境密钥将显示在这里。</string>
|
||||
<string name="keys_empty_desc">AITURK 的环境密钥将显示在这里。</string>
|
||||
<string name="keys_label_not_configured">未配置</string>
|
||||
<string name="keys_action_toggle_visibility">切换可见性</string>
|
||||
<string name="keys_search_placeholder">搜索密钥…</string>
|
||||
@@ -1132,7 +1130,7 @@
|
||||
<string name="files_dialog_delete_title">删除?</string>
|
||||
<string name="files_dialog_delete_message">确定要删除"%1$s"吗?此操作无法撤销。</string>
|
||||
<string name="channels_empty_title">未配置渠道</string>
|
||||
<string name="channels_empty_desc">在 Hermes 中添加消息平台后,会显示在这里。</string>
|
||||
<string name="channels_empty_desc">在 AITURK 中添加消息平台后,会显示在这里。</string>
|
||||
<string name="channels_action_configure">配置</string>
|
||||
<string name="channels_sec_settings">配置设置</string>
|
||||
<string name="channels_action_save_settings">保存设置</string>
|
||||
@@ -1155,7 +1153,7 @@
|
||||
<string name="settings_saved_profiles">已保存配置(%1$d)</string>
|
||||
<string name="settings_logout">退出登录</string>
|
||||
<string name="auth_login_title">认证登录</string>
|
||||
<string name="auth_login_desc">连接到你的 Hermes dashboard</string>
|
||||
<string name="auth_login_desc">连接到你的 AITURK dashboard</string>
|
||||
<string name="auth_login_base_url_label">服务器地址</string>
|
||||
<string name="auth_login_token_label">会话令牌</string>
|
||||
<string name="auth_login_username_label">用户名</string>
|
||||
@@ -1168,11 +1166,11 @@
|
||||
<string name="auth_login_error_password_required">需要填写密码</string>
|
||||
<string name="auth_login_error_unreachable">无法访问 dashboard——检查 IP 和端口</string>
|
||||
<string name="auth_login_error_oauth_unsupported">此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639)</string>
|
||||
<string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 Hermes Mobile 中支持。</string>
|
||||
<string name="auth_login_oauth_coming_soon">此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 AITURK Mobil 中支持。</string>
|
||||
<string name="auth_login_existing_profiles_title">使用已登录的配置:</string>
|
||||
<string name="auth_login_use_profile">以 %1$s 身份进入</string>
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">Hermes Agent 控制面板</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">AITURK 控制面板</string>
|
||||
<string name="landing_action_auth_login">认证登录</string>
|
||||
<string name="auth_init_failed">应用设置初始化失败</string>
|
||||
<string name="providers_empty_title">没有 OAuth 提供商</string>
|
||||
@@ -1193,7 +1191,7 @@
|
||||
<string name="action_dismiss">忽略</string>
|
||||
<string name="action_done">完成</string>
|
||||
<string name="action_close">关闭</string>
|
||||
<string name="update_notice_banner_text">Hermes Mobile %1$s 已可用</string>
|
||||
<string name="update_notice_banner_text">AITURK Mobil %1$s 已可用</string>
|
||||
<string name="update_notice_banner_action">更新</string>
|
||||
<string name="processes_empty_title">没有运行中的进程</string>
|
||||
<string name="processes_empty_desc">此会话的后台进程将显示在这里。</string>
|
||||
@@ -1283,7 +1281,7 @@
|
||||
<string name="context_window_usage_unavailable">上下文窗口使用量不可用</string>
|
||||
<string name="gif_play">播放 GIF</string>
|
||||
<string name="kanban_filter_placeholder">按标题、状态或负责人筛选任务…</string>
|
||||
<string name="logs_clipboard_label">Hermes 日志</string>
|
||||
<string name="logs_clipboard_label">AITURK 日志</string>
|
||||
<string name="logs_search_placeholder">搜索日志…</string>
|
||||
<string name="mcp_no_match">没有匹配"%1$s"的服务器</string>
|
||||
<string name="mcp_search_placeholder">搜索 MCP 服务器…</string>
|
||||
@@ -1506,7 +1504,7 @@
|
||||
<string name="connection_setup_open_browser">在浏览器中打开</string>
|
||||
<string name="connection_setup_required">%1$s为必填项</string>
|
||||
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
||||
<string name="connection_setup_pending">正在等待 Hermes…</string>
|
||||
<string name="connection_setup_pending">正在等待 AITURK…</string>
|
||||
<string name="connection_catalog_title">安装 %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">插件</string>
|
||||
<string name="connection_catalog_kind_skill">技能</string>
|
||||
@@ -1535,6 +1533,12 @@
|
||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
||||
<string name="chat_pending_sends">待发送消息</string>
|
||||
<string name="chat_send_accepted">服务器已接收</string>
|
||||
<plurals name="chat_pending_count">
|
||||
<item quantity="other">%d 条待处理消息</item>
|
||||
</plurals>
|
||||
<string name="chat_pending_discard">移除</string>
|
||||
<string name="chat_pending_discard_warning">要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。</string>
|
||||
<string name="chat_pending_queued">已在本机排队</string>
|
||||
<string name="chat_pending_parked">停止后已暂停</string>
|
||||
<string name="chat_pending_sending">正在发送…</string>
|
||||
@@ -1565,7 +1569,7 @@
|
||||
<string name="detail_dialog_docs">文档</string>
|
||||
<string name="detail_dialog_maintainer">维护者</string>
|
||||
<string name="detail_dialog_repository">仓库</string>
|
||||
<string name="detail_dialog_requires_hermes">需要 Hermes</string>
|
||||
<string name="detail_dialog_requires_hermes">需要 AITURK</string>
|
||||
<string name="detail_dialog_tier">层级</string>
|
||||
<string name="group_chat_action_stop">停止生成</string>
|
||||
<string name="group_chat_bot_passed">%1$s 本轮未发言</string>
|
||||
@@ -1574,7 +1578,7 @@
|
||||
<string name="group_chat_tool_no_output_success">退出码 0(无输出)</string>
|
||||
<string name="group_chat_tool_running">运行中…</string>
|
||||
<string name="kanban_bulk_complete_title">完成 %1$d 项任务</string>
|
||||
<string name="notif_input_needed">Hermes 需要你的输入</string>
|
||||
<string name="notif_input_needed">AITURK 需要你的输入</string>
|
||||
<string name="plugins_catalog_empty_desc">没有匹配筛选条件或搜索词的插件</string>
|
||||
<string name="plugins_catalog_empty_title">未找到目录插件</string>
|
||||
<string name="plugins_catalog_filter_all">全部</string>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chat_image_paste_loading">Adding pasted images…</string>
|
||||
<string name="chat_image_paste_failed">Could not paste the image. Check access and try again.</string>
|
||||
<string name="chat_image_paste_too_large">Image exceeds the 10 MB attachment limit.</string>
|
||||
<string name="chat_image_paste_batch_limit">Paste up to 8 images totaling no more than 20 MB.</string>
|
||||
<string name="chat_image_paste_busy">Wait for the images to finish loading before pasting again.</string>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="chat_reply_failed_title">Hermes couldn\'t finish this reply</string>
|
||||
<string name="chat_reply_failed_description">Something went wrong while Hermes was replying. Open the details or logs to understand what happened.</string>
|
||||
<string name="chat_reply_failed_title">AITURK couldn\'t finish this reply</string>
|
||||
<string name="chat_reply_failed_description">Something went wrong while AITURK was replying. Open the details or logs to understand what happened.</string>
|
||||
<string name="chat_reply_failed_details">Details</string>
|
||||
<string name="chat_reply_failed_details_collapsed">Collapsed</string>
|
||||
<string name="chat_reply_failed_details_expanded">Expanded</string>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Hermes Mobile</string>
|
||||
<string name="aiturk_backend_not_ready">The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes.</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
<string name="chat_send_desc">Send</string>
|
||||
@@ -75,7 +76,7 @@
|
||||
|
||||
<!-- Navigation & Drawer -->
|
||||
<string name="press_back_again_to_exit">Press back again to exit</string>
|
||||
<string name="nav_drawer_title">Hermes</string>
|
||||
<string name="nav_drawer_title">AITURK</string>
|
||||
<string name="nav_drawer_subtitle">AI Agent Control</string>
|
||||
<string name="nav_drawer_section_converse">Converse</string>
|
||||
<string name="nav_drawer_section_automate">Automate</string>
|
||||
@@ -150,21 +151,21 @@
|
||||
<string name="screen_files">Files</string>
|
||||
|
||||
<!-- Notifications -->
|
||||
<string name="notif_title">Hermes</string>
|
||||
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
|
||||
<string name="notif_title">AITURK</string>
|
||||
<string name="notif_waiting_replies">Waiting for AITURK replies</string>
|
||||
<string name="notif_connected_in_background">Connected in background</string>
|
||||
<string name="notif_action_turn_off">Turn off</string>
|
||||
<string name="notif_connecting">Connecting…</string>
|
||||
<string name="notif_waiting_network">Waiting for network</string>
|
||||
<string name="notif_reconnecting">Reconnecting</string>
|
||||
<string name="notif_new_message">New message</string>
|
||||
<string name="notif_clarification_needed">Hermes needs a clarification</string>
|
||||
<string name="notif_input_needed">Hermes needs your input</string>
|
||||
<string name="notif_clarification_needed">AITURK needs a clarification</string>
|
||||
<string name="notif_input_needed">AITURK needs your input</string>
|
||||
<string name="notif_reply_placeholder">Type your reply\u2026</string>
|
||||
<string name="notif_channel_service_name">Hermes Background Service</string>
|
||||
<string name="notif_channel_service_name">AITURK Background Service</string>
|
||||
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
|
||||
<string name="notif_channel_chat_name">Hermes Chat Replies</string>
|
||||
<string name="notif_channel_chat_desc">Alerts for new replies from Hermes</string>
|
||||
<string name="notif_channel_chat_name">AITURK Chat Replies</string>
|
||||
<string name="notif_channel_chat_desc">Alerts for new replies from AITURK</string>
|
||||
<string name="notif_session_complete">Session finished: %1$s</string>
|
||||
<string name="notif_session_failed">Session failed: %1$s</string>
|
||||
<string name="action_reply">Reply</string>
|
||||
@@ -188,7 +189,7 @@
|
||||
<string name="chat_history_window_more_both">More messages exist before and after this bounded window.</string>
|
||||
<string name="chat_action_reconnect">Reconnect</string>
|
||||
<string name="chat_action_relogin">Re-login</string>
|
||||
<string name="chat_relogin_title">Re-login to Hermes</string>
|
||||
<string name="chat_relogin_title">Re-login to AITURK</string>
|
||||
<string name="chat_relogin_username">Username</string>
|
||||
<string name="chat_relogin_password">Password</string>
|
||||
<string name="chat_relogin_submit">Login</string>
|
||||
@@ -196,7 +197,7 @@
|
||||
<string name="chat_relogin_error_empty">Username and password cannot be empty</string>
|
||||
<string name="chat_empty_title">Ready to chat</string>
|
||||
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
||||
<string name="chat_status_connecting">Connecting to Hermes\u2026</string>
|
||||
<string name="chat_status_connecting">Connecting to AITURK\u2026</string>
|
||||
<string name="chat_resume_error_title">Couldn\'t connect to this session</string>
|
||||
<string name="chat_status_reconnecting">Reconnecting\u2026</string>
|
||||
<string name="chat_status_disconnected">Disconnected</string>
|
||||
@@ -219,6 +220,7 @@
|
||||
<string name="chat_busy_override">Send mode for this message</string>
|
||||
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
||||
<string name="chat_pending_sends">Pending messages</string>
|
||||
<string name="chat_send_accepted">Accepted by server</string>
|
||||
<plurals name="chat_pending_count">
|
||||
<item quantity="one">%d pending message</item>
|
||||
<item quantity="other">%d pending messages</item>
|
||||
@@ -249,7 +251,7 @@
|
||||
<string name="content_desc_copied">Copied</string>
|
||||
<string name="content_desc_speak">Read aloud</string>
|
||||
<string name="content_desc_stop_speaking">Stop speaking</string>
|
||||
<string name="tts_error_network">Could not reach the Hermes server for speech</string>
|
||||
<string name="tts_error_network">Could not reach the AITURK server for speech</string>
|
||||
<string name="tts_error_synthesis">Could not synthesize the message audio</string>
|
||||
<string name="tts_error_playback">Could not play the message audio</string>
|
||||
<string name="content_desc_edit">Edit</string>
|
||||
@@ -277,9 +279,9 @@
|
||||
<string name="account_connectors_remove_confirm_message">Remove %1$s? You may need to authorize it again.</string>
|
||||
<string name="settings_field_token">Auth Token</string>
|
||||
<string name="settings_keep_connected_in_background_title">Stay connected in background</string>
|
||||
<string name="settings_keep_connected_in_background_desc">Keep Hermes connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
||||
<string name="settings_keep_connected_in_background_desc">Keep AITURK connected while using other apps. Shows an ongoing notification and may use more battery.</string>
|
||||
<string name="settings_notify_session_completions_title">Notify when a session completes</string>
|
||||
<string name="settings_notify_session_completions_desc">Alert when any Hermes session finishes while the app is in the background. Keeps Hermes connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string>
|
||||
<string name="settings_notify_session_completions_desc">Alert when any AITURK session finishes while the app is in the background. Keeps AITURK connected in the background (ongoing notification, extra battery use) so finished sessions can alert you.</string>
|
||||
<string name="settings_sec_behavior">Behavior</string>
|
||||
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
|
||||
<string name="settings_desc_auto_reconnect">Automatically reconnect on disconnect</string>
|
||||
@@ -321,7 +323,9 @@
|
||||
<string name="settings_action_test_connection">Test Connection</string>
|
||||
<string name="settings_sec_about">About</string>
|
||||
<string name="settings_sec_language">Language</string>
|
||||
<string name="settings_about_app_name">Hermes Mobile</string>
|
||||
<string name="settings_about_app_name">AITURK Mobil</string>
|
||||
<string name="settings_about_attribution">AITURK Mobil is adapted from Hy4ri/hermes-mobile (Apache-2.0).</string>
|
||||
<string name="settings_about_download">Updates</string>
|
||||
<string name="settings_about_version">Version</string>
|
||||
<string name="settings_about_build">Build</string>
|
||||
<string name="settings_about_debug">Debug</string>
|
||||
@@ -340,9 +344,9 @@
|
||||
<string name="settings_about_update_retry">Retry</string>
|
||||
<string name="settings_about_rc_updates_title">Check release candidate updates</string>
|
||||
<string name="settings_about_rc_updates_desc">Install pre-release RC builds. They may be less stable.</string>
|
||||
<string name="settings_support_title">Support Hermes Mobile</string>
|
||||
<string name="settings_support_message">If Hermes Mobile saves you time, you can support its development.</string>
|
||||
<string name="settings_support_action">Support me on Ko-fi</string>
|
||||
<string name="settings_support_title">Support the original developer</string>
|
||||
<string name="settings_support_message">AITURK Mobil is adapted from the open-source hermes-mobile project by Hy4ri. If it saves you time, you can support the original developer.</string>
|
||||
<string name="settings_support_action">Support on Ko-fi</string>
|
||||
|
||||
<!-- Settings list summaries (drill-down) -->
|
||||
<string name="settings_summary_profiles">%1$d saved</string>
|
||||
@@ -385,12 +389,13 @@
|
||||
<string name="language_arabic">العربية</string>
|
||||
<string name="language_chinese">简体中文</string>
|
||||
<string name="language_korean">한국어</string>
|
||||
<string name="language_turkish">Türkçe</string>
|
||||
|
||||
<!-- Connect Screen -->
|
||||
<string name="connect_selected_profile">Profile: %1$s</string>
|
||||
<string name="connect_action_select_profile">Select Saved Profile</string>
|
||||
<string name="connect_health_title">Backend health</string>
|
||||
<string name="connect_health_body">Hermes %1$s · %2$s</string>
|
||||
<string name="connect_health_body">AITURK %1$s · %2$s</string>
|
||||
<string name="connect_health_auth_gated">Gated (OAuth)</string>
|
||||
<string name="connect_health_auth_token">Token</string>
|
||||
|
||||
@@ -400,7 +405,7 @@
|
||||
<string name="connect_error_403">Access denied (403 Forbidden)</string>
|
||||
<string name="connect_error_http_code">Server returned HTTP %1$d</string>
|
||||
<string name="connect_error_timeout">Connection timed out</string>
|
||||
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
|
||||
<string name="connect_error_refused">Connection refused – is AITURK IDE running?</string>
|
||||
<string name="connect_error_resolve">Could not resolve host</string>
|
||||
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
|
||||
|
||||
@@ -608,7 +613,7 @@
|
||||
<string name="skills_hub_source_offline">offline</string>
|
||||
<string name="skills_hub_source_via_index">via index</string>
|
||||
<string name="skills_hub_featured_title">Featured skills</string>
|
||||
<string name="skills_hub_featured_subtitle">from the Hermes index — search above for more</string>
|
||||
<string name="skills_hub_featured_subtitle">from the skills index — search above for more</string>
|
||||
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
|
||||
<string name="skills_hub_scan">Scan</string>
|
||||
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
|
||||
@@ -636,7 +641,7 @@
|
||||
|
||||
<!-- Logs Screen -->
|
||||
<string name="logs_empty_title">No logs available</string>
|
||||
<string name="logs_empty_desc">Log output from Hermes will appear here.</string>
|
||||
<string name="logs_empty_desc">Log output from AITURK will appear here.</string>
|
||||
<string name="logs_action_pause">Pause</string>
|
||||
<string name="logs_action_resume">Resume</string>
|
||||
<string name="logs_action_export">Share</string>
|
||||
@@ -663,7 +668,7 @@
|
||||
<string name="system_label_arch">Arch</string>
|
||||
<string name="system_label_hostname">Host</string>
|
||||
<string name="system_label_python">Python</string>
|
||||
<string name="system_label_hermes_version">Hermes</string>
|
||||
<string name="system_label_hermes_version">AITURK</string>
|
||||
<string name="system_label_cpu">CPU</string>
|
||||
<string name="system_label_memory">Memory</string>
|
||||
<string name="system_label_disk">Disk</string>
|
||||
@@ -677,7 +682,7 @@
|
||||
<string name="system_version_behind">%1$d behind</string>
|
||||
<string name="system_action_check_updates">Check for updates</string>
|
||||
<string name="system_action_update_now">Update now</string>
|
||||
<string name="system_update_confirm_title">Update Hermes?</string>
|
||||
<string name="system_update_confirm_title">Update AITURK?</string>
|
||||
<string name="system_update_confirm_desc">This will run hermes update and restart the gateway.</string>
|
||||
<string name="system_update_progress_title">Software update</string>
|
||||
|
||||
@@ -766,12 +771,12 @@
|
||||
<string name="system_sec_restore">Restore from upload</string>
|
||||
<string name="system_op_restore_upload">Restore upload</string>
|
||||
<string name="system_op_restore_pick">Choose backup archive</string>
|
||||
<string name="system_op_import_confirm_title">Restore full Hermes backup?</string>
|
||||
<string name="system_op_import_confirm_desc">This will overwrite your current Hermes configuration, skills, sessions, and data. This cannot be undone.</string>
|
||||
<string name="system_op_import_confirm_title">Restore full AITURK backup?</string>
|
||||
<string name="system_op_import_confirm_desc">This will overwrite your current AITURK configuration, skills, sessions, and data. This cannot be undone.</string>
|
||||
|
||||
<!-- Debug share section -->
|
||||
<string name="system_sec_debug_share">Debug share</string>
|
||||
<string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send the Hermes team. Pastes auto-delete after 6 hours.</string>
|
||||
<string name="system_debug_share_description">Uploads system info + logs to a public paste service and returns links to send to support. Pastes auto-delete after 6 hours.</string>
|
||||
<string name="system_debug_share_generate">Generate share link</string>
|
||||
<string name="system_debug_share_uploading">Uploading…</string>
|
||||
<string name="system_debug_share_redact">Redact credential-shaped tokens before upload (recommended)</string>
|
||||
@@ -838,7 +843,7 @@
|
||||
|
||||
<!-- Sessions (History) Screen -->
|
||||
<string name="history_empty_title">No history</string>
|
||||
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
||||
<string name="history_empty_desc">Previous chat sessions from AITURK will appear here.</string>
|
||||
<string name="history_untitled">Untitled</string>
|
||||
<string name="history_message_count">%1$d messages</string>
|
||||
<plurals name="sessions_card_message_count">
|
||||
@@ -988,7 +993,7 @@
|
||||
|
||||
<!-- Profiles Screen -->
|
||||
<string name="profiles_empty_title">No profiles available</string>
|
||||
<string name="profiles_empty_desc">Connection profiles from Hermes will appear here.</string>
|
||||
<string name="profiles_empty_desc">Connection profiles from AITURK will appear here.</string>
|
||||
<string name="profiles_label_model">Model: %1$s</string>
|
||||
<string name="profiles_label_provider">Provider: %1$s</string>
|
||||
<string name="profiles_label_skills">%1$d skills</string>
|
||||
@@ -1030,7 +1035,7 @@
|
||||
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
|
||||
<string name="profiles_delete_path">Path: %1$s</string>
|
||||
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
|
||||
<string name="profiles_setup_command_hint">Run this command in the Hermes CLI to configure this profile.</string>
|
||||
<string name="profiles_setup_command_hint">Run this command in the agent CLI to configure this profile.</string>
|
||||
<string name="profiles_builder_step_indicator">Step %1$d of 5</string>
|
||||
<string name="profiles_builder_step_identity">Identity</string>
|
||||
<string name="profiles_builder_step_model">Model Configuration</string>
|
||||
@@ -1048,7 +1053,7 @@
|
||||
<string name="profiles_builder_skills_default_desc">Automatically bundles common system tools</string>
|
||||
<string name="profiles_builder_skills_local">Select Local Skills</string>
|
||||
<string name="profiles_builder_skills_search_hub">Search Hub</string>
|
||||
<string name="profiles_builder_skills_hub_title">Add Skills from Hermes Hub</string>
|
||||
<string name="profiles_builder_skills_hub_title">Add Skills from the Skills Hub</string>
|
||||
<string name="profiles_builder_skills_added">Added Skills:</string>
|
||||
<string name="profiles_builder_skills_search_results">Search Results:</string>
|
||||
<string name="profiles_builder_mcp_title">Configured MCP Servers</string>
|
||||
@@ -1164,7 +1169,7 @@
|
||||
<string name="detail_dialog_maintainer">Maintainer</string>
|
||||
<string name="detail_dialog_repository">Repository</string>
|
||||
<string name="detail_dialog_commit">Commit SHA</string>
|
||||
<string name="detail_dialog_requires_hermes">Requires Hermes</string>
|
||||
<string name="detail_dialog_requires_hermes">Requires AITURK</string>
|
||||
<string name="detail_dialog_docs">Documentation</string>
|
||||
<string name="detail_dialog_capabilities">Capabilities</string>
|
||||
|
||||
@@ -1240,7 +1245,7 @@
|
||||
|
||||
<!-- CronJobs Screen -->
|
||||
<string name="cron_empty_title">No cron jobs</string>
|
||||
<string name="cron_empty_desc">Scheduled tasks from Hermes will appear here.</string>
|
||||
<string name="cron_empty_desc">Scheduled tasks from AITURK will appear here.</string>
|
||||
<string name="cron_status_active">Active</string>
|
||||
<string name="cron_status_paused">Paused</string>
|
||||
<string name="cron_action_pause">Pause</string>
|
||||
@@ -1283,11 +1288,34 @@
|
||||
<string name="cron_edit_hint_skills">One per line</string>
|
||||
<string name="cron_edit_action_save">Save</string>
|
||||
<string name="cron_discard_title">Discard changes?</string>
|
||||
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
||||
<string name="cron_delete_title">Delete job?</string>
|
||||
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
||||
<string name="cron_discard_message">You have unsaved changes. Discard them?</string>
|
||||
<string name="cron_delete_title">Delete job?</string>
|
||||
<string name="cron_delete_message">Delete \"%1$s\"? This can\'t be undone.</string>
|
||||
<string name="cron_schedule_mode_interval">Interval</string>
|
||||
<string name="cron_schedule_mode_daily">Daily</string>
|
||||
<string name="cron_schedule_mode_weekly">Weekly</string>
|
||||
<string name="cron_schedule_mode_monthly">Monthly</string>
|
||||
<string name="cron_schedule_mode_advanced">Advanced</string>
|
||||
<string name="cron_schedule_select_time">Execution Time</string>
|
||||
<string name="cron_schedule_advanced_toggle">Edit raw cron expression</string>
|
||||
<string name="cron_schedule_visual_toggle">Switch to visual scheduler</string>
|
||||
<string name="cron_schedule_weekdays_workdays">Weekdays</string>
|
||||
<string name="cron_schedule_weekdays_weekend">Weekend</string>
|
||||
<string name="cron_schedule_weekdays_all">Every day</string>
|
||||
<string name="cron_schedule_interval_presets">Quick intervals</string>
|
||||
<string name="cron_schedule_day_of_month_label">Day of month: %1$d</string>
|
||||
<string name="cron_dow_mon">Mon</string>
|
||||
<string name="cron_dow_tue">Tue</string>
|
||||
<string name="cron_dow_wed">Wed</string>
|
||||
<string name="cron_dow_thu">Thu</string>
|
||||
<string name="cron_dow_fri">Fri</string>
|
||||
<string name="cron_dow_sat">Sat</string>
|
||||
<string name="cron_dow_sun">Sun</string>
|
||||
<string name="cron_unit_minute">min</string>
|
||||
<string name="cron_unit_hour">hr</string>
|
||||
<string name="cron_unit_day">day</string>
|
||||
|
||||
<!-- Achievements Screen -->
|
||||
<!-- Achievements Screen -->
|
||||
<string name="achievements_empty_title">No achievements yet</string>
|
||||
<string name="achievements_empty_desc">Complete tasks to unlock achievements.</string>
|
||||
<string name="achievements_label_progress">Progress: %1$d / %2$d</string>
|
||||
@@ -1319,7 +1347,7 @@
|
||||
|
||||
<!-- Keys Screen -->
|
||||
<string name="keys_empty_title">No environment keys reported</string>
|
||||
<string name="keys_empty_desc">Environment keys from Hermes will appear here.</string>
|
||||
<string name="keys_empty_desc">Environment keys from AITURK will appear here.</string>
|
||||
<string name="keys_label_not_configured">Not configured</string>
|
||||
<string name="keys_action_toggle_visibility">Toggle Visibility</string>
|
||||
<string name="keys_search_placeholder">Search keys…</string>
|
||||
@@ -1364,7 +1392,7 @@
|
||||
|
||||
<!-- Channels Screen -->
|
||||
<string name="channels_empty_title">No channels configured</string>
|
||||
<string name="channels_empty_desc">Add messaging platforms in Hermes to see them here.</string>
|
||||
<string name="channels_empty_desc">Add messaging platforms in AITURK to see them here.</string>
|
||||
<string name="channels_action_configure">Configure</string>
|
||||
<string name="channels_sec_settings">Configuration Settings</string>
|
||||
<string name="channels_action_save_settings">Save Settings</string>
|
||||
@@ -1393,7 +1421,7 @@
|
||||
|
||||
<!-- Auth Login Screen -->
|
||||
<string name="auth_login_title">Auth Login</string>
|
||||
<string name="auth_login_desc">Connect to your Hermes dashboard</string>
|
||||
<string name="auth_login_desc">Connect to your AITURK IDE</string>
|
||||
<string name="auth_login_base_url_label">Server URL</string>
|
||||
<string name="auth_login_token_label">Session Token</string>
|
||||
<string name="auth_login_username_label">Username</string>
|
||||
@@ -1406,13 +1434,13 @@
|
||||
<string name="auth_login_error_password_required">Password is required</string>
|
||||
<string name="auth_login_error_unreachable">Dashboard unreachable — check IP and port</string>
|
||||
<string name="auth_login_error_oauth_unsupported">OAuth login (Nous Portal) is not supported in this app version yet. Switch the dashboard to basic auth or token mode. (issue #639)</string>
|
||||
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to Hermes Mobile.</string>
|
||||
<string name="auth_login_oauth_coming_soon">This dashboard uses OAuth (Nous Portal) login, which is coming soon to AITURK Mobil.</string>
|
||||
<string name="auth_login_existing_profiles_title">Use existing logged-in profile:</string>
|
||||
<string name="auth_login_use_profile">Enter as %1$s</string>
|
||||
|
||||
<!-- Landing Screen -->
|
||||
<string name="landing_title">Hermes Mobile</string>
|
||||
<string name="landing_subtitle">Control panel for Hermes Agent</string>
|
||||
<string name="landing_title">AITURK Mobil</string>
|
||||
<string name="landing_subtitle">Control panel for AITURK</string>
|
||||
<string name="landing_action_auth_login">Auth Login</string>
|
||||
|
||||
<!-- Loading / Startup State (issue #1171) -->
|
||||
@@ -1439,7 +1467,7 @@
|
||||
<string name="action_close">Close</string>
|
||||
|
||||
<!-- Update notice banner (issue #890) -->
|
||||
<string name="update_notice_banner_text">Hermes Mobile %1$s is available</string>
|
||||
<string name="update_notice_banner_text">AITURK Mobil %1$s is available</string>
|
||||
<string name="update_notice_banner_action">Update</string>
|
||||
<string name="update_dialog_title">Update to %1$s</string>
|
||||
<string name="update_dialog_changelog_header">What\'s New</string>
|
||||
@@ -1537,7 +1565,7 @@
|
||||
<string name="context_window_usage_unavailable">Context window usage unavailable</string>
|
||||
<string name="gif_play">Play GIF</string>
|
||||
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
|
||||
<string name="logs_clipboard_label">Hermes Logs</string>
|
||||
<string name="logs_clipboard_label">AITURK Logs</string>
|
||||
<string name="logs_search_placeholder">Search logs...</string>
|
||||
<string name="mcp_no_match">No servers match "%1$s"</string>
|
||||
<string name="mcp_search_placeholder">Search MCP servers...</string>
|
||||
@@ -1787,7 +1815,7 @@
|
||||
<string name="connection_setup_open_browser">Open in browser</string>
|
||||
<string name="connection_setup_required">%1$s is required</string>
|
||||
<string name="connection_setup_request_failed">Couldn’t send the setup response. Try again.</string>
|
||||
<string name="connection_setup_pending">Waiting for Hermes…</string>
|
||||
<string name="connection_setup_pending">Waiting for AITURK…</string>
|
||||
<string name="connection_catalog_title">Install %1$s</string>
|
||||
<string name="connection_catalog_kind_plugin">Plugin</string>
|
||||
<string name="connection_catalog_kind_skill">Skill</string>
|
||||
@@ -1834,7 +1862,7 @@
|
||||
<string name="kanban_active_workers">Active workers</string>
|
||||
<string name="kanban_no_active_workers">No active workers</string>
|
||||
<string name="custom_headers_title">Custom headers</string>
|
||||
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. Hermes login is still required.</string>
|
||||
<string name="custom_headers_description">Saved securely for this server URL. Use Cloudflare Access service-token headers here. AITURK login is still required.</string>
|
||||
<string name="custom_headers_name">Header name</string>
|
||||
<string name="custom_headers_value">Header value</string>
|
||||
<string name="custom_headers_add">Add header</string>
|
||||
@@ -1858,4 +1886,69 @@
|
||||
<string name="mcp_servers_catalog_empty_title">No catalog entries</string>
|
||||
<string name="mcp_servers_catalog_installed">Installed</string>
|
||||
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
|
||||
|
||||
<!-- AITURK IDE pairing (QR / code) -->
|
||||
<string name="landing_action_aiturk_pair">Connect to computer (QR / code)</string>
|
||||
<string name="aiturk_pair_title">Connect to your computer</string>
|
||||
<string name="aiturk_pair_desc">On your computer open AITURK IDE → Settings → Phone connection. Scan the QR code shown there or type the 8-character code. It works from anywhere with internet; the same Wi-Fi is not required. The computer must be on with AITURK IDE running.</string>
|
||||
<string name="aiturk_pair_scan_qr">Scan QR</string>
|
||||
<string name="aiturk_pair_scan_prompt">Point the camera at the QR code in AITURK IDE</string>
|
||||
<string name="aiturk_pair_or">or</string>
|
||||
<string name="aiturk_pair_code_label">8-character code</string>
|
||||
<string name="aiturk_pair_code_placeholder">ABCD-EFGH</string>
|
||||
<string name="aiturk_pair_connect">Connect</string>
|
||||
<string name="aiturk_pair_cancel">Cancel</string>
|
||||
<string name="aiturk_pair_back">Back</string>
|
||||
<string name="aiturk_pair_progress_resolve">Looking up the code…</string>
|
||||
<string name="aiturk_pair_progress_connect">Connecting to your computer…</string>
|
||||
<string name="aiturk_pair_confirm_title">Connect to this computer?</string>
|
||||
<string name="aiturk_pair_confirm_message">A link is asking AITURK Mobil to connect to the computer at %1$s. Continue only if you opened this link yourself from AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_camera_denied">Camera permission was not granted. You can type the 8-character code instead.</string>
|
||||
<string name="aiturk_pair_error_lan_permission">Access to devices on your local network was not allowed, so the computer could not be reached over Wi-Fi. Allow the permission in the phone\'s settings and try again.</string>
|
||||
<string name="aiturk_pair_error_qr_not_aiturk">This is not an AITURK pairing QR code.</string>
|
||||
<string name="aiturk_pair_error_update_app">This QR code needs a newer AITURK Mobil. Please update the app.</string>
|
||||
<string name="aiturk_pair_error_qr_invalid">The QR code is incomplete or damaged. Open Phone connection in AITURK IDE again and retry.</string>
|
||||
<string name="aiturk_pair_error_code_invalid">The code has 8 characters. Type it exactly as shown in AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_code_not_found">Code not found or expired. Check it, or open Phone connection in AITURK IDE again for a new code.</string>
|
||||
<string name="aiturk_pair_error_rate_limited">Too many attempts. Wait a minute and try again.</string>
|
||||
<string name="aiturk_pair_error_gateway">The code service could not be reached. Check your internet connection, or scan the QR code instead.</string>
|
||||
<string name="aiturk_pair_error_pc_unreachable">Your computer could not be reached. Make sure the phone and the computer are on the same Wi-Fi and Phone connection is open in AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_wrong_code">Wrong code. Check the code in AITURK IDE and try again.</string>
|
||||
<string name="aiturk_pair_error_pairing_closed">Pairing is closed, or the code expired or was already used. Open Phone connection in AITURK IDE again.</string>
|
||||
<string name="aiturk_pair_error_pairing_locked">Too many wrong codes, pairing was cancelled. Start Phone connection again in AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_unexpected">The computer gave an unexpected answer. Update AITURK IDE and AITURK Mobil, then try again.</string>
|
||||
<string name="aiturk_pair_error_save_failed">Paired, but the connection could not be saved. Please try again.</string>
|
||||
<string name="aiturk_pair_error_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off. Open AITURK IDE, turn on Phone connection and try again.</string>
|
||||
<string name="aiturk_pair_error_host_timeout">The computer did not answer. Check that AITURK IDE is running and try again.</string>
|
||||
<string name="aiturk_pair_error_relay_busy">Too many connections. Try again in a moment.</string>
|
||||
<string name="aiturk_pair_error_relay_unknown">This pairing is no longer valid. Pair again from AITURK IDE.</string>
|
||||
<string name="aiturk_pair_error_tls_pin">The computer\'s identity could not be verified. Pair again from AITURK IDE.</string>
|
||||
<string name="aiturk_pair_security_title">Connected</string>
|
||||
<string name="aiturk_pair_security_message">Security code: %1$s\n\nIt should match the security code shown under the QR code in AITURK IDE. The connection is end-to-end encrypted.</string>
|
||||
<string name="aiturk_pair_security_ok">OK</string>
|
||||
<string name="aiturk_pair_error_no_internet">The internet could not be reached. Check the phone\'s Wi-Fi or mobile data and try again.</string>
|
||||
<string name="aiturk_pair_error_relay">Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil.</string>
|
||||
<string name="aiturk_pair_error_code">Diagnostic code: %1$s</string>
|
||||
<string name="aiturk_status_host_offline">AITURK IDE is not open on the computer, or Phone connection is turned off</string>
|
||||
<string name="aiturk_feature_unavailable">This feature is not available in this version of AITURK IDE.</string>
|
||||
<string name="aiturk_feature_unavailable_hint">Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil.</string>
|
||||
<string name="aiturk_phone_forbidden">For security this cannot be done from the phone. Do it in AITURK IDE on your computer.</string>
|
||||
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
|
||||
<string name="cron_repeat_label">Repeat</string>
|
||||
<string name="cron_repeat_hourly">Every hour</string>
|
||||
<string name="cron_repeat_minutely">Every minute</string>
|
||||
<string name="cron_repeat_yearly">Every year</string>
|
||||
<string name="cron_repeat_seconds_unavailable">Every second · unsupported by server</string>
|
||||
<string name="cron_repeat_month">Month</string>
|
||||
<string name="cron_repeat_minute">At which minute of the hour?</string>
|
||||
<string name="cron_summary_daily">Every day at %1$s</string>
|
||||
<string name="cron_summary_hourly">Every hour at minute %1$02d</string>
|
||||
<string name="cron_summary_weekly">%1$s · %2$s</string>
|
||||
<string name="cron_summary_monthly">Day %1$d of each month · %2$s</string>
|
||||
<string name="cron_summary_yearly">Every year on %1$d/%2$d · %3$s</string>
|
||||
<string name="cron_summary_interval">Every %1$d %2$s</string>
|
||||
<string name="cron_repeat_calendar_note">Skips months or years without the selected date.</string>
|
||||
<string name="cron_repeat_count">Interval</string>
|
||||
<string name="cron_repeat_unit">Unit</string>
|
||||
<string name="cron_schedule_timezone_note">Times use the time zone configured on the connected AITURK server.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Registers Hermes Mobile as a candidate for the Android digital-assistant
|
||||
Registers AITURK Mobil as a candidate for the Android digital-assistant
|
||||
role (Settings > Apps > Default apps > Digital assistant app).
|
||||
|
||||
Both sessionService and recognitionService are REQUIRED by the framework
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||
import com.m57.hermescontrol.ChatScreen
|
||||
import com.m57.hermescontrol.FilesScreen
|
||||
import com.m57.hermescontrol.KeysScreen
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.SettingsVault
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.mapHttpError
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.ReplayTracker
|
||||
import com.m57.hermescontrol.data.ws.RpcChannel
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.ChatWsEventReducer
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
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
|
||||
|
||||
/**
|
||||
* A paired AITURK IDE does not have every method and route this app knows.
|
||||
* The user must never read `JsonRpcError(code=-32601, …)` or `unknown method`,
|
||||
* a missing method must not be asked for again and again, and none of this may
|
||||
* change how a plain Hermes dashboard connection behaves.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AiturkCompatTest {
|
||||
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
|
||||
private val forbidden = "Güvenlik nedeniyle bu işlem telefondan yapılamaz."
|
||||
private val backendNotReady = "Seçili profil IDE’de henüz hazır değil."
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
AiturkCompat.resetForTest()
|
||||
AiturkCompat.stringLookup = { id ->
|
||||
when (id) {
|
||||
R.string.aiturk_feature_unavailable -> unavailable
|
||||
R.string.aiturk_phone_forbidden -> forbidden
|
||||
R.string.aiturk_backend_not_ready -> backendNotReady
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() = AiturkCompat.resetForTest()
|
||||
|
||||
@Test
|
||||
fun phoneProfileIsExplicitOnWebSocketsAndPlainHermesIsUnchanged() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals(mapOf("profile" to "default"), AiturkCompat.webSocketProfile(null))
|
||||
assertEquals(mapOf("profile" to "kodlama"), AiturkCompat.webSocketProfile("kodlama"))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertTrue(AiturkCompat.webSocketProfile("kodlama").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unresolvedIdeBackendGetsAnActionableMessageWithoutChangingOtherServerErrors() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals(backendNotReady, AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"different_failure"}"""))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
|
||||
}
|
||||
|
||||
private class Gateway(
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val sent = mutableListOf<Pair<String, String>>()
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val channel =
|
||||
RpcChannel(
|
||||
scope = scope,
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
sendRequest = { method, _, onSent ->
|
||||
val id = "req-${sent.size + 1}"
|
||||
sent.add(id to method)
|
||||
onSent?.invoke(id)
|
||||
id
|
||||
},
|
||||
sendFrame = { true },
|
||||
removeQueuedMessage = {},
|
||||
onIdleCheck = {},
|
||||
)
|
||||
val replay =
|
||||
ReplayTracker(
|
||||
scope = scope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
fun answerUnknownMethod(
|
||||
id: String,
|
||||
method: String,
|
||||
) = channel.handleIncomingFrame(
|
||||
"""{"jsonrpc":"2.0","id":"$id","error":{"code":-32601,"message":"unknown method: $method"}}""",
|
||||
replay,
|
||||
) { events.add(it) }
|
||||
}
|
||||
|
||||
private fun failure(deferred: CompletableDeferred<Any?>): HermesWsClient.HermesRpcException =
|
||||
deferred.getCompletionExceptionOrNull() as HermesWsClient.HermesRpcException
|
||||
|
||||
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun unknownMethodOnPairedIdeFailsQuietlyWithAPlainSentence() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
// No RpcError event: the chat reducer would have turned it into a banner.
|
||||
assertTrue(gateway.events.isEmpty(), "events: ${gateway.events}")
|
||||
val error = failure(call)
|
||||
assertEquals(-32601, error.code)
|
||||
assertEquals(unavailable, error.message)
|
||||
assertFalse(error.message.orEmpty().contains("unknown method"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aMissingMethodIsNotSentAgainUntilTheNextSocket() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
val again = gateway.channel.request("connectors.list")
|
||||
|
||||
assertEquals(listOf("req-1" to "connectors.list"), gateway.sent)
|
||||
assertEquals(-32601, failure(again).code)
|
||||
assertEquals(unavailable, failure(again).message)
|
||||
assertTrue(gateway.events.isEmpty())
|
||||
|
||||
// Another method is unaffected, and a new socket (maybe a newer IDE) asks again.
|
||||
gateway.channel.request("session.list")
|
||||
AiturkRelayStatus.onSocketOpened()
|
||||
gateway.channel.request("connectors.list")
|
||||
assertEquals(listOf("connectors.list", "session.list", "connectors.list"), gateway.sent.map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun plainHermesConnectionKeepsTheUpstreamBehaviour() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = false
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("connectors.list")
|
||||
gateway.answerUnknownMethod("req-1", "connectors.list")
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertTrue(gateway.events.single() is WsEvent.RpcError)
|
||||
assertEquals("unknown method: connectors.list", failure(call).message)
|
||||
gateway.channel.request("connectors.list")
|
||||
assertEquals(2, gateway.sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun otherRpcErrorsOnPairedIdeStillReachTheirConsumers() =
|
||||
runTest {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
val call = gateway.channel.request("session.resume")
|
||||
gateway.channel.handleIncomingFrame(
|
||||
"""{"jsonrpc":"2.0","id":"req-1","error":{"code":4001,"message":"session not found"}}""",
|
||||
gateway.replay,
|
||||
) { gateway.events.add(it) }
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertEquals("session not found", failure(call).message)
|
||||
gateway.channel.request("session.resume")
|
||||
assertEquals(2, gateway.sent.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fireAndForgetUnknownMethodStillEmitsItsEvent() =
|
||||
runTest {
|
||||
// ChatViewModel needs this event to fall back from session.branch_whole to session.branch.
|
||||
AiturkCompat.linkedOverride = true
|
||||
val gateway = Gateway(this)
|
||||
|
||||
gateway.answerUnknownMethod("77", "session.branch_whole")
|
||||
|
||||
assertEquals(1, gateway.events.size)
|
||||
assertEquals(-32601, (gateway.events.single() as WsEvent.RpcError).error.code)
|
||||
}
|
||||
|
||||
// ── Banner text ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun chatBannerNeverShowsTheRawErrorOnPairedIde() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
val error = JsonRpcError(-32601, "unknown method: connectors.list")
|
||||
|
||||
val reduced =
|
||||
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
|
||||
|
||||
assertEquals(unavailable, reduced.state.errorMessage)
|
||||
val other = JsonRpcError(4001, "session not found")
|
||||
assertEquals("session not found", AiturkCompat.rpcBannerText(other, legacy = "Error: $other"))
|
||||
assertEquals(unavailable, AiturkCompat.rpcBannerText(mapOf("code" to -32601), legacy = "x"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatBannerIsUnchangedForPlainHermes() {
|
||||
AiturkCompat.linkedOverride = false
|
||||
val error = JsonRpcError(-32601, "unknown method: connectors.list")
|
||||
|
||||
val reduced =
|
||||
ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), WsEvent.RpcError("9", error), null)
|
||||
|
||||
assertEquals("Error: $error", reduced.state.errorMessage)
|
||||
}
|
||||
|
||||
// ── REST ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun proxyRefusalAndMissingRouteGetPlainSentences() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
|
||||
assertEquals(forbidden, mapHttpError(403, """{"error":"forbidden"}""").message)
|
||||
assertEquals(unavailable, mapHttpError(404, """{"detail":"Not Found"}""").message)
|
||||
assertEquals(unavailable, mapHttpError(404, """{"error":"not_found"}""").message)
|
||||
assertEquals(403, (mapHttpError(403, """{"error":"forbidden"}""") as NetworkError.Http).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realRecordErrorsAndPlainHermesKeepTheirOwnText() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals("Session not found", mapHttpError(404, """{"detail":"Session not found"}""").message)
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, """{"detail":"Profile is read-only"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(500, """{"error":"forbidden"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, "not json"))
|
||||
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertNull(AiturkCompat.httpErrorMessage(403, """{"error":"forbidden"}"""))
|
||||
assertTrue(mapHttpError(403, """{"error":"forbidden"}""").message.startsWith("Forbidden (HTTP 403)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsavedChatSkipsTheSessionDetailPollOnlyOnPairedIde() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertTrue(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
|
||||
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = false))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertFalse(AiturkCompat.skipSessionDetail(transcriptEmpty = true))
|
||||
}
|
||||
|
||||
// ── Network and lifecycle ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun returningToTheForegroundReopensAnIdleClosedSocket() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
var connects = 0
|
||||
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
|
||||
assertEquals(1, connects)
|
||||
|
||||
// Open, opening, signed out or switched off by the user: left alone.
|
||||
for (status in ConnectionStatus.entries - ConnectionStatus.DISCONNECTED) {
|
||||
AiturkCompat.onAppForeground(status, autoReconnect = { true }) { connects++ }
|
||||
}
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { false }) { connects++ }
|
||||
AiturkCompat.linkedOverride = false
|
||||
AiturkCompat.onAppForeground(ConnectionStatus.DISCONNECTED, autoReconnect = { true }) { connects++ }
|
||||
assertEquals(1, connects)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun networkChangeDropsIdleTunnelConnectionsOnlyOnPairedIde() {
|
||||
var evictions = 0
|
||||
AiturkCompat.linkedOverride = true
|
||||
AiturkCompat.onNetworkChanged { evictions++ }
|
||||
assertEquals(1, evictions)
|
||||
|
||||
AiturkCompat.linkedOverride = false
|
||||
AiturkCompat.onNetworkChanged { evictions++ }
|
||||
assertEquals(1, evictions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewSocketClearsTheIdeClosedNote() {
|
||||
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
|
||||
assertTrue(AiturkRelayStatus.hostOffline.value)
|
||||
|
||||
AiturkRelayStatus.onSocketOpened()
|
||||
|
||||
assertFalse(AiturkRelayStatus.hostOffline.value)
|
||||
}
|
||||
|
||||
// ── Screens ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun screensThePairedIdeCannotServeAreHidden() {
|
||||
for (key in listOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)) {
|
||||
assertFalse(AiturkFeatureGate.shows(key, linked = true), "$key")
|
||||
assertTrue(AiturkFeatureGate.shows(key, linked = false), "$key")
|
||||
}
|
||||
assertTrue(AiturkFeatureGate.shows(ChatScreen, linked = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionLabelShowsNameAndBuildNumber() {
|
||||
assertEquals("v1.4.0 (37)", AiturkCompat.versionLabel("1.4.0", 37))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user