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/<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:
yilsemandClaude Opus 5.5 committed 2026-10-05 17:21:32 +03:00
1 parent c2d828b90a
commit 827c74b3c3
18 files changed
+709 -22

No files matched your search

@@ -0,0 +1,60 @@
package com.m57.hermescontrol.ui.aiturkpair
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.FilesScreen
import com.m57.hermescontrol.KeysScreen
import com.m57.hermescontrol.R
import com.m57.hermescontrol.SettingsVault
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
/**
* Screens a paired AITURK IDE cannot serve, hidden from the drawer and the
* settings list while that pairing is the active connection:
*
* - Account connectors, Credential vault: the IDE's agent has no
* `connectors.*` / `vault.*` methods;
* - Keys, Files: the IDE's phone proxy refuses `/api/env` and `/api/files`
* on purpose (secrets and the computer's files stay on the computer).
*
* A plain Hermes dashboard connection shows everything, as upstream does.
*/
object AiturkFeatureGate {
val HIDDEN_WHEN_LINKED: Set<NavKey> = setOf(AccountConnectorsScreen, KeysScreen, FilesScreen, SettingsVault)
fun shows(
key: NavKey,
linked: Boolean = AiturkCompat.isLinked(),
): Boolean = !(linked && key in HIDDEN_WHEN_LINKED)
}
/** Shown instead of a hidden screen that is still reached (restored back stack, shortcut). */
@Composable
fun AiturkUnavailableScreen(
@StringRes titleRes: Int,
onOpenDrawer: (() -> Unit)?,
) {
HermesScaffold(
title = { Text(stringResource(titleRes)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
) { padding ->
EmptyState(
title = stringResource(R.string.aiturk_feature_unavailable),
subtitle = stringResource(R.string.aiturk_feature_unavailable_hint),
icon = Icons.Filled.Computer,
modifier = Modifier.padding(padding).testTag("aiturk_unavailable"),
)
}
}
@@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
@@ -61,6 +62,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.aiturk.AiturkPairing
import com.m57.hermescontrol.data.remote.requiresLocalNetworkPermission
@@ -286,6 +288,15 @@ fun AiturkPairScreen(
TextButton(onClick = onBack, enabled = !state.busy) {
Text(stringResource(R.string.aiturk_pair_back))
}
// So the user can tell which build is installed when support asks.
Text(
text = stringResource(R.string.aiturk_app_version, AiturkCompat.versionLabel()),
modifier = Modifier.testTag("aiturk_app_version"),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center,
)
}
}
}
@@ -79,6 +79,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.AccountConnectorsScreen
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
import com.m57.hermescontrol.HistoryScreen
@@ -94,6 +95,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.aiturkpair.AiturkFeatureGate
import com.m57.hermescontrol.ui.chat.components.ChatAppUpdateSection
import com.m57.hermescontrol.ui.chat.components.ChatConnectionBanner
import com.m57.hermescontrol.ui.chat.components.ChatHistoryWindowBanner
@@ -601,20 +603,23 @@ fun ChatScreen(
modifier = Modifier.testTag("chat_menu_search"),
)
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
// AITURK: the paired IDE has no connectors; the entry would only open an empty sheet.
if (AiturkFeatureGate.shows(AccountConnectorsScreen)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.session_integrations_menu_item)) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Extension,
contentDescription = null,
)
},
onClick = {
showSessionMenu = false
connectorsViewModel.show()
},
modifier = Modifier.testTag("chat_menu_session_integrations"),
)
}
ChatBackgroundConnectionMenuItem(
checked = settingsState.keepConnectedInBackground,
onToggle = settingsViewModel::onKeepConnectedInBackgroundChange,
@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.DataScope
import com.m57.hermescontrol.data.local.HermesDatabase
@@ -2317,7 +2318,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Error ($method): $errorMsg",
errorMessage = AiturkCompat.rpcBannerText(error, legacy = "Error ($method): $errorMsg"),
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
)
}
@@ -6038,6 +6039,8 @@ class ChatViewModel(
) {
return@launch
}
// AITURK: an unsaved (empty) chat has no session row yet; skip the 404 poll.
if (AiturkCompat.skipSessionDetail(_uiState.value.messages.isEmpty())) return@launch
val usedResult =
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
coroutineContext.ensureActive()
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.aiturk.AiturkCompat
import com.m57.hermescontrol.data.model.deltaFrom
import com.m57.hermescontrol.data.model.mergeWith
import com.m57.hermescontrol.data.model.parseUsageSnapshot
@@ -997,7 +998,7 @@ object ChatWsEventReducer {
state =
state.copy(
isLoading = false,
errorMessage = "Error: ${event.error}",
errorMessage = AiturkCompat.rpcBannerText(event.error, legacy = "Error: ${event.error}"),
),
streamingState = streamingState,
)
@@ -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),