From 827c74b3c38ff5d1398cebeae4087a8e179b3e2e Mon Sep 17 00:00:00 2001 From: yilsem Date: Mon, 5 Oct 2026 17:21:32 +0300 Subject: [PATCH] feat(aiturk): hide what the paired IDE cannot serve, plain error sentences, reconnect on return 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/ - the pairing screen shows the app version A plain Hermes connection is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf --- .../com/m57/hermescontrol/HermesControlApp.kt | 2 + .../java/com/m57/hermescontrol/Navigation.kt | 9 +- .../hermescontrol/data/aiturk/AiturkCompat.kt | 204 ++++++++++++ .../hermescontrol/data/aiturk/AiturkTunnel.kt | 1 + .../data/remote/NetworkResult.kt | 4 + .../m57/hermescontrol/data/ws/RpcChannel.kt | 15 +- .../m57/hermescontrol/data/ws/WsTransport.kt | 2 + .../notification/ChatNotificationService.kt | 5 + .../ui/aiturkpair/AiturkFeatureGate.kt | 60 ++++ .../ui/aiturkpair/AiturkPairScreen.kt | 11 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 33 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 5 +- .../ui/chat/ChatWsEventReducer.kt | 3 +- .../ui/settings/SettingsScreen.kt | 7 +- app/src/main/res/values-tr/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../data/aiturk/AiturkCompatTest.kt | 305 ++++++++++++++++++ .../data/aiturk/AiturkSocketOpenHookTest.kt | 57 ++++ 18 files changed, 709 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkFeatureGate.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkSocketOpenHookTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index fe0ea4fe..9b0df7e1 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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). diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 9336c209..467c433d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -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) }, diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt new file mode 100644 index 00000000..8a3d92d2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkCompat.kt @@ -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 = 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/` 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 +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt index 936e2ac0..99a28d0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkTunnel.kt @@ -710,5 +710,6 @@ object AiturkRelayStatus { fun onSocketOpened() { _hostOffline.value = false + AiturkCompat.onSocketOpened() } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index a19c8281..45473dfa 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -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) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt index 6f8cfa5d..2345853f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/RpcChannel.kt @@ -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 { val deferred = CompletableDeferred() + // 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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt index 3ce464e5..32af7415 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsTransport.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 0f3ac200..db5308b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -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() } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkFeatureGate.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkFeatureGate.kt new file mode 100644 index 00000000..dd01eb5a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkFeatureGate.kt @@ -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 = 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"), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt index 3e43602a..5b294ca5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/aiturkpair/AiturkPairScreen.kt @@ -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, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 8e26461b..1106561f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index dc823d32..8d2b90b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index fa3fc094..6bdc3802 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index 8669d16c..7d111aa0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -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), diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9d4f83b9..cc227a8f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1906,4 +1906,8 @@ Bağlantı hatası. Biraz sonra tekrar deneyin; sürerse AITURK IDE ve AITURK Mobil\'i güncelleyin. Tanı kodu: %1$s Bilgisayarda AITURK IDE açık değil ya da Telefon bağlantısı kapalı + Bu özellik AITURK IDE\'nin bu sürümünde yok. + Bilgisayardaki AITURK IDE\'den kullanın ya da AITURK IDE ve AITURK Mobil\'i güncelleyin. + Güvenlik nedeniyle bu işlem telefondan yapılamaz. Bilgisayardaki AITURK IDE\'den yapın. + AITURK Mobil %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86f64a41..124900c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1906,4 +1906,8 @@ Connection error. Try again in a moment; if it continues, update AITURK IDE and AITURK Mobil. Diagnostic code: %1$s AITURK IDE is not open on the computer, or Phone connection is turned off + This feature is not available in this version of AITURK IDE. + Use it in AITURK IDE on your computer, or update AITURK IDE and AITURK Mobil. + For security this cannot be done from the phone. Do it in AITURK IDE on your computer. + AITURK Mobil %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt new file mode 100644 index 00000000..09f7c444 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkCompatTest.kt @@ -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>() + val events = mutableListOf() + 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): 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)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkSocketOpenHookTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkSocketOpenHookTest.kt new file mode 100644 index 00000000..d428730b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkSocketOpenHookTest.kt @@ -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(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } 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(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")) + } +}