refactor: remove verified dead code across data, UI, and resources (#680)

* refactor: remove unused dead code across data, UI, and resources

* fix: resolve dangling references left by dead-code removal

Restore empty object Motion (braces required) and remove the 12 stale
test usages that still called the now-deleted production symbols:
- ServerStoreTest: removeServer/switchToServer tests
- ServerEndpointTest: isCleartext assertions
- ConnectViewModelTest: onHostChange/onPortChange tests + 3 dropped R.string refs
- SettingsViewModelTest: renameProfile tests + clearNavigateToLogin
- E2eIntegrationTest: previewSkill tests + onHostChange/onPortChange calls

Verified locally: compileDebugUnitTestKotlin + 48 tests pass.

* fix: remove unused imports flagged by ktlint

Drops 4 no-longer-referenced imports left by the dead-code removal:
- HermesApiService: SkillScanResponse (scanHubSkill removed)
- ServerEndpointTest: assertFalse, assertTrue (isCleartext assertions dropped)
- SettingsViewModelTest: assertFalse (rename tests removed)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-22 15:51:07 +03:00
1 parent 58420d7b2a
commit 2e97ee9a84
29 files changed
+10 -550

No files matched your search

@@ -8,14 +8,6 @@ fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStore
return copy(connectionProfiles = updated)
}
fun ServerStoreState.removeServer(id: String): ServerStoreState {
val updated = connectionProfiles.filter { it.id != id }
val newSelected = if (selectedProfileId == id) null else selectedProfileId
return copy(connectionProfiles = updated, selectedProfileId = newSelected)
}
fun ServerStoreState.switchToServer(id: String?): ServerStoreState = copy(selectedProfileId = id)
fun ServerStoreState.selfHealed(): ServerStoreState {
val hasActive = connectionProfiles.any { it.id == selectedProfileId }
val newSelected = if (selectedProfileId != null && !hasActive) null else selectedProfileId
@@ -5,7 +5,6 @@ import androidx.datastore.core.Serializer
import com.m57.hermescontrol.data.remote.ServerEndpoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -22,7 +21,6 @@ class ServerStore(
) {
private val _stateFlow: MutableStateFlow<ServerStoreState>
val stateFlow: StateFlow<ServerStoreState>
val state: Flow<ServerStoreState>
init {
val initial =
@@ -35,7 +33,6 @@ class ServerStore(
}
_stateFlow = MutableStateFlow(initial)
stateFlow = _stateFlow.asStateFlow()
state = stateFlow
}
fun getLatestState(): ServerStoreState = _stateFlow.value
@@ -450,18 +450,6 @@ object AuthManager {
fun getPort(): Int = serverStore.getLatestState().resolvedPort
fun setHost(host: String) {
val endpoint = endpoint()
val normalizedHost = host.trim().removePrefix("[").removeSuffix("]")
setBaseUrl(endpoint.baseUrl.newBuilder().host(normalizedHost).build().toString())
}
fun setPort(port: Int) {
require(port in 1..65535) { "Port is out of range" }
val endpoint = endpoint()
setBaseUrl(endpoint.baseUrl.newBuilder().port(port).build().toString())
}
// ── Auto-reconnect ───────────────────────────────────────────────────
fun isAutoReconnect(): Boolean = serverStore.getLatestState().autoReconnect
@@ -125,11 +125,6 @@ data class UsageBar(
// ── subscription.preview ──────────────────────────────────────────────────
@Serializable
data class SubscriptionPreviewRequest(
val subscription_type_id: String,
)
@Serializable
data class SubscriptionPreviewResponse(
val ok: Boolean? = null,
@@ -184,11 +179,6 @@ data class SubscriptionResumeResponse(
// ── subscription.upgrade ──────────────────────────────────────────────────
@Serializable
data class SubscriptionUpgradeRequest(
val subscription_type_id: String,
)
@Serializable
data class SubscriptionUpgradeResponse(
val ok: Boolean? = null,
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.data.remote
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@Serializable
@@ -12,11 +11,6 @@ private data class PasswordLoginPayload(
val next: String,
)
@Serializable
private data class WebSocketTicketPayload(
val ticket: String,
)
/** JSON serialization for authentication requests and responses. */
object AuthPayloads {
fun passwordLogin(
@@ -31,11 +25,4 @@ object AuthPayloads {
next = "",
),
)
fun webSocketTicket(body: String): String? =
runCatching {
OkHttpProvider.json.decodeFromString<WebSocketTicketPayload>(body)
.ticket
.takeIf { it.isNotBlank() }
}.getOrNull()
}
@@ -77,13 +77,6 @@ fun wrapSessionCookie(rawValue: String): Cookie? {
}.getOrNull()
}
/**
* Build a permissive [Cookie] from a Set-Cookie `hermes_session_at=...`
* header captured during login. Used by [PersistentCookieJar] when the server
* does not echo an explicit domain (common for same-host dashboard logins).
*/
fun sessionCookieFromValue(value: String): Cookie? = wrapSessionCookie(value)
const val SESSION_COOKIE_NAME = "hermes_session_at"
/**
@@ -76,7 +76,6 @@ import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest
@@ -211,14 +210,6 @@ interface HermesApiService {
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@GET("api/skills/hub/sources")
suspend fun getHubSources(): Response<List<String>>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
@@ -21,9 +21,6 @@ class ServerEndpoint private constructor(
val baseUrl: HttpUrl,
val securityWarning: String?,
) {
val isCleartext: Boolean
get() = baseUrl.scheme == "http"
fun resolve(relativePath: String): HttpUrl {
val path = relativePath.trim().trimStart('/')
require(path.isNotEmpty()) { "Endpoint path must not be blank" }
@@ -24,7 +24,6 @@ object WsMethods {
// ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch"
const val COMMAND_RESOLVE = "command.resolve"
const val SLASH_EXEC = "slash.exec"
const val CONFIG_SET = "config.set"
@@ -77,21 +77,12 @@ val StatusGreyLight = Color(0xFFF5F5F5)
// ── Chat-specific colours (always brand-defined for consistency) ───────
val UserBubble = HermesPurple
val UserBubbleDark = HermesPurpleDark
val AssistantBubble = Color(0xFF1C1C26)
val AssistantBubbleLight = Color(0xFFEDEAF4)
val SystemMessageColor = Color(0xFF8B879A)
val ToolChipColor = Color(0xFF262633)
val ToolChipColorLight = Color(0xFFE6E3EE)
// ── Skeleton shimmer colours ───────────────────────────────────────────
val SkeletonBase = Color(0xFF1C1C26)
val SkeletonHighlight = Color(0xFF2F2F3D)
val SkeletonBaseLight = Color(0xFFE6E3EE)
val SkeletonHighlightLight = Color(0xFFEFEDF4)
// ── Syntax highlighting tokens (code blocks) ─────────────────────────────
// VS Code Dark+ palette — good general readability across all presets.
// These are consumed exclusively by CodeBlockCard (MessageCards.kt).
@@ -1,78 +1,13 @@
package com.m57.hermescontrol.theme
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Motion tokens — duration, easing, and spring specs.
*
* Design intent (ui-ux-pro-max §7):
* - Micro-interactions: 150–300 ms
* - Complex transitions: ≤ 400 ms
* - Never exceed 500 ms
* - Enter: ease-out (decelerated), Exit: ease-in (accelerated)
* - Exit shorter than enter (~60–70 %)
* - Spring physics for interactive elements
* - All animations must be interruptible
*
* Access via `LocalMotion.current` in composables.
*/
object Motion {
// ── Duration tokens (ms) ─────────────────────────────────────────
/** Instant — state changes with no perceptible delay. */
const val INSTANT = 0
/** Fast — micro-interactions, ripples, opacity toggles. */
const val FAST = 150
/** Normal — standard UI transitions, sheet slides, expansion. */
const val NORMAL = 250
/** Slow — complex multi-element transitions, screen navigation. */
const val SLOW = 400
// ── Easing curves ────────────────────────────────────────────────
/** Emphasized decelerate — for ENTERING elements (ease-out). */
val emphasizedDecelerate: Easing =
CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
/** Emphasized accelerate — for EXITING elements (ease-in). */
val emphasizedAccelerate: Easing =
CubicBezierEasing(0.3f, 0.0f, 0.8f, 0.15f)
/** Standard — default M3 easing for neutral transitions. */
val standard: Easing = FastOutSlowInEasing
// ── Spring specs ─────────────────────────────────────────────────
/** Bouncy spring for interactive elements (press, release, drag). */
fun <T> bouncySpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
)
/** Snappy spring for state toggles (switch, selection). */
fun <T> snappySpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessHigh,
)
/** Gentle spring for layout animations (expand, collapse). */
fun <T> gentleSpring(): SpringSpec<T> =
spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow,
)
}
object Motion
val MotionDefaults = Motion
@@ -86,24 +86,6 @@ class AchievementsViewModel : ViewModel(), ToastHost {
)
}
fun loadScanStatus() {
safeLaunchLoad(
apiCall = { safeApiCall { api.getAchievementScanStatus() } },
onStart = { /* silent background refresh */ },
onSuccess = { status ->
_uiState.update {
it.copy(
scanState = status.state,
scanLastError = status.lastError,
scanLastDurationMs = status.lastDurationMs,
scanRunCount = status.runCount,
)
}
},
onError = { /* silent — scan runs independently */ },
)
}
fun loadRecentUnlocks() {
safeLaunchLoad(
apiCall = { safeApiCall { api.getRecentUnlocks() } },
@@ -260,6 +260,4 @@ class BillingViewModel : ViewModel() {
}
}
}
fun clearActionMessage() = _uiState.update { it.copy(actionMessage = null) }
}
@@ -260,10 +260,6 @@ class ChannelsViewModel :
)
}
fun dismissRestartNeeded() {
_uiState.update { it.copy(restartNeeded = false) }
}
// ── Telegram onboarding ────────────────────────────────────────────────
fun startTelegramOnboarding() {
@@ -502,10 +498,6 @@ class ChannelsViewModel :
}
}
fun dismissOnboardingError() {
_uiState.update { it.copy(onboardingError = null) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -91,7 +91,6 @@ import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble
import com.m57.hermescontrol.theme.onColorFor
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -1607,10 +1607,6 @@ class ChatViewModel(
// ── UI actions ───────────────────────────────────────────────────────
fun toggleSessionPicker() {
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
}
/**
* Dismiss the active clarify prompt and reject it (tell the agent no answer
* was given).
@@ -96,20 +96,6 @@ class ConnectViewModel(
_uiState.update { it.copy(baseUrl = trimmed, transportWarning = warning, errorMessage = null) }
}
fun onHostChange(value: String) {
val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().host(value.trim()).build().toString()
onBaseUrlChange(next)
}
fun onPortChange(value: String) {
val portText = value.filter { c -> c.isDigit() }
val port = portText.toIntOrNull() ?: return
val endpoint = AuthManager.endpoint()
val next = endpoint.baseUrl.newBuilder().port(port).build().toString()
onBaseUrlChange(next)
}
fun connect() {
val state = _uiState.value
if (state.token.isBlank()) {
@@ -234,10 +234,6 @@ class SessionsViewModel : ViewModel(), ToastHost {
}
}
fun exitSelecting() {
_uiState.update { it.copy(isSelecting = false, selectedIds = emptySet()) }
}
fun toggleSessionSelection(id: String) {
_uiState.update {
val updated = it.selectedIds.toMutableSet()
@@ -258,14 +254,6 @@ class SessionsViewModel : ViewModel(), ToastHost {
// ── Rename ───────────────────────────────────────────────────────────
fun startRenaming(sessionId: String) {
_uiState.update { it.copy(renamingSessionId = sessionId) }
}
fun cancelRenaming() {
_uiState.update { it.copy(renamingSessionId = null) }
}
fun renameSession(
sessionId: String,
newTitle: String,
@@ -308,27 +296,6 @@ class SessionsViewModel : ViewModel(), ToastHost {
}
}
// ── Copy prompt ──────────────────────────────────────────────────────
fun copySessionPrompt(sessionId: String) {
viewModelScope.launch {
val result =
safeApiCall {
ApiClient.hermesApi.getSessionPrompt(sessionId)
}
when (result) {
is NetworkResult.Success -> {
val promptText = result.data.prompt ?: "No prompt available"
_uiState.update { it.copy(toastMessage = promptText) }
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to get prompt: ${result.error.message}") }
}
}
}
}
// ── Delete (single) ──────────────────────────────────────────────────
fun requestDeleteSession(sessionId: String) {
@@ -108,22 +108,6 @@ class SettingsViewModel(
ApiClient.rebuild()
}
fun onRenameProfileNameChange(value: String) {
_uiState.update { it.copy(renameProfileName = value, isSaved = false) }
}
fun renameProfile() {
val currentId = _uiState.value.selectedProfileId ?: return
val newName = _uiState.value.renameProfileName.trim()
if (newName.isBlank()) return
val updatedProfiles =
AuthManager.getConnectionProfiles().map {
if (it.id == currentId) it.copy(name = newName) else it
}
AuthManager.saveConnectionProfiles(updatedProfiles)
viewModelScope.launch(ioDispatcher) { loadSettings() }
}
fun deleteProfile(profileId: String) {
val updatedProfiles = AuthManager.getConnectionProfiles().filter { it.id != profileId }
AuthManager.saveConnectionProfiles(updatedProfiles)
@@ -235,10 +219,6 @@ class SettingsViewModel(
ApiClient.rebuild()
}
fun clearNavigateToLogin() {
_uiState.update { it.copy(navigateToLogin = false) }
}
// ── Delete confirmation ──────────────────────────────────────────────
fun requestDeleteProfile(profileId: String) {
@@ -205,17 +205,6 @@ class SkillsViewModel(
}
}
fun clearHubPreview() {
_uiState.update {
it.copy(
hubPreviewIdentifier = null,
hubPreviewContent = null,
isHubPreviewing = false,
hubPreviewError = null,
)
}
}
// ── Hub install ─────────────────────────────────────────────────────────
fun installSkill(identifier: String) {
@@ -293,43 +282,6 @@ class SkillsViewModel(
}
}
// ── SKILL.md preview ────────────────────────────────────────────────────
fun previewSkill(skillName: String) {
_uiState.update {
it.copy(
previewSkillName = skillName,
previewSkillContent = null,
isLoadingPreview = true,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
previewSkillContent = result.data.content,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
toastMessage = "Failed to load preview: ${result.error.message}",
)
}
}
}
}
}
fun clearPreview() {
_uiState.update {
it.copy(
@@ -700,23 +700,4 @@ class SystemViewModel :
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
fun clearTransientState() {
actionPollingJob?.cancel()
_uiState.update {
it.copy(
errorMessage = null,
toastMessage = null,
activeAction = null,
actionLog = null,
isLoading = false,
addingCred = false,
creatingHook = false,
sharing = false,
checkingUpdate = false,
updateConfirmOpen = false,
hookModalOpen = false,
)
}
}
}
-32
View File
@@ -3,13 +3,9 @@
<!-- Chat -->
<string name="chat_send_desc">전송</string>
<string name="chat_mic_desc">음성 입력</string>
<string name="chat_mic_listening">듣는 중…</string>
<string name="stt_permission_denied">음성 입력을 위해 마이크 권한이 필요합니다</string>
<string name="stt_not_available">이 기기에서 음성 인식을 사용할 수 없습니다</string>
<string name="chat_clarify_title">확인 필요</string>
<string name="chat_clarify_hint">옵션을 탭하면 입력창에 복사되고, 길게 누르면 바로 전송됩니다.</string>
<string name="chat_clarify_response">응답</string>
<string name="chat_send">전송</string>
<string name="chat_dismiss">닫기</string>
<string name="chat_sudo_title">Sudo 비밀번호 필요</string>
@@ -43,13 +39,8 @@
<string name="error_unknown">문제가 발생했습니다</string>
<string name="billing_free_plan">무료</string>
<string name="billing_usage">사용량</string>
<string name="billing_resume">재개</string>
<string name="billing_cancel">취소</string>
<string name="billing_renews">갱신: %1$s</string>
<string name="billing_status_unknown">알 수 없음</string>
<string name="billing_status_active">활성</string>
<string name="billing_status_cancelled">취소됨</string>
<string name="billing_status_paused">일시중지됨</string>
<string name="billing_feature_unavailable">이 서버에서는 결제를 사용할 수 없습니다 (지원되지 않는 메서드)</string>
<string name="billing_no_active_plan">활성 요금제 없음 — 무료 등급입니다.</string>
<string name="billing_login_required">요금제를 보려면 Nous Portal에 로그인하세요.</string>
@@ -120,11 +111,6 @@
<string name="chat_status_disconnected">연결 끊김</string>
<string name="chat_status_no_network">네트워크 없음</string>
<string name="chat_status_auth_expired">세션 만료</string>
<string name="chat_thinking">생각 중…</string>
<string name="chat_thinking_param">생각 중: %1$s…</string>
<string name="chat_reasoning">추론</string>
<string name="chat_reasoning_expand">추론 펼치기</string>
<string name="chat_reasoning_collapse">추론 접기</string>
<string name="chat_input_placeholder_not_connected">연결되지 않음…</string>
<string name="chat_input_placeholder_waiting">응답 대기 중…</string>
<string name="chat_input_placeholder_type_message">메시지 입력…</string>
@@ -139,7 +125,6 @@
<string name="content_desc_edit">편집</string>
<!-- Attachment / File Picker -->
<string name="chat_attach_desc">파일 첨부</string>
<string name="chat_attach_remove_desc">첨부 제거</string>
<!-- Camera -->
@@ -147,8 +132,6 @@
<!-- Settings Screen -->
<string name="settings_sec_connection">연결</string>
<string name="settings_field_host">호스트</string>
<string name="settings_field_port">포트</string>
<string name="settings_field_token">인증 토큰</string>
<string name="settings_sec_behavior">동작</string>
<string name="settings_item_auto_reconnect">자동 재연결</string>
@@ -199,8 +182,6 @@
<!-- Connect Error Messages -->
<string name="connect_error_token_required">토큰이 필요합니다</string>
<string name="connect_error_host_required">호스트가 필요합니다</string>
<string name="connect_error_port_invalid">포트는 1~65535 사이여야 합니다</string>
<string name="connect_error_401">잘못된 토큰 (401 Unauthorized)</string>
<string name="connect_error_403">접근 거부 (403 Forbidden)</string>
<string name="connect_error_http_code">서버가 HTTP %1$d 반환</string>
@@ -208,19 +189,13 @@
<string name="connect_error_refused">연결 거부됨 – Hermes가 실행 중인가요?</string>
<string name="connect_error_resolve">호스트를 찾을 수 없음</string>
<string name="connect_error_connection_failed">연결 실패: %1$s</string>
<string name="connect_error_missing_fields">잘못된 페어링 문자열 — host, port 또는 token 누락</string>
<string name="connect_error_malformed">잘못된 페어링 문자열 — URL 또는 Base64 JSON이어야 함</string>
<string name="connect_error_parse_failed">페어링 문자열 파싱 실패: %1$s</string>
<string name="connect_placeholder_pairing">hermes://… 또는 Base64 설정 붙여넣기</string>
<string name="connect_save_profile">연결 프로필 저장</string>
<string name="connect_profile_name">프로필 이름</string>
<string name="connect_placeholder_profile_name">예: 집 헤르메스, 사무실 서버</string>
<string name="connect_action_hide_manual">수동 입력 숨기기</string>
<string name="connect_action_show_manual">수동으로 입력</string>
<string name="connect_placeholder_token">API 토큰</string>
<string name="connect_placeholder_host">127.0.0.1</string>
<string name="connect_placeholder_port">9119</string>
<string name="action_connect">연결</string>
<!-- Kanban Screen -->
@@ -640,16 +615,12 @@
<!-- Auth Login Screen -->
<string name="auth_login_title">인증 로그인</string>
<string name="auth_login_desc">Hermes 대시보드에 연결</string>
<string name="auth_login_host_label">호스트</string>
<string name="auth_login_port_label">포트</string>
<string name="auth_login_token_label">세션 토큰</string>
<string name="auth_login_username_label">사용자명</string>
<string name="auth_login_password_label">비밀번호</string>
<string name="auth_login_action_connect">연결</string>
<string name="auth_login_action_probing">대시보드 확인 중…</string>
<string name="auth_login_probing_dashboard">대시보드 설정 확인 중…</string>
<string name="auth_login_error_host_required">호스트가 필요합니다</string>
<string name="auth_login_error_port_invalid">잘못된 포트 (1~65535)</string>
<string name="auth_login_error_token_required">세션 토큰이 필요합니다</string>
<string name="auth_login_error_username_required">사용자명이 필요합니다</string>
<string name="auth_login_error_password_required">비밀번호가 필요합니다</string>
@@ -663,9 +634,6 @@
<string name="landing_action_auth_login">인증 로그인</string>
<!-- Loading State -->
<string name="loading_state_subtitle_models">모델 제공자 확인 중…</string>
<string name="loading_state_subtitle_channels">채널 로딩 중…</string>
<string name="loading_state_subtitle_providers">OAuth 제공자 로딩 중…</string>
<!-- Providers Screen -->
<string name="providers_empty_title">OAuth 제공자가 없습니다</string>
-34
View File
@@ -3,13 +3,9 @@
<!-- Chat -->
<string name="chat_send_desc">Send</string>
<string name="chat_mic_desc">Voice input</string>
<string name="chat_mic_listening">Listening…</string>
<string name="stt_permission_denied">Microphone permission is required for voice input</string>
<string name="stt_not_available">Speech recognition is not available on this device</string>
<string name="chat_clarify_title">Clarification Needed</string>
<string name="chat_clarify_hint">Tap an option to copy to input, or long-press to send directly.</string>
<string name="chat_clarify_response">Response</string>
<string name="chat_send">Send</string>
<string name="chat_dismiss">Dismiss</string>
<string name="chat_sudo_title">Sudo Password Required</string>
@@ -42,11 +38,9 @@
<string name="empty_action_retry_search">Retry search</string>
<string name="empty_action_create_job">Create your first job</string>
<string name="empty_action_add_server">Add a server</string>
<string name="empty_action_create_subscription">Create subscription</string>
<string name="empty_action_connect_telegram">Connect a Telegram channel</string>
<string name="empty_action_configure_model">Configure model</string>
<string name="empty_action_start_chat">Start a new chat</string>
<string name="empty_action_install_plugin">Install from URL</string>
<string name="empty_action_explore">Explore Skills or Cron jobs</string>
<string name="empty_action_clear_search">Clear search</string>
<string name="common_cancel">Cancel</string>
@@ -56,13 +50,8 @@
<string name="error_unknown">Unknown error</string>
<string name="billing_free_plan">Free</string>
<string name="billing_usage">Usage</string>
<string name="billing_resume">Resume</string>
<string name="billing_cancel">Cancel</string>
<string name="billing_renews">Renews: %1$s</string>
<string name="billing_status_unknown">Unknown</string>
<string name="billing_status_active">Active</string>
<string name="billing_status_cancelled">Cancelled</string>
<string name="billing_status_paused">Paused</string>
<string name="billing_feature_unavailable">Billing is unavailable on this server (method not supported)</string>
<string name="billing_no_active_plan">No active plan — you\'re on the free tier.</string>
<string name="billing_login_required">Log in to Nous Portal to view your plan.</string>
@@ -133,11 +122,6 @@
<string name="chat_status_disconnected">Disconnected</string>
<string name="chat_status_no_network">No network</string>
<string name="chat_status_auth_expired">Session expired</string>
<string name="chat_thinking">Thinking\u2026</string>
<string name="chat_thinking_param">Thinking: %1$s\u2026</string>
<string name="chat_reasoning">Reasoning</string>
<string name="chat_reasoning_expand">Expand reasoning</string>
<string name="chat_reasoning_collapse">Collapse reasoning</string>
<string name="chat_input_placeholder_not_connected">Not connected\u2026</string>
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
@@ -153,7 +137,6 @@
<string name="content_desc_edit">Edit</string>
<!-- Attachment / File Picker -->
<string name="chat_attach_desc">Attach file</string>
<string name="chat_attach_remove_desc">Remove attachment</string>
<!-- Camera -->
@@ -161,8 +144,6 @@
<!-- Settings Screen -->
<string name="settings_sec_connection">Connection</string>
<string name="settings_field_host">Host</string>
<string name="settings_field_port">Port</string>
<string name="settings_field_token">Auth Token</string>
<string name="settings_sec_behavior">Behavior</string>
<string name="settings_item_auto_reconnect">Auto-Reconnect</string>
@@ -229,8 +210,6 @@
<!-- Connect Error Messages -->
<string name="connect_error_token_required">Token is required</string>
<string name="connect_error_host_required">Host is required</string>
<string name="connect_error_port_invalid">Port must be between 1 and 65535</string>
<string name="connect_error_401">Invalid token (401 Unauthorized)</string>
<string name="connect_error_403">Access denied (403 Forbidden)</string>
<string name="connect_error_http_code">Server returned HTTP %1$d</string>
@@ -238,19 +217,13 @@
<string name="connect_error_refused">Connection refused – is Hermes running?</string>
<string name="connect_error_resolve">Could not resolve host</string>
<string name="connect_error_connection_failed">Connection failed: %1$s</string>
<string name="connect_error_missing_fields">Malformed pairing string — missing host, port, or token</string>
<string name="connect_error_malformed">Malformed pairing string — expected URL or Base64-encoded JSON</string>
<string name="connect_error_parse_failed">Failed to parse pairing string: %1$s</string>
<string name="connect_placeholder_pairing">Paste hermes://… or Base64 config</string>
<string name="connect_save_profile">Save connection profile</string>
<string name="connect_profile_name">Profile Name</string>
<string name="connect_placeholder_profile_name">e.g. Local Hermit, Remote Server</string>
<string name="connect_action_hide_manual">Hide manual entry</string>
<string name="connect_action_show_manual">Enter manually instead</string>
<string name="connect_placeholder_token">API token</string>
<string name="connect_placeholder_host">127.0.0.1</string>
<string name="connect_placeholder_port">9119</string>
<string name="connect_server_url_label">Server URL</string>
<string name="connect_placeholder_server_url">https://host:9119/ or http://127.0.0.1:9119/</string>
<string name="connect_error_url_invalid">Enter a valid server URL (https:// or http://)</string>
@@ -788,16 +761,12 @@
<string name="auth_login_title">Auth Login</string>
<string name="auth_login_desc">Connect to your Hermes dashboard</string>
<string name="auth_login_base_url_label">Server URL</string>
<string name="auth_login_host_label">Host</string>
<string name="auth_login_port_label">Port</string>
<string name="auth_login_token_label">Session Token</string>
<string name="auth_login_username_label">Username</string>
<string name="auth_login_password_label">Password</string>
<string name="auth_login_action_connect">Connect</string>
<string name="auth_login_action_probing">Probing dashboard…</string>
<string name="auth_login_probing_dashboard">Checking dashboard configuration…</string>
<string name="auth_login_error_host_required">Host is required</string>
<string name="auth_login_error_port_invalid">Invalid port (1–65535)</string>
<string name="auth_login_error_token_required">Session token is required</string>
<string name="auth_login_error_username_required">Username is required</string>
<string name="auth_login_error_password_required">Password is required</string>
@@ -813,9 +782,6 @@
<string name="landing_action_auth_login">Auth Login</string>
<!-- Loading State -->
<string name="loading_state_subtitle_models">Fetching model providers…</string>
<string name="loading_state_subtitle_channels">Loading channels…</string>
<string name="loading_state_subtitle_providers">Loading OAuth providers…</string>
<!-- Providers Screen (issue #534) -->
<string name="providers_empty_title">No OAuth providers</string>
@@ -38,7 +38,6 @@ import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
@@ -100,8 +99,6 @@ class E2eIntegrationTest {
every { AuthManager.getPort() } returns 9119
every { AuthManager.getBaseUrl() } returns "https://127.0.0.1:9119/"
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every {
AuthManager.endpoint()
@@ -471,47 +468,6 @@ class E2eIntegrationTest {
// ── Skills Preview ────────────────────────────────────────────────────
@Test
fun testSkillsPreview_success() =
runTest {
val content = SkillContentResponse("my-skill", "# My Skill\n\nDo stuff")
coEvery { mockApiService.getSkillContent(any()) } returns
Response.success(content)
val viewModel = SkillsViewModel(mockApp)
viewModel.previewSkill("my-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingPreview)
assertEquals(
"my-skill",
viewModel.uiState.value.previewSkillName,
)
assertEquals(
content.content,
viewModel.uiState.value.previewSkillContent,
)
}
@Test
fun testSkillsPreview_error() =
runTest {
coEvery { mockApiService.getSkillContent(any()) } returns
createErrorResponse(500)
val viewModel = SkillsViewModel(mockApp)
viewModel.previewSkill("failing-skill")
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isLoadingPreview)
assertNull(viewModel.uiState.value.previewSkillContent)
assertNotNull(viewModel.uiState.value.toastMessage)
assertTrue(
viewModel.uiState.value.toastMessage!!
.contains("HTTP 500"),
)
}
// Cron Jobs Screen:
@Test
fun testCronJobsListing_success() =
@@ -1031,8 +987,6 @@ class E2eIntegrationTest {
val connectViewModel = ConnectViewModel(mockApp)
connectViewModel.onTokenChange("valid-token")
connectViewModel.onHostChange("127.0.0.1")
connectViewModel.onPortChange("9119")
connectViewModel.connect()
advanceUntilIdle()
@@ -35,29 +35,6 @@ class ServerStoreTest {
assertEquals("10.0.0.2", finalState.connectionProfiles[0].host)
}
@Test
fun testPureOps_removeServer() {
val profile1 = ConnectionProfile(id = "1", name = "Test1", host = "10.0.0.1", port = 8080)
val profile2 = ConnectionProfile(id = "2", name = "Test2", host = "10.0.0.2", port = 8081)
val state =
ServerStoreState(
connectionProfiles = listOf(profile1, profile2),
selectedProfileId = "2",
)
val newState = state.removeServer("2")
assertEquals(1, newState.connectionProfiles.size)
assertEquals("1", newState.connectionProfiles[0].id)
assertNull(newState.selectedProfileId)
}
@Test
fun testPureOps_switchToServer() {
val state = ServerStoreState(selectedProfileId = null)
val newState = state.switchToServer("prof-1")
assertEquals("prof-1", newState.selectedProfileId)
}
@Test
fun testPureOps_selfHealed() {
// Test orphaned selected profile ID
@@ -130,14 +130,9 @@ class AuthManagerTest {
}
@Test
fun testGetAndSetHost() {
AuthManager.setHost("10.0.0.1")
fun testGetHostAndPortFromBaseUrl() {
AuthManager.setBaseUrl("https://10.0.0.1:9090/")
assertEquals("10.0.0.1", AuthManager.getHost())
}
@Test
fun testGetAndSetPort() {
AuthManager.setPort(9090)
assertEquals(9090, AuthManager.getPort())
}
@@ -149,10 +144,7 @@ class AuthManagerTest {
@Test
fun testBaseUrl() {
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
// setHost/setPort derive from the canonical base URL (default is https),
// so the result keeps the https scheme and swaps host/port.
AuthManager.setBaseUrl("https://hermes.local:1234/")
assertEquals("https://hermes.local:1234/", AuthManager.getBaseUrl())
}
@@ -181,8 +173,7 @@ class AuthManagerTest {
AuthManager.ensureDefaultProfile()
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns "token123"
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
AuthManager.setBaseUrl("https://hermes.local:1234/")
// Scheme tracks the canonical base URL (default https) → wss.
assertEquals("wss://hermes.local:1234/api/ws?token=token123", AuthManager.wsUrl())
}
@@ -192,8 +183,7 @@ class AuthManagerTest {
AuthManager.ensureDefaultProfile()
every { mockPrefs.getString("token_${AuthManager.DEFAULT_PROFILE_ID}", null) } returns null
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
AuthManager.setHost("hermes.local")
AuthManager.setPort(1234)
AuthManager.setBaseUrl("https://hermes.local:1234/")
assertEquals("wss://hermes.local:1234/api/ws?token=", AuthManager.wsUrl())
}
@@ -265,34 +255,21 @@ class AuthManagerTest {
AuthManager.ensureDefaultProfile()
AuthManager.setSelectedProfileId("nonexistent")
AuthManager.saveConnectionProfiles(emptyList())
AuthManager.setHost("192.168.1.1")
AuthManager.setBaseUrl("http://192.168.1.1:9119/")
assertEquals("192.168.1.1", AuthManager.getHost())
}
@Test
fun testSetHost_updatesProfileWhenSelected() {
fun testSetBaseUrl_updatesProfileWhenSelected() {
val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
AuthManager.setHost("10.0.0.99")
AuthManager.setBaseUrl("http://10.0.0.99:9999/")
val updated = AuthManager.getConnectionProfiles().first()
// host/port are now canonicalized into the base URL (profile was http)
assertEquals("http://10.0.0.99:9119/", updated.resolvedBaseUrl)
}
@Test
fun testSetPort_updatesProfileWhenSelected() {
val profile = ConnectionProfile(id = "prof-1", name = "Home", baseUrl = "http://10.0.0.1:9119/")
AuthManager.saveConnectionProfiles(listOf(profile))
AuthManager.setSelectedProfileId("prof-1")
AuthManager.setPort(9999)
val updated = AuthManager.getConnectionProfiles().first()
assertEquals("http://10.0.0.1:9999/", updated.resolvedBaseUrl)
assertEquals("http://10.0.0.99:9999/", updated.resolvedBaseUrl)
}
@Test
@@ -1,9 +1,7 @@
package com.m57.hermescontrol.data.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
@@ -13,7 +11,6 @@ class ServerEndpointTest {
val ep = ServerEndpoint.parse("https://example.com/hermes", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("https://example.com/hermes/", ep.baseUrl.toString())
assertNull(ep.securityWarning)
assertFalse(ep.isCleartext)
}
@Test
@@ -21,7 +18,6 @@ class ServerEndpointTest {
val ep = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING)
assertEquals("http://127.0.0.1:9119/", ep.baseUrl.toString())
assertEquals(ServerEndpoint.CLEARTEXT_WARNING, ep.securityWarning)
assertTrue(ep.isCleartext)
}
@Test
@@ -130,7 +126,6 @@ class ServerEndpointTest {
fun `fromLegacy preserves http host and port`() {
val ep = ServerEndpoint.fromLegacy("10.0.0.1", 8080)
assertEquals("http://10.0.0.1:8080/", ep.baseUrl.toString())
assertTrue(ep.isCleartext)
}
@Test
@@ -79,13 +79,6 @@ class ConnectViewModelTest {
every { mockApp.getString(R.string.connect_error_refused) } returns "Connection refused – is Hermes running?"
every { mockApp.getString(R.string.connect_error_timeout) } returns "Connection timed out"
every { mockApp.getString(R.string.connect_error_connection_failed) } returns "Connection failed: %1\$s"
every {
mockApp.getString(R.string.connect_error_malformed)
} returns "Malformed pairing string — expected URL or Base64-encoded JSON"
every {
mockApp.getString(R.string.connect_error_missing_fields)
} returns "Malformed pairing string — missing host, port, or token"
every { mockApp.getString(R.string.connect_error_parse_failed) } returns "Failed to parse pairing string: %1\$s"
}
@After
@@ -117,22 +110,6 @@ class ConnectViewModelTest {
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnHostChange() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onHostChange(" 192.168.1.50 ")
assertEquals("https://192.168.1.50:9119/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testOnPortChange() {
val viewModel = ConnectViewModel(mockApp)
viewModel.onPortChange("90abc90")
assertEquals("https://127.0.0.1:9090/", viewModel.uiState.value.baseUrl)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun testConnect_blankToken_showsError() {
val viewModel = ConnectViewModel(mockApp)
@@ -16,7 +16,6 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -67,8 +66,6 @@ class SettingsViewModelTest {
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
every { AuthManager.setHost(any()) } returns Unit
every { AuthManager.setPort(any()) } returns Unit
every { AuthManager.setBaseUrl(any()) } returns Unit
every { AuthManager.setToken(any()) } returns Unit
every { AuthManager.setAutoReconnect(any()) } returns Unit
@@ -146,47 +143,6 @@ class SettingsViewModelTest {
assertEquals(AuthManager.DEFAULT_PROFILE_ID, viewModel.uiState.value.selectedProfileId)
}
@Test
fun testOnRenameProfileNameChange_updatesState() {
val viewModel = createViewModel()
viewModel.onRenameProfileNameChange("New Name")
assertEquals("New Name", viewModel.uiState.value.renameProfileName)
assertFalse(viewModel.uiState.value.isSaved)
}
@Test
fun testRenameProfile_updatesProfileName() {
every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getSelectedProfileId() } returns "prof-1"
val viewModel = createViewModel()
viewModel.onRenameProfileNameChange("Renamed Work")
viewModel.renameProfile()
verify { AuthManager.saveConnectionProfiles(any()) }
}
@Test
fun testRenameProfile_withBlankName_doesNothing() {
every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getSelectedProfileId() } returns "prof-1"
val viewModel = createViewModel()
viewModel.onRenameProfileNameChange(" ")
viewModel.renameProfile()
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
}
@Test
fun testRenameProfile_noSelectedProfile_doesNothing() {
val viewModel = createViewModel()
viewModel.onRenameProfileNameChange("New Name")
viewModel.renameProfile()
verify(exactly = 0) { AuthManager.saveConnectionProfiles(any()) }
}
@Test
fun testDeleteProfile_removesProfileAndToken() {
every { AuthManager.getConnectionProfiles() } returns testProfiles
@@ -233,8 +189,5 @@ class SettingsViewModelTest {
verify { AuthManager.saveConnectionProfiles(any()) }
verify { AuthManager.setSelectedProfileId(any()) }
assertEquals(true, viewModel.uiState.value.navigateToLogin)
viewModel.clearNavigateToLogin()
assertEquals(false, viewModel.uiState.value.navigateToLogin)
}
}