Author SHA1 Message Date
yilsem 5006d1c7a8 ci: bound compiler concurrency and size Kotlin heap
Android acceptance / android (push) Successful in 18m53s
2026-10-07 21:15:02 +03:00
yilsem 004d4622c4 ci: transfer the accepted workstation Android SDK license
Android acceptance / android (push) Failing after 10m26s
2026-10-07 20:58:24 +03:00
yilsem 824f0a07e2 ci: accept package licenses at explicit SDK root and verify installation
Android acceptance / android (push) Failing after 31s
2026-10-07 20:54:18 +03:00
yilsem 71dd92a4c2 ci: install checksum-pinned Android tools with native Windows extraction
Android acceptance / android (push) Failing after 2m17s
2026-10-07 20:47:21 +03:00
yilsem ebdfeddd9a ci: retain compatible Windows extraction with pinned modern SDK tools
Android acceptance / android (push) Failing after 35s
2026-10-07 20:44:03 +03:00
yilsem 017864a0db ci: use current SDK tools and exact Android 37.0 package
Android acceptance / android (push) Failing after 25s
2026-10-07 20:37:37 +03:00
yilsem 6472d876b3 ci: isolate Android SDK from spaced Windows user paths
Android acceptance / android (push) Failing after 30s
2026-10-07 20:32:39 +03:00
yilsem 9e4e979e7a ci: provision Android SDK and Java on the Windows runner
Android acceptance / android (push) Failing after 34s
2026-10-07 20:28:27 +03:00
yilsem cb7e6acb59 fix(mobile): align profile routing, catalog timeout and replay cancellation
Android acceptance / android (push) Failing after 6s
2026-10-07 20:23:17 +03:00
yilsemandClaude Opus 5.5 827c74b3c3 feat(aiturk): hide what the paired IDE cannot serve, plain error sentences, reconnect on return
Merge Conflict Detector / detect-and-label (push) Successful in 3s
Android CI/CD / Detect Changes (push) Successful in 8s
Android CI/CD / Android Lint — Static Analysis (push) Failing after 2m58s
Android CI/CD / Release Compile Check (push) Failing after 1m32s
CodeQL Analysis / Analyze (java-kotlin) (push) Failing after 3m47s
Android CI/CD / ktlint — Code Style (push) Failing after 3m52s
Android CI/CD / Unit & Integration Tests (push) Failing after 3m56s
Android CI/CD / Build Debug APK (push) Failing after 4m7s
Android CI/CD / Instrumented Tests (push) Failing after 1m18s
Android CI/CD / CI Summary (push) Failing after 1s
When paired with AITURK IDE:
- an "unknown method" (-32601) answer and the proxy's 403/route-404 are shown
  as plain sentences, never as raw JsonRpcError text; a method the IDE does
  not have is not sent again on the same socket
- Account connectors, Vault, Keys and Files are hidden (the IDE has no
  connectors.* / vault.* and its phone proxy refuses /api/env and /api/files)
- an idle-closed chat socket is reopened when the app returns to the
  foreground; idle tunnel connections are dropped on a network change
- an unsaved chat no longer polls /api/sessions/<id>
- the pairing screen shows the app version

A plain Hermes connection is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
2026-10-05 17:21:32 +03:00
37 changed files with 1063 additions and 34 deletions
+36
View File
@@ -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
+5
View File
@@ -21,6 +21,7 @@ android {
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
@@ -326,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") }
@@ -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)
}
}
}
@@ -35,6 +35,8 @@ class HermesControlApp :
}
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).
@@ -52,7 +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
@@ -115,7 +117,11 @@ private fun appEntryProvider(
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)
}
}
}
@@ -325,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) },
@@ -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
}
@@ -710,5 +710,6 @@ object AiturkRelayStatus {
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),
)
}
@@ -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)
}
}
@@ -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) {
@@ -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
@@ -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()
@@ -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()
}
@@ -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"),
)
}
}
@@ -43,6 +43,7 @@ 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
@@ -61,6 +62,7 @@ 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
@@ -286,6 +288,15 @@ fun AiturkPairScreen(
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,
)
}
}
}
@@ -79,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
@@ -94,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
@@ -601,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,
@@ -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
@@ -2317,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,
)
}
@@ -6038,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
@@ -997,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,
)
@@ -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 ->
@@ -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,
@@ -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),
+5
View File
@@ -1,4 +1,5 @@
<resources>
<string name="aiturk_backend_not_ready">Seçili profil AITURK IDE’de henüz hazır değil. Bilgisayarda IDE’yi açık tutun; kurulum tamamlanınca yeniden deneyin.</string>
<string name="app_name">AITURK Mobil</string>
<!-- Chat -->
@@ -1906,4 +1907,8 @@
<string name="aiturk_pair_error_relay">Bağlantı hatası. Biraz sonra tekrar deneyin; sürerse AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
<string name="aiturk_pair_error_code">Tanı kodu: %1$s</string>
<string name="aiturk_status_host_offline">Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı</string>
<string name="aiturk_feature_unavailable">Bu özellik AITURK IDE\'nin bu sürümünde yok.</string>
<string name="aiturk_feature_unavailable_hint">Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin.</string>
<string name="aiturk_phone_forbidden">Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın.</string>
<string name="aiturk_app_version">AITURK Mobil %1$s</string>
</resources>
+5
View File
@@ -1,4 +1,5 @@
<resources>
<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 -->
@@ -1906,4 +1907,8 @@
<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>
</resources>
@@ -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))
}
}
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.data.aiturk
import android.util.Log
import com.m57.hermescontrol.data.ws.HermesWsClient
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import okhttp3.WebSocket
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* The chat socket opening again (IDE restarted, network back) must clear the
* "AITURK IDE is closed" note and forget which methods the previous IDE
* process lacked. This drives the real socket listener, not the helper.
*/
class AiturkSocketOpenHookTest {
@BeforeEach
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
AiturkCompat.resetForTest()
HermesWsClient.disconnect(clearPendingMessages = true)
}
@AfterEach
fun tearDown() {
HermesWsClient.disconnect(clearPendingMessages = true)
AiturkCompat.resetForTest()
AiturkRelayStatus.onSocketEnded(null)
unmockkAll()
}
@Test
fun openingTheChatSocketClearsIdeClosedNoteAndMissingMethods() {
AiturkCompat.linkedOverride = true
AiturkRelayStatus.onSocketEnded(AiturkTunnelException(AiturkTunnelException.HOST_OFFLINE, 4503))
AiturkCompat.noteRpcError("connectors.list", AiturkCompat.METHOD_NOT_FOUND)
assertTrue(AiturkRelayStatus.hostOffline.value)
assertTrue(AiturkCompat.isKnownMissing("connectors.list"))
val socket = mockk<WebSocket>(relaxed = true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(7)
HermesWsClient.createListenerForTest(7).onOpen(socket, mockk(relaxed = true))
assertTrue(HermesWsClient.isConnected)
assertFalse(AiturkRelayStatus.hostOffline.value)
assertFalse(AiturkCompat.isKnownMissing("connectors.list"))
}
}
@@ -0,0 +1,36 @@
package com.m57.hermescontrol.data.remote
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.concurrent.TimeUnit
class CatalogTimeoutInterceptorTest {
@Test
fun `only catalog reads receive the discovery timeout including proxy prefixes`() {
for (path in listOf("/api/model/options", "/hermes/api/model/options", "/api/cron/jobs", "/api/model/options-extra")) {
var timeout = 0
val client =
OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(CatalogTimeoutInterceptor)
.addInterceptor { chain ->
timeout = chain.readTimeoutMillis()
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("{}".toResponseBody())
.build()
}
.build()
client.newCall(Request.Builder().url("https://test.invalid$path").build()).execute().close()
assertEquals(if (path.endsWith("/api/model/options")) 120_000 else 30_000, timeout)
}
}
}
@@ -98,9 +98,9 @@ class GatewayFileClientTest {
}
@Test
fun `normalizePath expands tilde`() {
val home = System.getenv("HOME") ?: "/home/test"
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
fun `normalizePath expands tilde only when home is known`() {
val home = System.getenv("HOME")
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
}
@Test
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
rootDir
.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.filter { !it.invariantSeparatorsPath.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.forEach { file ->
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
val code = stripComments(file.readText())
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
@Test
fun `cancelled replay does not report failure or request stale transcript`() = runTest {
val started = CompletableDeferred<Unit>()
var failures = 0
val events = mutableListOf<WsEvent>()
val tracker = ReplayTracker(
scope = this,
fetchEventsSince = {
started.complete(Unit)
kotlinx.coroutines.awaitCancellation()
},
emitEvent = { events.add(it) },
onReplayFailure = { _, _ -> failures++ },
)
tracker.setSeqWatermark("old-profile", 1)
tracker.triggerReplay()
started.await()
tracker.clear()
advanceUntilIdle()
assertEquals(0, failures)
assertTrue(events.isEmpty())
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `initial state has empty watermarks and no replay in flight`() {
val testDispatcher = StandardTestDispatcher()
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
assertEquals("gateway unreachable", job.last_fire_error?.detail)
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
}
@Test
fun `lost create response does not create duplicate scheduled jobs`() {
val vm = createViewModel()
vm.openNewJobDialog()
settle()
vm.updateEditorField("name", "Retry safety")
vm.updateEditorField("schedule", "every 1d")
coEvery { mockApi.createCronJob(any()) } throws java.net.SocketTimeoutException("reply lost")
vm.saveEditor()
settle()
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
assertTrue(vm.uiState.value.editorState.isOpen)
assertFalse(vm.uiState.value.editorState.isSaving)
assertNotNull(vm.uiState.value.editorState.toastMessage)
}
@Test
fun `lost trigger response does not run a scheduled job twice`() {
val vm = createViewModel()
coEvery { mockApi.triggerCronJob("j-test") } throws java.net.SocketTimeoutException("reply lost")
vm.triggerCronJob("j-test")
settle()
coVerify(exactly = 1) { mockApi.triggerCronJob("j-test") }
assertNotNull(vm.uiState.value.toastMessage)
}
}
+33
View File
@@ -0,0 +1,33 @@
# AITURK Mobil beta.5 candidate
Paired IDE REST, chat and Kanban sockets select the same phone profile. Requires
IDE beta.19 for the desktop routing/startup fix. An unavailable backend returns
an actionable Turkish message instead of waiting indefinitely.
Cold model discovery took 38 seconds on the actual backend. Only model catalog
REST/RPC reads now allow 120 seconds; ordinary requests keep their prior limit.
Cron create, blueprint creation and manual trigger are not replayed by the
application retry loop after an ambiguous network failure. This does not claim
server-side exactly-once execution or disable every transport-level retry.
Disconnect cancellation no longer reports replay as a remote error or asks for
the old transcript. Media streaming uses platform-absolute paths/file URIs.
Windows test portability: HOME absence, invariant path separators and an
explicit JVM test locale; APK localization is unchanged.
## Acceptance gates
- Targeted JVM tests pass, including every previously failing class, profile,
Cron, relay, catalog, replay cancellation and media tests.
- Gitea runs the complete JVM suite, debug build and release compilation on the
exact source commit before publication. No excluded failing test list.
- Opt-in Android public-relay test verifies TLS with the actual production
OkHttp stack. It uses only public relay ID/certificate fingerprint arguments.
- Desktop's opt-in real TLS proxy test covers screen reads, Cron record
create/update/pause/resume/delete and chat ping. No real agent run is triggered.
- Signed release, installed UI acceptance and physical-phone/cellular testing
remain separate gates, not implied by unit or hello tests.
Not included: bypassing IDE phone security restrictions, implementing missing
upstream RPCs, or claiming every advanced feature is complete. The plugin
catalog route in the current backend is absent (404) and needs integration.
@@ -0,0 +1,2 @@
24333f8a63b6825ea9c5514f83c2829b004d1fee
+43
View File
@@ -0,0 +1,43 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Official Windows command-line tools 22.0; checksum from developer.android.com/studio.
# Explicit .zip avoids tool-cache's extensionless archive bug on Windows PowerShell.
$sdkRoot = Join-Path $env:RUNNER_TEMP 'android-sdk'
$archive = Join-Path $env:RUNNER_TEMP 'commandlinetools-win-15859902.zip'
$unpack = Join-Path $env:RUNNER_TEMP 'android-tools-extracted'
$expected = '90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a'
Invoke-WebRequest 'https://dl.google.com/android/repository/commandlinetools-win-15859902_latest.zip' -OutFile $archive -UseBasicParsing
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) {
throw 'Android command-line tools checksum mismatch'
}
Expand-Archive -LiteralPath $archive -DestinationPath $unpack
$toolsRoot = Join-Path $sdkRoot 'cmdline-tools'
New-Item -ItemType Directory -Path $toolsRoot -Force | Out-Null
# Both absolute paths are bounded to the runner's fresh temporary directory.
$source = [IO.Path]::GetFullPath((Join-Path $unpack 'cmdline-tools'))
$destination = [IO.Path]::GetFullPath((Join-Path $toolsRoot 'latest'))
$boundary = [IO.Path]::GetFullPath($env:RUNNER_TEMP).TrimEnd('\') + '\'
foreach ($candidate in @($source, $destination)) {
if (-not $candidate.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase)) {
throw 'SDK extraction path escaped runner temporary directory'
}
}
Move-Item -LiteralPath $source -Destination $destination
$env:ANDROID_HOME = $sdkRoot
$env:ANDROID_SDK_ROOT = $sdkRoot
$manager = Join-Path $destination 'bin\sdkmanager.bat'
# Transfer the already accepted workstation license, as documented by Google:
# https://developer.android.com/studio/intro/update#download-with-gradle
# The self-hosted runner does not reliably forward stdin to sdkmanager prompts.
$licenses = Join-Path $sdkRoot 'licenses'
New-Item -ItemType Directory -Path $licenses -Force | Out-Null
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'android-licenses\android-sdk-license') -Destination $licenses
& $manager "--sdk_root=$sdkRoot" 'platform-tools' 'platforms;android-37.0' 'build-tools;37.0.0'
if ($LASTEXITCODE -ne 0) { throw 'Android SDK installation failed' }
foreach ($required in @('platforms\android-37.0\android.jar', 'build-tools\37.0.0\aapt2.exe', 'licenses\android-sdk-license')) {
if (-not (Test-Path -LiteralPath (Join-Path $sdkRoot $required))) {
throw "Android SDK setup incomplete: $required"
}
}
"ANDROID_HOME=$sdkRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"ANDROID_SDK_ROOT=$sdkRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append