From 1870b375b10e78aea8ecf9535db8a02942ce86cf Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:13:28 +0300 Subject: [PATCH] feat(skills): hub sources + featured landing and security scan (#779) (#793) Co-authored-by: M57 --- .../com/m57/hermescontrol/data/model/Skill.kt | 38 +- .../data/remote/HermesApiService.kt | 10 + .../hermescontrol/ui/skills/SkillsScreen.kt | 458 +++++++++++++++++- .../ui/skills/SkillsViewModel.kt | 103 ++++ .../ui/skills/components/HubSkillsTab.kt | 291 ----------- app/src/main/res/values/strings.xml | 17 + .../ui/skills/SkillsViewModelTest.kt | 199 ++++++++ 7 files changed, 812 insertions(+), 304 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt index 22e0e25b..f4b15d28 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Skill.kt @@ -56,7 +56,39 @@ data class SkillHubUninstallRequest( @Serializable data class SkillScanResponse( val identifier: String, - val passed: Boolean? = null, - val issues: List? = null, - val warnings: List? = null, + val name: String? = null, + val source: String? = null, + @SerialName("trust_level") val trustLevel: String? = null, + val verdict: String? = null, + val summary: String? = null, + val policy: String? = null, + @SerialName("policy_reason") val policyReason: String? = null, + val findings: List = emptyList(), + @SerialName("severity_counts") val severityCounts: Map = emptyMap(), +) + +@Serializable +data class SkillScanFinding( + val severity: String, + val category: String, + val file: String? = null, + val line: Int? = null, + val description: String? = null, +) + +@Serializable +data class SkillHubSource( + val id: String, + val label: String? = null, + @SerialName("rate_limited") val rateLimited: Boolean? = null, + val available: Boolean? = null, + val searchable: Boolean? = null, +) + +@Serializable +data class SkillHubSourcesResponse( + val sources: List = emptyList(), + @SerialName("index_available") val indexAvailable: Boolean? = null, + val featured: List = emptyList(), + val installed: Map? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 8ff67dbf..6ef64a83 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -88,7 +88,9 @@ import com.m57.hermescontrol.data.model.Skill 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.SkillHubSourcesResponse 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 @@ -240,6 +242,11 @@ interface HermesApiService { @Query("identifier") identifier: String, ): Response + @GET("api/skills/hub/scan") + suspend fun scanHubSkill( + @Query("identifier") identifier: String, + ): Response + @POST("api/skills/hub/install") suspend fun installHubSkill( @Body body: SkillHubInstallRequest, @@ -250,6 +257,9 @@ interface HermesApiService { @Body body: SkillHubUninstallRequest, ): Response + @GET("api/skills/hub/sources") + suspend fun getSkillHubSources(): Response + @PUT("api/skills/toggle") suspend fun toggleSkill( @Body body: ToggleSkillRequest, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt index 3a7e39c5..a9517af8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsScreen.kt @@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.skills import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,12 +31,14 @@ import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Shield import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -55,6 +58,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily @@ -69,6 +73,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.Skill +import com.m57.hermescontrol.data.model.SkillHubSource +import com.m57.hermescontrol.data.model.SkillScanFinding +import com.m57.hermescontrol.data.model.SkillScanResponse import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.DetailDialog import com.m57.hermescontrol.ui.common.DetailRow @@ -174,6 +181,9 @@ fun SkillsScreen( onClearSearch = viewModel::clearHubSearch, onInstall = viewModel::installSkill, onPreviewHubSkill = viewModel::previewHubSkill, + onScanHubSkill = viewModel::scanHubSkill, + onClearHubScan = viewModel::clearHubScan, + onLoadHubSources = viewModel::loadHubSources, isInstalling = state.isInstalling, installingSkillName = state.installingSkillName, ) @@ -533,6 +543,9 @@ private fun HubBrowseView( onClearSearch: () -> Unit, onInstall: (String) -> Unit, onPreviewHubSkill: (String) -> Unit, + onScanHubSkill: (String) -> Unit, + onClearHubScan: () -> Unit, + onLoadHubSources: () -> Unit, isInstalling: Boolean, installingSkillName: String?, ) { @@ -629,9 +642,16 @@ private fun HubBrowseView( } else -> { - EmptyState( - icon = Icons.Filled.Search, - title = stringResource(R.string.skills_hub_search_hint), + HubLandingView( + state = state, + onLoadHubSources = onLoadHubSources, + onInstall = onInstall, + isInstalling = isInstalling, + installingSkillName = installingSkillName, + onOpenDetail = { skill -> + hubShowDetail = skill + skill.identifier?.let { onPreviewHubSkill(it) } + }, ) } } @@ -655,17 +675,435 @@ private fun HubBrowseView( DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), ), - actions = - if (previewReady && state.isHubPreviewing) { - { CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) } - } else { - null - }, - onDismiss = { hubShowDetail = null }, + actions = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (previewReady && state.isHubPreviewing) { + CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) + } + hubSkill.identifier?.let { identifier -> + SkillScanSection( + scanResult = + state.hubScanResult.takeIf { + state.hubScanIdentifier == identifier + }, + isScanning = + state.isHubScanning && + state.hubScanIdentifier == identifier, + scanError = + state.hubScanError.takeIf { + state.hubScanIdentifier == identifier + }, + onScan = { onScanHubSkill(identifier) }, + ) + } + } + }, + onDismiss = { + hubShowDetail = null + onClearHubScan() + }, ) } } +// ── Hub Landing (sources + featured) ─────────────────────────────────── + +@Composable +private fun HubLandingView( + state: SkillsUiState, + onLoadHubSources: () -> Unit, + onInstall: (String) -> Unit, + isInstalling: Boolean, + installingSkillName: String?, + onOpenDetail: (HubSkill) -> Unit, +) { + when { + state.isHubSourcesLoading -> { + SkeletonListState() + } + + state.hubSourcesError != null -> { + ErrorState( + message = state.hubSourcesError, + onRetry = onLoadHubSources, + ) + } + + state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> { + EmptyState( + icon = Icons.Filled.Extension, + title = stringResource(R.string.skills_hub_landing_hint), + ) + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + verticalArrangement = listItemSpacing, + ) { + if (state.hubSources.isNotEmpty()) { + item(key = "hub_sources_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_sources_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + state.hubSources.forEach { source -> + SourceChip(source = source) + } + } + } + } + } + + if (state.hubFeatured.isNotEmpty()) { + item(key = "hub_featured_header") { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + ) { + Text( + text = stringResource(R.string.skills_hub_featured_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.skills_hub_featured_subtitle), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + itemsIndexed( + state.hubFeatured, + key = { index, hubSkill -> + "featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" + }, + ) { _, hubSkill -> + HubSkillCard( + hubSkill = hubSkill, + onInstall = { onInstall(hubSkill.name) }, + isInstalling = + isInstalling && installingSkillName == hubSkill.name, + onClick = { onOpenDetail(hubSkill) }, + ) + } + } + } + } + } +} + +@Composable +private fun SourceChip(source: SkillHubSource) { + val statusColors = LocalHermesStatusColors.current + val label = + buildString { + append(source.label ?: source.id) + when { + source.rateLimited == true -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_rate_limited)) + } + + source.available == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_offline)) + } + + source.searchable == false -> { + append(" · ") + append(stringResource(R.string.skills_hub_source_via_index)) + } + } + } + val color = + when { + source.rateLimited == true -> statusColors.warning + source.available == false -> statusColors.error + else -> MaterialTheme.colorScheme.primary + } + Surface( + color = color.copy(alpha = 0.12f), + shape = RoundedCornerShape(50), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + ) + } +} + +// ── Hub security scan ────────────────────────────────────────────────── + +@Composable +private fun SkillScanSection( + scanResult: SkillScanResponse?, + isScanning: Boolean, + scanError: String?, + onScan: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = onScan, + enabled = !isScanning, + ) { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(stringResource(R.string.skills_hub_scan)) + } + } + + when { + isScanning -> Unit + + scanError != null -> { + Surface( + color = LocalHermesStatusColors.current.errorContainer, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = scanError, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } + + scanResult != null -> { + ScanResultCard(result = scanResult) + } + } + } +} + +@Composable +private fun ScanResultCard(result: SkillScanResponse) { + val statusColors = LocalHermesStatusColors.current + val verdictColor = + when (result.verdict) { + "safe" -> statusColors.success + "dangerous" -> statusColors.error + else -> statusColors.warning + } + val policyLabel = + when (result.policy) { + "allow" -> stringResource(R.string.skills_hub_scan_policy_allow) + "ask" -> stringResource(R.string.skills_hub_scan_policy_ask) + "block" -> stringResource(R.string.skills_hub_scan_policy_block) + else -> result.policy.orEmpty() + } + val policyColor = + when (result.policy) { + "allow" -> statusColors.success + "ask" -> statusColors.warning + "block" -> statusColors.error + else -> statusColors.neutral + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Verdict header ── + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Shield, + contentDescription = null, + tint = verdictColor, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()), + style = MaterialTheme.typography.titleSmall, + color = verdictColor, + modifier = Modifier.weight(1f), + ) + Surface( + color = policyColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = policyLabel, + style = MaterialTheme.typography.labelSmall, + color = policyColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + + // ── Trust + finding count ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (!result.trustLevel.isNullOrBlank()) { + Text( + text = result.trustLevel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = + pluralStringResource( + R.plurals.skills_hub_scan_findings_count, + result.findings.size, + result.findings.size, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Severity tally ── + val severityColors = + mapOf( + "critical" to statusColors.error, + "high" to statusColors.error, + "medium" to statusColors.warning, + "low" to statusColors.neutral, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + listOf("critical", "high", "medium", "low").forEach { sev -> + val count = result.severityCounts[sev] ?: 0 + if (count > 0) { + val sevColor = severityColors[sev] ?: statusColors.neutral + Surface( + color = sevColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(50), + ) { + Text( + text = "$count $sev", + style = MaterialTheme.typography.labelSmall, + color = sevColor, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } + } + } + if (result.findings.isEmpty()) { + Text( + text = stringResource(R.string.skills_hub_scan_no_findings), + style = MaterialTheme.typography.labelSmall, + color = statusColors.success, + ) + } + } + + // ── Policy reason ── + if (!result.policyReason.isNullOrBlank()) { + Text( + text = result.policyReason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── Findings ── + if (result.findings.isNotEmpty()) { + HorizontalDivider() + result.findings.forEach { finding -> + ScanFindingRow(finding = finding) + } + } + } + } +} + +@Composable +private fun ScanFindingRow(finding: SkillScanFinding) { + val statusColors = LocalHermesStatusColors.current + val severityColor = + when (finding.severity) { + "critical", "high" -> statusColors.error + "medium" -> statusColors.warning + else -> statusColors.neutral + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Surface( + color = severityColor.copy(alpha = 0.15f), + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = finding.severity, + style = MaterialTheme.typography.labelSmall, + color = severityColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + Text( + text = finding.category, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + val location = + buildString { + finding.file?.let { append(it) } + finding.line?.let { append(":").append(it) } + } + if (location.isNotEmpty()) { + Text( + text = location, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + finding.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + // ── Hub Skill Card ───────────────────────────────────────────────────── @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt index 13b743a6..40dad9e7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/SkillsViewModel.kt @@ -8,7 +8,9 @@ import com.m57.hermescontrol.data.model.HubSkill import com.m57.hermescontrol.data.model.SaveSkillContentRequest import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.SkillHubInstallRequest +import com.m57.hermescontrol.data.model.SkillHubSource import com.m57.hermescontrol.data.model.SkillHubUninstallRequest +import com.m57.hermescontrol.data.model.SkillScanResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult @@ -58,6 +60,16 @@ data class SkillsUiState( val hubResults: List = emptyList(), val isHubSearching: Boolean = false, val hubSearchError: String? = null, + // Hub sources + featured (landing, before any search) + val hubSources: List = emptyList(), + val hubFeatured: List = emptyList(), + val isHubSourcesLoading: Boolean = false, + val hubSourcesError: String? = null, + // Hub security scan + val hubScanIdentifier: String? = null, + val hubScanResult: SkillScanResponse? = null, + val isHubScanning: Boolean = false, + val hubScanError: String? = null, // Hub detail preview (full description/content via preview endpoint) val hubPreviewIdentifier: String? = null, val hubPreviewContent: String? = null, @@ -111,6 +123,9 @@ class SkillsViewModel( if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) { loadSkills() } + if (mode == SkillsViewMode.HUB && _uiState.value.hubSources.isEmpty()) { + loadHubSources() + } } fun setSourceFilter(source: String?) { @@ -161,6 +176,94 @@ class SkillsViewModel( } } + // ── Hub sources + featured landing ──────────────────────────────────── + + /** + * Loads the configured hub sources and featured skills (the browse-hub + * landing state shown before any search runs). + */ + fun loadHubSources() { + safeLaunchLoad( + apiCall = { safeApiCall { ApiClient.hermesApi.getSkillHubSources() } }, + onStart = { + _uiState.update { + it.copy(isHubSourcesLoading = true, hubSourcesError = null) + } + }, + onSuccess = { data -> + _uiState.update { + it.copy( + isHubSourcesLoading = false, + hubSources = data.sources, + hubFeatured = data.featured, + ) + } + }, + onError = { errorMsg -> + _uiState.update { + it.copy( + isHubSourcesLoading = false, + hubSourcesError = "Failed to load hub sources: $errorMsg", + ) + } + }, + ) + } + + // ── Hub security scan ───────────────────────────────────────────────── + + /** + * Runs the install-time security scan on a hub skill WITHOUT installing + * it, so the user sees the safety verdict before deciding. + */ + fun scanHubSkill(identifier: String) { + if (identifier.isBlank()) return + _uiState.update { + it.copy( + hubScanIdentifier = identifier, + isHubScanning = true, + hubScanResult = null, + hubScanError = null, + ) + } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isHubScanning = false, + hubScanResult = result.data, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isHubScanning = false, + hubScanError = result.error.message, + ) + } + } + } + } + } + + fun clearHubScan() { + _uiState.update { + it.copy( + hubScanIdentifier = null, + hubScanResult = null, + isHubScanning = false, + hubScanError = null, + ) + } + } + // ── Hub detail preview ────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt deleted file mode 100644 index 20679341..00000000 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/HubSkillsTab.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.m57.hermescontrol.ui.skills.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.m57.hermescontrol.R -import com.m57.hermescontrol.data.model.HubSkill -import com.m57.hermescontrol.ui.common.DetailDialog -import com.m57.hermescontrol.ui.common.DetailRow -import com.m57.hermescontrol.ui.common.EmptyState -import com.m57.hermescontrol.ui.common.ErrorState -import com.m57.hermescontrol.ui.common.SkeletonListState -import com.m57.hermescontrol.ui.common.listItemSpacing -import com.m57.hermescontrol.ui.skills.SkillsUiState - -@Composable -internal fun HubBrowseView( - state: SkillsUiState, - hubQuery: String, - onHubQueryChange: (String) -> Unit, - onSearch: (String) -> Unit, - onClearSearch: () -> Unit, - onInstall: (String) -> Unit, - onPreviewHubSkill: (String) -> Unit, - isInstalling: Boolean, - installingSkillName: String?, -) { - var showDetail by remember { mutableStateOf(null) } - - Column(modifier = Modifier.fillMaxSize()) { - OutlinedTextField( - value = hubQuery, - onValueChange = onHubQueryChange, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) }, - trailingIcon = { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - if (hubQuery.isNotEmpty()) { - IconButton(onClick = { - onHubQueryChange("") - onClearSearch() - }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.action_clear), - ) - } - } - IconButton( - onClick = { onSearch(hubQuery) }, - enabled = hubQuery.isNotBlank(), - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(R.string.skills_hub_search_button), - ) - } - } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = - KeyboardActions( - onSearch = { - if (hubQuery.isNotBlank()) { - onSearch(hubQuery) - } - }, - ), - ) - - when { - state.isHubSearching -> { - SkeletonListState() - } - - state.hubSearchError != null -> { - ErrorState( - message = state.hubSearchError, - onRetry = { onSearch(hubQuery) }, - ) - } - - state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> { - EmptyState( - icon = Icons.Filled.Extension, - title = stringResource(R.string.skills_hub_empty_results), - actionLabel = stringResource(R.string.empty_action_retry_search), - onAction = { onSearch(hubQuery) }, - ) - } - - state.hubResults.isNotEmpty() -> { - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = listItemSpacing, - ) { - itemsIndexed( - state.hubResults, - key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" }, - ) { _, hubSkill -> - HubSkillCard( - hubSkill = hubSkill, - onInstall = { onInstall(hubSkill.name) }, - isInstalling = isInstalling && installingSkillName == hubSkill.name, - onClick = { - showDetail = hubSkill - hubSkill.identifier?.let { onPreviewHubSkill(it) } - }, - ) - } - } - } - - else -> { - EmptyState( - icon = Icons.Filled.Search, - title = stringResource(R.string.skills_hub_search_hint), - ) - } - } - } - - showDetail?.let { hubSkill -> - val previewReady = state.hubPreviewIdentifier == hubSkill.identifier - val fullContent = - if (previewReady) { - state.hubPreviewContent ?: hubSkill.description - } else { - hubSkill.description - } - DetailDialog( - title = hubSkill.name, - rows = - listOf( - DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category), - DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source), - DetailRow(stringResource(R.string.detail_dialog_description), fullContent), - DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")), - DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel), - ), - actions = - if (previewReady && state.isHubPreviewing) { - { CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) } - } else { - null - }, - onDismiss = { showDetail = null }, - ) - } -} - -@Composable -private fun HubSkillCard( - hubSkill: HubSkill, - onInstall: () -> Unit, - isInstalling: Boolean, - onClick: () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), - ) { - // ── Top row: name + source badge ── - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = hubSkill.name, - style = - MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - hubSkill.source?.let { source -> - Spacer(modifier = Modifier.width(6.dp)) - SourceBadge(source = source) - } - } - - // ── Category ── - hubSkill.category?.let { cat -> - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = cat, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - // ── Description ── - hubSkill.description?.let { desc -> - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = desc, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - - // ── Install button ── - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Button( - onClick = onInstall, - enabled = !isInstalling, - ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - imageVector = Icons.Filled.CloudDownload, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.skills_hub_install)) - } - } - } - } - } -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03a691b7..bf05afb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -277,6 +277,23 @@ No skills found in hub Type a search term to browse the skill hub Install + Connected sources + rate limited + offline + via index + Featured skills + from the Hermes index — search above for more + Search the hub above to browse installable skills from the connected sources + Scan + Verdict: %1$s + Install allowed + Needs confirmation + Install blocked + No risky patterns detected + + %1$d finding + %1$d findings + Enter skill content\u2026 No skills found Discard changes? diff --git a/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt new file mode 100644 index 00000000..1cad80e1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/skills/SkillsViewModelTest.kt @@ -0,0 +1,199 @@ +package com.m57.hermescontrol.ui.skills + +import android.app.Application +import com.m57.hermescontrol.data.model.HubSkill +import com.m57.hermescontrol.data.model.SkillHubSource +import com.m57.hermescontrol.data.model.SkillHubSourcesResponse +import com.m57.hermescontrol.data.model.SkillScanFinding +import com.m57.hermescontrol.data.model.SkillScanResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class SkillsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApi = mockk(relaxed = true) + private val app = mockk(relaxed = true) + + /** + * Pump the test scheduler while letting the real Dispatchers.IO hops + * (safeLaunchLoad / withContext(IO)) land their resumptions. + */ + private fun settle() { + repeat(20) { + testDispatcher.scheduler.advanceUntilIdle() + Thread.sleep(10) + } + testDispatcher.scheduler.advanceUntilIdle() + } + + private val sourcesResponse = + SkillHubSourcesResponse( + sources = + listOf( + SkillHubSource(id = "hermes-index", label = "Hermes Index", available = true), + SkillHubSource(id = "github", label = "GitHub", rateLimited = true), + ), + indexAvailable = true, + featured = + listOf( + HubSkill( + name = "web-research", + description = "Find and verify current web facts", + source = "hermes-index", + identifier = "hermes-index:web-research", + ), + ), + ) + + private val scanResponse = + SkillScanResponse( + identifier = "hermes-index:web-research", + name = "web-research", + source = "hermes-index", + trustLevel = "trusted", + verdict = "safe", + summary = "No issues found", + policy = "allow", + policyReason = "No dangerous patterns detected.", + findings = + listOf( + SkillScanFinding( + severity = "low", + category = "network", + file = "scripts/fetch.py", + line = 12, + description = "Uses urllib without a timeout.", + ), + ), + severityCounts = mapOf("low" to 1), + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + every { ApiClient.hermesApi } returns mockApi + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `loadHubSources stores sources and featured skills`() { + coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) + val vm = SkillsViewModel(app) + vm.loadHubSources() + settle() + + assertEquals(sourcesResponse.sources, vm.uiState.value.hubSources) + assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured) + assertEquals(false, vm.uiState.value.isHubSourcesLoading) + assertNull(vm.uiState.value.hubSourcesError) + } + + @Test + fun `loadHubSources surfaces an error`() { + coEvery { mockApi.getSkillHubSources() } returns + Response.error( + 404, + """{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()), + ) + val vm = SkillsViewModel(app) + vm.loadHubSources() + settle() + + assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources")) + assertEquals(false, vm.uiState.value.isHubSourcesLoading) + } + + @Test + fun `setViewMode HUB triggers sources load when landing is empty`() { + coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse) + val vm = SkillsViewModel(app) + vm.setViewMode(SkillsViewMode.HUB) + settle() + + coVerify { mockApi.getSkillHubSources() } + assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured) + } + + @Test + fun `scanHubSkill stores the scan result for the identifier`() { + coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns + Response.success(scanResponse) + val vm = SkillsViewModel(app) + vm.scanHubSkill("hermes-index:web-research") + settle() + + assertEquals("hermes-index:web-research", vm.uiState.value.hubScanIdentifier) + assertEquals(scanResponse, vm.uiState.value.hubScanResult) + assertEquals(false, vm.uiState.value.isHubScanning) + assertNull(vm.uiState.value.hubScanError) + } + + @Test + fun `scanHubSkill surfaces an error`() { + coEvery { mockApi.scanHubSkill(identifier = "broken:skill") } returns + Response.error( + 404, + """{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()), + ) + val vm = SkillsViewModel(app) + vm.scanHubSkill("broken:skill") + settle() + + assertEquals("broken:skill", vm.uiState.value.hubScanIdentifier) + assertNull(vm.uiState.value.hubScanResult) + assertEquals(false, vm.uiState.value.isHubScanning) + assertEquals("Skill not found", vm.uiState.value.hubScanError) + } + + @Test + fun `scanHubSkill ignores blank identifiers`() { + val vm = SkillsViewModel(app) + vm.scanHubSkill(" ") + settle() + + assertNull(vm.uiState.value.hubScanIdentifier) + coVerify(exactly = 0) { mockApi.scanHubSkill(any()) } + } + + @Test + fun `clearHubScan resets scan state`() { + coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns + Response.success(scanResponse) + val vm = SkillsViewModel(app) + vm.scanHubSkill("hermes-index:web-research") + settle() + vm.clearHubScan() + + assertNull(vm.uiState.value.hubScanIdentifier) + assertNull(vm.uiState.value.hubScanResult) + assertNull(vm.uiState.value.hubScanError) + assertEquals(false, vm.uiState.value.isHubScanning) + } +}