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
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
This commit is contained in:
@@ -35,6 +35,8 @@ class HermesControlApp :
|
|||||||
}
|
}
|
||||||
AuthManager.init(this)
|
AuthManager.init(this)
|
||||||
NetworkMonitor.init(this)
|
NetworkMonitor.init(this)
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
|
.init(this)
|
||||||
SessionListCacheStore.init(this)
|
SessionListCacheStore.init(this)
|
||||||
// Reply-notification turn boundaries survive process death (armed before
|
// Reply-notification turn boundaries survive process death (armed before
|
||||||
// each mobile prompt, consumed by the matching completion).
|
// 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.ConnectionStatus
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
|
||||||
import com.m57.hermescontrol.ui.aiturkpair.AiturkPairDeepLinkEffect
|
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.DisableDrawerGestures
|
||||||
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
import com.m57.hermescontrol.ui.common.DrawerGestureController
|
||||||
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
import com.m57.hermescontrol.ui.common.LocalDrawerGestureController
|
||||||
@@ -115,7 +117,11 @@ private fun appEntryProvider(
|
|||||||
|
|
||||||
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
ScreenRegistry.ALL_SCREENS.forEach { screen ->
|
||||||
addEntryProvider(clazz = screen.key::class) {
|
addEntryProvider(clazz = screen.key::class) {
|
||||||
|
if (AiturkFeatureGate.shows(screen.key)) {
|
||||||
screen.content(sessionId, openDrawer)
|
screen.content(sessionId, openDrawer)
|
||||||
|
} else {
|
||||||
|
AiturkUnavailableScreen(titleRes = screen.labelRes, onOpenDrawer = openDrawer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +331,7 @@ fun MainNavigation(sessionId: String? = null) {
|
|||||||
)
|
)
|
||||||
ScreenRegistry.ALL_SCREENS
|
ScreenRegistry.ALL_SCREENS
|
||||||
.filter { it.drawerSection == section }
|
.filter { it.drawerSection == section }
|
||||||
|
.filter { AiturkFeatureGate.shows(it.key) }
|
||||||
.forEach { entry ->
|
.forEach { entry ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
icon = { Icon(entry.icon, contentDescription = null) },
|
icon = { Icon(entry.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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."
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
// ── 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) 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_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
|
||||||
|
}
|
||||||
@@ -710,5 +710,6 @@ object AiturkRelayStatus {
|
|||||||
|
|
||||||
fun onSocketOpened() {
|
fun onSocketOpened() {
|
||||||
_hostOffline.value = false
|
_hostOffline.value = false
|
||||||
|
AiturkCompat.onSocketOpened()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ fun mapHttpError(
|
|||||||
if (code == 401) {
|
if (code == 401) {
|
||||||
return NetworkError.AuthExpired()
|
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
|
// Try to parse FastAPI-style error detail from response body
|
||||||
val detail =
|
val detail =
|
||||||
if (errorBody != null) {
|
if (errorBody != null) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.ws
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -110,6 +111,11 @@ internal class RpcChannel(
|
|||||||
suppressErrorEvent: Boolean = false,
|
suppressErrorEvent: Boolean = false,
|
||||||
): CompletableDeferred<Any?> {
|
): CompletableDeferred<Any?> {
|
||||||
val deferred = 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 =
|
val id =
|
||||||
sendRequest(method, params) { reqId ->
|
sendRequest(method, params) { reqId ->
|
||||||
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
pendingCalls[reqId] = PendingCall(method, deferred, suppressErrorEvent = suppressErrorEvent)
|
||||||
@@ -191,7 +197,7 @@ internal class RpcChannel(
|
|||||||
if (error != null) {
|
if (error != null) {
|
||||||
call.deferred.completeExceptionally(
|
call.deferred.completeExceptionally(
|
||||||
HermesWsClient.HermesRpcException(
|
HermesWsClient.HermesRpcException(
|
||||||
message = error.message,
|
message = AiturkCompat.rpcExceptionMessage(error.code, error.message),
|
||||||
code = error.code,
|
code = error.code,
|
||||||
data = error.data,
|
data = error.data,
|
||||||
),
|
),
|
||||||
@@ -332,7 +338,12 @@ internal class RpcChannel(
|
|||||||
|
|
||||||
is WsEvent.RpcError -> {
|
is WsEvent.RpcError -> {
|
||||||
onError(event.id)
|
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
|
// Opt-in suppression: the caller already handles the
|
||||||
// failure through the CompletableDeferred, so the event
|
// failure through the CompletableDeferred, so the event
|
||||||
// copy would only surface a duplicate UI banner for
|
// copy would only surface a duplicate UI banner for
|
||||||
|
|||||||
@@ -659,6 +659,8 @@ internal class WsTransport(
|
|||||||
outboundDrainJob?.cancel()
|
outboundDrainJob?.cancel()
|
||||||
outboundDrainJob = null
|
outboundDrainJob = null
|
||||||
Log.i(TAG, "WebSocket opened")
|
Log.i(TAG, "WebSocket opened")
|
||||||
|
com.m57.hermescontrol.data.aiturk.AiturkRelayStatus
|
||||||
|
.onSocketOpened()
|
||||||
connected.set(true)
|
connected.set(true)
|
||||||
_connectionStatus.value = ConnectionStatus.CONNECTED
|
_connectionStatus.value = ConnectionStatus.CONNECTED
|
||||||
reconnectPolicy.reset()
|
reconnectPolicy.reset()
|
||||||
|
|||||||
@@ -726,6 +726,11 @@ object NotificationHelper {
|
|||||||
) {
|
) {
|
||||||
ChatNotificationService.setAppForeground(foreground)
|
ChatNotificationService.setAppForeground(foreground)
|
||||||
HermesWsClient.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) {
|
if (!foreground) {
|
||||||
BackgroundConnectionController.default.reconcileState()
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.ScanContract
|
||||||
import com.journeyapps.barcodescanner.ScanOptions
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
import com.m57.hermescontrol.data.aiturk.AiturkPairing
|
||||||
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
|
||||||
|
|
||||||
@@ -286,6 +288,15 @@ fun AiturkPairScreen(
|
|||||||
TextButton(onClick = onBack, enabled = !state.busy) {
|
TextButton(onClick = onBack, enabled = !state.busy) {
|
||||||
Text(stringResource(R.string.aiturk_pair_back))
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.AccountConnectorsScreen
|
||||||
import com.m57.hermescontrol.BuildConfig
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.HistoryScreen
|
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.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.notification.NotificationHelper
|
import com.m57.hermescontrol.notification.NotificationHelper
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
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.ChatAppUpdateSection
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
|
||||||
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
|
||||||
@@ -601,6 +603,8 @@ fun ChatScreen(
|
|||||||
modifier = Modifier.testTag("chat_menu_search"),
|
modifier = Modifier.testTag("chat_menu_search"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
|
||||||
|
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
@@ -615,6 +619,7 @@ fun ChatScreen(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
modifier = Modifier.testTag("chat_menu_session_integrations"),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
ChatBackgroundConnectionMenuItem(
|
ChatBackgroundConnectionMenuItem(
|
||||||
checked = settingsState.keepConnectedInBackground,
|
checked = settingsState.keepConnectedInBackground,
|
||||||
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkCompat
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.local.DataScope
|
import com.m57.hermescontrol.data.local.DataScope
|
||||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||||
@@ -2317,7 +2318,7 @@ class ChatViewModel(
|
|||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error ($method): $errorMsg",
|
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
|
||||||
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6038,6 +6039,8 @@ class ChatViewModel(
|
|||||||
) {
|
) {
|
||||||
return@launch
|
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 =
|
val usedResult =
|
||||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.deltaFrom
|
||||||
import com.m57.hermescontrol.data.model.mergeWith
|
import com.m57.hermescontrol.data.model.mergeWith
|
||||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||||
@@ -997,7 +998,7 @@ object ChatWsEventReducer {
|
|||||||
state =
|
state =
|
||||||
state.copy(
|
state.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Error: ${event.error}",
|
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
|
||||||
),
|
),
|
||||||
streamingState = streamingState,
|
streamingState = streamingState,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.m57.hermescontrol.SettingsConnection
|
|||||||
import com.m57.hermescontrol.SettingsLanguage
|
import com.m57.hermescontrol.SettingsLanguage
|
||||||
import com.m57.hermescontrol.SettingsVault
|
import com.m57.hermescontrol.SettingsVault
|
||||||
import com.m57.hermescontrol.theme.ThemePreference
|
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.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
import com.m57.hermescontrol.ui.settings.components.SupportSection
|
||||||
@@ -74,7 +75,7 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
SettingsCategoryCard(
|
SettingsCategoryCard(
|
||||||
items =
|
items =
|
||||||
listOf(
|
listOfNotNull(
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.AutoMirrored.Filled.ListAlt,
|
icon = Icons.AutoMirrored.Filled.ListAlt,
|
||||||
label = stringResource(R.string.settings_sec_connection),
|
label = stringResource(R.string.settings_sec_connection),
|
||||||
@@ -94,7 +95,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.screen_account_connectors),
|
label = stringResource(R.string.screen_account_connectors),
|
||||||
summary = stringResource(R.string.account_connectors_summary),
|
summary = stringResource(R.string.account_connectors_summary),
|
||||||
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
onClick = { NavigationController.navigateTo(AccountConnectorsScreen) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(AccountConnectorsScreen) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Palette,
|
icon = Icons.Filled.Palette,
|
||||||
label = stringResource(R.string.settings_sec_appearance),
|
label = stringResource(R.string.settings_sec_appearance),
|
||||||
@@ -136,7 +137,7 @@ fun SettingsScreen(
|
|||||||
label = stringResource(R.string.settings_sec_vault),
|
label = stringResource(R.string.settings_sec_vault),
|
||||||
summary = stringResource(R.string.settings_summary_vault),
|
summary = stringResource(R.string.settings_summary_vault),
|
||||||
onClick = { NavigationController.navigateTo(SettingsVault) },
|
onClick = { NavigationController.navigateTo(SettingsVault) },
|
||||||
),
|
).takeIf { AiturkFeatureGate.shows(SettingsVault) },
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
icon = Icons.Filled.Info,
|
icon = Icons.Filled.Info,
|
||||||
label = stringResource(R.string.settings_sec_about),
|
label = stringResource(R.string.settings_sec_about),
|
||||||
|
|||||||
@@ -1906,4 +1906,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_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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -1906,4 +1906,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_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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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."
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
AiturkCompat.resetForTest()
|
||||||
|
AiturkCompat.stringLookup = { id ->
|
||||||
|
when (id) {
|
||||||
|
R.string.aiturk_feature_unavailable -> unavailable
|
||||||
|
R.string.aiturk_phone_forbidden -> forbidden
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() = AiturkCompat.resetForTest()
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user