diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt index 255ae60..6850bc6 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -56,6 +56,7 @@ import com.manjee.linkops.domain.usecase.localhosting.StartLocalServerUseCase import com.manjee.linkops.domain.usecase.localhosting.StopLocalServerUseCase import com.manjee.linkops.domain.usecase.logstream.ObserveLogStreamUseCase import com.manjee.linkops.domain.usecase.manifest.AnalyzeManifestUseCase +import com.manjee.linkops.domain.usecase.topology.BuildTopologyTreeUseCase import com.manjee.linkops.domain.usecase.manifest.GetInstalledPackagesUseCase import com.manjee.linkops.domain.usecase.manifest.SearchPackagesUseCase import com.manjee.linkops.domain.usecase.manifest.TestDeepLinkUseCase @@ -288,6 +289,11 @@ object AppContainer { RunVerificationWorkflowUseCase(localHostingRepository) } + // UseCases - Topology + val buildTopologyTreeUseCase: BuildTopologyTreeUseCase by lazy { + BuildTopologyTreeUseCase() + } + // UseCases - Favorite val observeFavoritesUseCase: ObserveFavoritesUseCase by lazy { ObserveFavoritesUseCase(favoriteRepository) diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TopologyTree.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TopologyTree.kt new file mode 100644 index 0000000..05bd587 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/TopologyTree.kt @@ -0,0 +1,87 @@ +package com.manjee.linkops.domain.model + +/** + * Represents a node in the deep link topology tree + * + * Tree structure: APP_ROOT -> SCHEME -> HOST -> PATH -> ACTIVITY + */ +data class TopologyNode( + val id: String, + val label: String, + val type: TopologyNodeType, + val children: List, + val metadata: TopologyNodeMetadata = TopologyNodeMetadata() +) { + /** + * Total number of descendants including this node + */ + val totalDescendants: Int + get() = 1 + children.sumOf { it.totalDescendants } +} + +/** + * Type of topology node in the hierarchy + */ +enum class TopologyNodeType { + APP_ROOT, + SCHEME, + HOST, + PATH, + ACTIVITY +} + +/** + * Metadata for a topology node + */ +data class TopologyNodeMetadata( + val autoVerify: Boolean = false, + val verificationStatus: DomainVerificationStatus? = null, + val isDuplicate: Boolean = false, + val isOrphaned: Boolean = false, + val sampleUri: String? = null, + val activityName: String? = null, + val deepLinkCount: Int = 0 +) + +/** + * Result of topology tree analysis + */ +data class TopologyAnalysisResult( + val tree: TopologyNode, + val insights: List, + val totalSchemes: Int, + val totalHosts: Int, + val totalPaths: Int, + val totalActivities: Int +) + +/** + * An insight detected during topology analysis + */ +data class TopologyInsight( + val severity: InsightSeverity, + val category: InsightCategory, + val title: String, + val description: String, + val affectedNodeIds: List +) + +/** + * Severity level of a topology insight + */ +enum class InsightSeverity { + INFO, + WARNING, + ERROR +} + +/** + * Category of topology insight + */ +enum class InsightCategory { + DUPLICATE_SCHEME, + MISSING_PATH, + ORPHANED_ACTIVITY, + UNVERIFIED_DOMAIN, + MIXED_VERIFICATION +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCase.kt new file mode 100644 index 0000000..755d773 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCase.kt @@ -0,0 +1,291 @@ +package com.manjee.linkops.domain.usecase.topology + +import com.manjee.linkops.domain.model.DeepLinkInfo +import com.manjee.linkops.domain.model.DomainVerificationResult +import com.manjee.linkops.domain.model.DomainVerificationStatus +import com.manjee.linkops.domain.model.InsightCategory +import com.manjee.linkops.domain.model.InsightSeverity +import com.manjee.linkops.domain.model.ManifestInfo +import com.manjee.linkops.domain.model.TopologyAnalysisResult +import com.manjee.linkops.domain.model.TopologyInsight +import com.manjee.linkops.domain.model.TopologyNode +import com.manjee.linkops.domain.model.TopologyNodeMetadata +import com.manjee.linkops.domain.model.TopologyNodeType + +/** + * Builds a topology tree from ManifestInfo deep links + * + * Groups deep links by scheme -> host -> path -> activity and detects + * structural issues like duplicate schemes, orphaned activities, and + * unverified domains. + */ +class BuildTopologyTreeUseCase { + + /** + * Build topology tree from manifest analysis data + * + * @param manifestInfo Parsed manifest information + * @param domainVerification Optional domain verification result + * @return Topology analysis result with tree and insights + */ + operator fun invoke( + manifestInfo: ManifestInfo, + domainVerification: DomainVerificationResult? + ): TopologyAnalysisResult { + val deepLinks = manifestInfo.deepLinks + val insights = mutableListOf() + + val schemeGroups = deepLinks.groupBy { it.scheme } + + val schemeNodes = schemeGroups.map { (scheme, schemeLinks) -> + buildSchemeNode(scheme, schemeLinks, domainVerification, insights) + } + + val rootNode = TopologyNode( + id = "root", + label = manifestInfo.packageName, + type = TopologyNodeType.APP_ROOT, + children = schemeNodes, + metadata = TopologyNodeMetadata( + deepLinkCount = deepLinks.size + ) + ) + + // Detect duplicate schemes (same scheme handled by multiple activities) + detectDuplicateSchemes(schemeGroups, insights) + + // Detect orphaned activities + detectOrphanedActivities(manifestInfo, deepLinks, insights) + + // Detect unverified domains + detectUnverifiedDomains(deepLinks, domainVerification, insights) + + // Detect mixed verification states + detectMixedVerification(deepLinks, insights) + + val allActivities = deepLinks.map { it.activityName }.distinct() + val allHosts = deepLinks.mapNotNull { it.host }.distinct() + + return TopologyAnalysisResult( + tree = rootNode, + insights = insights, + totalSchemes = schemeGroups.size, + totalHosts = allHosts.size, + totalPaths = deepLinks.count { it.path != null || it.pathPrefix != null || it.pathPattern != null }, + totalActivities = allActivities.size + ) + } + + private fun buildSchemeNode( + scheme: String, + schemeLinks: List, + domainVerification: DomainVerificationResult?, + insights: MutableList + ): TopologyNode { + val hostGroups = schemeLinks.groupBy { it.host ?: "(no host)" } + + val hostNodes = hostGroups.map { (host, hostLinks) -> + buildHostNode(scheme, host, hostLinks, domainVerification) + } + + return TopologyNode( + id = "scheme:$scheme", + label = "$scheme://", + type = TopologyNodeType.SCHEME, + children = hostNodes, + metadata = TopologyNodeMetadata( + autoVerify = schemeLinks.any { it.autoVerify }, + deepLinkCount = schemeLinks.size + ) + ) + } + + private fun buildHostNode( + scheme: String, + host: String, + hostLinks: List, + domainVerification: DomainVerificationResult? + ): TopologyNode { + val verificationStatus = if (host != "(no host)") { + domainVerification?.domains?.find { it.domain == host }?.status + } else { + null + } + + val pathGroups = hostLinks.groupBy { extractPathKey(it) } + + val pathNodes = pathGroups.map { (pathKey, pathLinks) -> + buildPathNode(scheme, host, pathKey, pathLinks) + } + + return TopologyNode( + id = "host:$scheme://$host", + label = host, + type = TopologyNodeType.HOST, + children = pathNodes, + metadata = TopologyNodeMetadata( + autoVerify = hostLinks.any { it.autoVerify }, + verificationStatus = verificationStatus, + deepLinkCount = hostLinks.size, + sampleUri = "$scheme://$host" + ) + ) + } + + private fun buildPathNode( + scheme: String, + host: String, + pathKey: String, + pathLinks: List + ): TopologyNode { + val activityNodes = pathLinks.map { link -> + TopologyNode( + id = "activity:${link.activityName}:${link.sampleUri}", + label = link.activityName.substringAfterLast("."), + type = TopologyNodeType.ACTIVITY, + children = emptyList(), + metadata = TopologyNodeMetadata( + activityName = link.activityName, + sampleUri = link.sampleUri, + autoVerify = link.autoVerify + ) + ) + } + + val displayHost = if (host == "(no host)") "" else host + val sampleUri = if (pathKey == "(no path)") { + "$scheme://$displayHost/" + } else { + "$scheme://$displayHost$pathKey" + } + + return TopologyNode( + id = "path:$scheme://$host$pathKey", + label = if (pathKey == "(no path)") "/" else pathKey, + type = TopologyNodeType.PATH, + children = activityNodes, + metadata = TopologyNodeMetadata( + deepLinkCount = pathLinks.size, + sampleUri = sampleUri + ) + ) + } + + private fun extractPathKey(link: DeepLinkInfo): String { + return when { + link.path != null -> link.path + link.pathPrefix != null -> "${link.pathPrefix}*" + link.pathPattern != null -> link.pathPattern + else -> "(no path)" + } + } + + private fun detectDuplicateSchemes( + schemeGroups: Map>, + insights: MutableList + ) { + schemeGroups.forEach { (scheme, links) -> + val activitiesByHost = links.groupBy { it.host ?: "(no host)" } + activitiesByHost.forEach { (host, hostLinks) -> + val activities = hostLinks.map { it.activityName }.distinct() + if (activities.size > 1) { + insights.add( + TopologyInsight( + severity = InsightSeverity.WARNING, + category = InsightCategory.DUPLICATE_SCHEME, + title = "Multiple activities handle $scheme://$host", + description = "${activities.size} activities handle the same scheme+host combination: ${activities.joinToString(", ") { it.substringAfterLast(".") }}", + affectedNodeIds = activities.map { "activity:$it:$scheme://$host" } + ) + ) + } + } + } + } + + private fun detectOrphanedActivities( + manifestInfo: ManifestInfo, + deepLinks: List, + insights: MutableList + ) { + val deepLinkActivities = deepLinks.map { it.activityName }.toSet() + val allActivities = manifestInfo.activities.filter { activity -> + activity.intentFilters.any { filter -> + filter.isViewAction && filter.isBrowsable + } + } + + val orphaned = allActivities.filter { activity -> + activity.name !in deepLinkActivities && + activity.intentFilters.any { it.hasDeepLinkData } + } + + orphaned.forEach { activity -> + insights.add( + TopologyInsight( + severity = InsightSeverity.WARNING, + category = InsightCategory.ORPHANED_ACTIVITY, + title = "Orphaned activity: ${activity.name.substringAfterLast(".")}", + description = "Activity ${activity.name} has intent filters with deep link data but no resolved deep links", + affectedNodeIds = listOf("activity:${activity.name}") + ) + ) + } + } + + private fun detectUnverifiedDomains( + deepLinks: List, + domainVerification: DomainVerificationResult?, + insights: MutableList + ) { + if (domainVerification == null) return + + val appLinkHosts = deepLinks + .filter { it.isAppLink } + .mapNotNull { it.host } + .distinct() + + appLinkHosts.forEach { host -> + val status = domainVerification.domains.find { it.domain == host }?.status + if (status != null && status != DomainVerificationStatus.VERIFIED && status != DomainVerificationStatus.ALWAYS) { + insights.add( + TopologyInsight( + severity = InsightSeverity.ERROR, + category = InsightCategory.UNVERIFIED_DOMAIN, + title = "Unverified domain: $host", + description = "Domain $host has verification status: ${status.displayName}. App Links will not work as expected.", + affectedNodeIds = deepLinks + .filter { it.host == host && it.isAppLink } + .map { "host:${it.scheme}://$host" } + .distinct() + ) + ) + } + } + } + + private fun detectMixedVerification( + deepLinks: List, + insights: MutableList + ) { + val hostGroups = deepLinks.groupBy { it.host } + hostGroups.forEach { (host, links) -> + if (host == null) return@forEach + val hasAutoVerify = links.any { it.autoVerify } + val hasNoAutoVerify = links.any { !it.autoVerify } + if (hasAutoVerify && hasNoAutoVerify) { + insights.add( + TopologyInsight( + severity = InsightSeverity.WARNING, + category = InsightCategory.MIXED_VERIFICATION, + title = "Mixed verification for $host", + description = "Some deep links for $host have autoVerify=true while others do not. This may cause inconsistent behavior.", + affectedNodeIds = links.flatMap { link -> + listOf("host:${link.scheme}://$host") + }.distinct() + ) + ) + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/TopologyTreeView.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/TopologyTreeView.kt new file mode 100644 index 0000000..c09b0cf --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/component/TopologyTreeView.kt @@ -0,0 +1,480 @@ +package com.manjee.linkops.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.manjee.linkops.domain.model.* +import com.manjee.linkops.ui.theme.LinkOpsColors + +/** + * Flattened node for display in LazyColumn + */ +private data class FlatNode( + val node: TopologyNode, + val depth: Int, + val isExpanded: Boolean, + val hasChildren: Boolean +) + +/** + * Tree view displaying topology nodes with expand/collapse + * + * @param analysisResult The topology analysis result to display + * @param searchQuery Current search query for filtering + * @param highlightedNodeIds Set of node IDs to highlight + * @param onNodeClick Callback when a node is clicked + * @param modifier Modifier for the component + */ +@Composable +fun TopologyTreeView( + analysisResult: TopologyAnalysisResult, + searchQuery: String, + highlightedNodeIds: Set, + onNodeClick: (TopologyNode) -> Unit, + modifier: Modifier = Modifier +) { + val expandedNodes = remember { mutableStateMapOf() } + + // Auto-expand root and scheme nodes by default + LaunchedEffect(analysisResult) { + expandedNodes["root"] = true + analysisResult.tree.children.forEach { schemeNode -> + expandedNodes[schemeNode.id] = true + } + } + + // Auto-expand highlighted nodes + LaunchedEffect(highlightedNodeIds) { + if (highlightedNodeIds.isNotEmpty()) { + expandAncestors(analysisResult.tree, highlightedNodeIds, expandedNodes) + } + } + + val flatNodes = remember(analysisResult, expandedNodes.toMap(), searchQuery) { + buildFlatList(analysisResult.tree, 0, expandedNodes, searchQuery) + } + + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(flatNodes, key = { it.node.id }) { flatNode -> + TopologyNodeRow( + node = flatNode.node, + depth = flatNode.depth, + isExpanded = flatNode.isExpanded, + hasChildren = flatNode.hasChildren, + isHighlighted = highlightedNodeIds.contains(flatNode.node.id), + matchesSearch = searchQuery.isNotBlank() && nodeMatchesSearch(flatNode.node, searchQuery), + onToggleExpand = { + expandedNodes[flatNode.node.id] = !(expandedNodes[flatNode.node.id] ?: false) + }, + onClick = { onNodeClick(flatNode.node) } + ) + } + } +} + +/** + * Row representing a single topology node + */ +@Composable +private fun TopologyNodeRow( + node: TopologyNode, + depth: Int, + isExpanded: Boolean, + hasChildren: Boolean, + isHighlighted: Boolean, + matchesSearch: Boolean, + onToggleExpand: () -> Unit, + onClick: () -> Unit +) { + val indentDp = (depth * 24).dp + val nodeColor = nodeColor(node) + val bgColor = when { + isHighlighted -> LinkOpsColors.PrimaryLight.copy(alpha = 0.3f) + matchesSearch -> LinkOpsColors.InfoLight.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.surface + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = indentDp) + .background(bgColor, RoundedCornerShape(6.dp)) + .clickable { if (hasChildren) onToggleExpand() else onClick() } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Expand/collapse indicator + if (hasChildren) { + Text( + text = if (isExpanded) "▼" else "▶", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(16.dp) + ) + } else { + Spacer(modifier = Modifier.width(16.dp)) + } + + // Node type icon + Text( + text = nodeIcon(node), + style = MaterialTheme.typography.bodyMedium + ) + + // Node label + Text( + text = node.label, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = if (node.type != TopologyNodeType.APP_ROOT) FontFamily.Monospace else FontFamily.Default, + fontSize = if (node.type == TopologyNodeType.APP_ROOT) 14.sp else 13.sp + ), + fontWeight = if (node.type == TopologyNodeType.APP_ROOT || node.type == TopologyNodeType.SCHEME) { + FontWeight.SemiBold + } else { + FontWeight.Normal + }, + color = nodeColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + // Badges + NodeBadges(node) + } +} + +/** + * Badges displayed on a topology node row + */ +@Composable +private fun NodeBadges(node: TopologyNode) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Verification status badge for HOST nodes + node.metadata.verificationStatus?.let { status -> + val (text, color, bgColor) = when (status) { + DomainVerificationStatus.VERIFIED -> Triple("verified", LinkOpsColors.Success, LinkOpsColors.SuccessLight) + DomainVerificationStatus.ALWAYS -> Triple("always", LinkOpsColors.Success, LinkOpsColors.SuccessLight) + DomainVerificationStatus.NONE -> Triple("none", LinkOpsColors.Unknown, LinkOpsColors.SecondaryLight) + DomainVerificationStatus.LEGACY_FAILURE -> Triple("failed", LinkOpsColors.Error, LinkOpsColors.ErrorLight) + DomainVerificationStatus.NEVER -> Triple("never", LinkOpsColors.Error, LinkOpsColors.ErrorLight) + DomainVerificationStatus.UNKNOWN -> Triple("unknown", LinkOpsColors.Unknown, LinkOpsColors.SecondaryLight) + } + Box( + modifier = Modifier + .background(bgColor, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, + fontSize = 10.sp + ) + } + } + + // Auto-verify badge for SCHEME nodes + if (node.metadata.autoVerify && node.type == TopologyNodeType.SCHEME) { + Box( + modifier = Modifier + .background(LinkOpsColors.SuccessLight, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "autoVerify", + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.Success, + fontSize = 10.sp + ) + } + } + + // Deep link count badge + if (node.metadata.deepLinkCount > 0 && node.type != TopologyNodeType.ACTIVITY) { + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "${node.metadata.deepLinkCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 10.sp + ) + } + } + } +} + +/** + * Panel displaying topology insights + * + * @param insights List of topology insights + * @param onInsightClick Callback when an insight is clicked + * @param modifier Modifier for the component + */ +@Composable +fun TopologyInsightsPanel( + insights: List, + onInsightClick: (TopologyInsight) -> Unit, + modifier: Modifier = Modifier +) { + if (insights.isEmpty()) return + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + modifier = modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Insights (${insights.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + insights.forEach { insight -> + InsightItem( + insight = insight, + onClick = { onInsightClick(insight) } + ) + } + } + } +} + +/** + * Individual insight item + */ +@Composable +private fun InsightItem( + insight: TopologyInsight, + onClick: () -> Unit +) { + val (bgColor, iconColor, icon) = when (insight.severity) { + InsightSeverity.ERROR -> Triple(LinkOpsColors.InsightErrorBg, LinkOpsColors.Error, "!") + InsightSeverity.WARNING -> Triple(LinkOpsColors.InsightWarningBg, LinkOpsColors.Warning, "!") + InsightSeverity.INFO -> Triple(LinkOpsColors.InsightInfoBg, LinkOpsColors.Info, "i") + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(bgColor, RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(24.dp) + .background(iconColor.copy(alpha = 0.2f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = icon, + color = iconColor, + fontWeight = FontWeight.Bold, + fontSize = 12.sp + ) + } + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = insight.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = insight.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Statistics summary for topology tree + * + * @param analysisResult The topology analysis result + * @param modifier Modifier for the component + */ +@Composable +fun TopologyStatsBar( + analysisResult: TopologyAnalysisResult, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + StatChip("Schemes", analysisResult.totalSchemes) + StatChip("Hosts", analysisResult.totalHosts) + StatChip("Paths", analysisResult.totalPaths) + StatChip("Activities", analysisResult.totalActivities) + } +} + +@Composable +private fun StatChip(label: String, count: Int) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = count.toString(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = LinkOpsColors.Primary + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +// -- Helper functions -- + +private fun nodeIcon(node: TopologyNode): String { + return when (node.type) { + TopologyNodeType.APP_ROOT -> "\uD83D\uDCE6" + TopologyNodeType.SCHEME -> if (node.label.startsWith("http")) "\uD83D\uDD12" else "\uD83D\uDD17" + TopologyNodeType.HOST -> "\uD83C\uDF10" + TopologyNodeType.PATH -> "\u2192" + TopologyNodeType.ACTIVITY -> "\u25A0" + } +} + +@Composable +private fun nodeColor(node: TopologyNode): androidx.compose.ui.graphics.Color { + return when (node.type) { + TopologyNodeType.APP_ROOT -> MaterialTheme.colorScheme.onSurface + TopologyNodeType.SCHEME -> if (node.label.startsWith("http")) { + LinkOpsColors.TopologySchemeHttp + } else { + LinkOpsColors.TopologySchemeCustom + } + TopologyNodeType.HOST -> when (node.metadata.verificationStatus) { + DomainVerificationStatus.VERIFIED, DomainVerificationStatus.ALWAYS -> LinkOpsColors.TopologyHostVerified + DomainVerificationStatus.LEGACY_FAILURE, DomainVerificationStatus.NEVER -> LinkOpsColors.TopologyHostFailed + else -> MaterialTheme.colorScheme.onSurface + } + TopologyNodeType.PATH -> LinkOpsColors.TopologyPath + TopologyNodeType.ACTIVITY -> LinkOpsColors.TopologyActivity + } +} + +private fun nodeMatchesSearch(node: TopologyNode, query: String): Boolean { + val q = query.lowercase() + return node.label.lowercase().contains(q) || + node.metadata.activityName?.lowercase()?.contains(q) == true || + node.metadata.sampleUri?.lowercase()?.contains(q) == true +} + +private fun buildFlatList( + node: TopologyNode, + depth: Int, + expandedNodes: Map, + searchQuery: String +): List { + val result = mutableListOf() + val isExpanded = expandedNodes[node.id] ?: false + val hasChildren = node.children.isNotEmpty() + + // Skip root node itself, but include its children + if (node.type == TopologyNodeType.APP_ROOT) { + if (isExpanded || searchQuery.isNotBlank()) { + node.children.forEach { child -> + result.addAll(buildFlatList(child, depth, expandedNodes, searchQuery)) + } + } + return result + } + + // If searching, only show matching nodes and their ancestors + if (searchQuery.isNotBlank()) { + if (!subtreeMatchesSearch(node, searchQuery)) { + return result + } + } + + result.add( + FlatNode( + node = node, + depth = depth, + isExpanded = isExpanded, + hasChildren = hasChildren + ) + ) + + if (isExpanded || (searchQuery.isNotBlank() && subtreeMatchesSearch(node, searchQuery))) { + node.children.forEach { child -> + result.addAll(buildFlatList(child, depth + 1, expandedNodes, searchQuery)) + } + } + + return result +} + +private fun subtreeMatchesSearch(node: TopologyNode, query: String): Boolean { + if (nodeMatchesSearch(node, query)) return true + return node.children.any { subtreeMatchesSearch(it, query) } +} + +private fun expandAncestors( + root: TopologyNode, + targetIds: Set, + expandedNodes: MutableMap +) { + fun findAndExpand(node: TopologyNode): Boolean { + if (targetIds.contains(node.id)) return true + var found = false + node.children.forEach { child -> + if (findAndExpand(child)) { + expandedNodes[node.id] = true + found = true + } + } + return found + } + findAndExpand(root) +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt index 906a67d..a92b754 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerScreen.kt @@ -34,6 +34,9 @@ import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.* import com.manjee.linkops.domain.model.IntentConfig import com.manjee.linkops.domain.repository.PackageFilter +import com.manjee.linkops.domain.model.TopologyAnalysisResult +import com.manjee.linkops.domain.model.TopologyInsight +import com.manjee.linkops.domain.model.TopologyNode import com.manjee.linkops.ui.component.* import com.manjee.linkops.ui.theme.LinkOpsColors import com.manjee.linkops.ui.util.ExportUtils @@ -138,7 +141,7 @@ fun ManifestAnalyzerScreen( ) } - // Right Panel - Analysis Results + // Right Panel - Analysis Results with Tabs Column( modifier = Modifier .weight(0.6f) @@ -148,36 +151,98 @@ fun ManifestAnalyzerScreen( ) { val scope = rememberCoroutineScope() - AnalysisResultsPanel( - result = uiState.analysisResult, - isAnalyzing = uiState.isAnalyzing, - favoriteUris = uiState.favoriteUris, - onClear = { viewModel.clearAnalysis() }, - onTestDeepLink = { uri -> viewModel.testDeepLink(uri) }, - onSendDeepLink = { uri -> - intentDialogUri = uri - showIntentDialog = true - }, - onShowQr = { uri -> - qrDialogUri = uri - showQrDialog = true - }, - onToggleFavorite = { uri, name -> viewModel.toggleFavorite(uri, name) }, - onExportMarkdown = { - uiState.analysisResult?.let { result -> - scope.launch(Dispatchers.IO) { - ExportUtils.saveMarkdown(result) - } + // Header with clear button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Analysis Results", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (uiState.analysisResult != null) { + IconButton(onClick = { viewModel.clearAnalysis() }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") } - }, - onExportPdf = { - uiState.analysisResult?.let { result -> - scope.launch(Dispatchers.IO) { - ExportUtils.savePdf(result) + } + } + + // Tab row for switching views + if (uiState.analysisResult?.manifestInfo != null) { + TabRow( + selectedTabIndex = uiState.selectedTab.ordinal, + modifier = Modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary + ) { + Tab( + selected = uiState.selectedTab == ManifestTab.LIST_VIEW, + onClick = { viewModel.selectTab(ManifestTab.LIST_VIEW) }, + text = { Text("List View") } + ) + Tab( + selected = uiState.selectedTab == ManifestTab.TOPOLOGY_MAP, + onClick = { viewModel.selectTab(ManifestTab.TOPOLOGY_MAP) }, + text = { Text("Topology Map") } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + // Content based on selected tab + when (uiState.selectedTab) { + ManifestTab.LIST_VIEW -> { + ListViewContent( + result = uiState.analysisResult, + isAnalyzing = uiState.isAnalyzing, + favoriteUris = uiState.favoriteUris, + onTestDeepLink = { uri -> viewModel.testDeepLink(uri) }, + onSendDeepLink = { uri -> + intentDialogUri = uri + showIntentDialog = true + }, + onShowQr = { uri -> + qrDialogUri = uri + showQrDialog = true + }, + onToggleFavorite = { uri, name -> viewModel.toggleFavorite(uri, name) }, + onExportMarkdown = { + uiState.analysisResult?.let { result -> + scope.launch(Dispatchers.IO) { + ExportUtils.saveMarkdown(result) + } + } + }, + onExportPdf = { + uiState.analysisResult?.let { result -> + scope.launch(Dispatchers.IO) { + ExportUtils.savePdf(result) + } + } } - } + ) } - ) + ManifestTab.TOPOLOGY_MAP -> { + TopologyMapContent( + topologyResult = uiState.topologyResult, + searchQuery = uiState.topologySearchQuery, + highlightedNodeIds = uiState.highlightedNodeIds, + onSearchQueryChange = { viewModel.updateTopologySearch(it) }, + onNodeClick = { node -> + node.metadata.sampleUri?.let { uri -> + viewModel.testDeepLink(uri) + } + }, + onInsightClick = { insight -> + viewModel.highlightInsightNodes(insight) + } + ) + } + } } } } @@ -427,14 +492,13 @@ private fun PackageItem( } /** - * Analysis results panel + * List view content for analysis results */ @Composable -private fun AnalysisResultsPanel( +private fun ListViewContent( result: ManifestAnalysisResult?, isAnalyzing: Boolean, favoriteUris: Set, - onClear: () -> Unit, onTestDeepLink: (String) -> Unit, onSendDeepLink: (String) -> Unit, onShowQr: (String) -> Unit, @@ -442,130 +506,172 @@ private fun AnalysisResultsPanel( onExportMarkdown: () -> Unit, onExportPdf: () -> Unit ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + if (result == null && !isAnalyzing) { + EmptyState( + title = "No analysis yet", + description = "Select a package to analyze its deep links", + icon = Icons.Default.Search + ) + } else if (result != null) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = "Analysis Results", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - if (result != null) { - IconButton(onClick = onClear) { - Icon(Icons.Default.Clear, contentDescription = "Clear") + // Error state + if (!result.isSuccess) { + item { + ErrorCard(result.error ?: "Unknown error") } } - } - if (result == null && !isAnalyzing) { - EmptyState( - title = "No analysis yet", - description = "Select a package to analyze its deep links", - icon = Icons.Default.Search - ) - } else if (result != null) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Error state - if (!result.isSuccess) { - item { - ErrorCard(result.error ?: "Unknown error") - } + // Package info + result.manifestInfo?.let { info -> + item { + PackageInfoCard(info) } - // Package info - result.manifestInfo?.let { info -> - item { - PackageInfoCard(info) + // Export buttons + item { + ExportButtonsRow( + onExportMarkdown = onExportMarkdown, + onExportPdf = onExportPdf + ) + } + + // Domain verification status + result.domainVerification?.let { verification -> + if (verification.domains.isNotEmpty()) { + item { + DomainVerificationCard(verification) + } } + } + + // Deep links summary + item { + DeepLinksSummaryCard(info) + } - // Export buttons + // App Links (verified) + if (info.appLinks.isNotEmpty()) { item { - ExportButtonsRow( - onExportMarkdown = onExportMarkdown, - onExportPdf = onExportPdf + DeepLinksCard( + title = "App Links (Auto-Verified)", + deepLinks = info.appLinks, + isAppLink = true, + domainVerification = result.domainVerification, + favoriteUris = favoriteUris, + onTestDeepLink = onTestDeepLink, + onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, + onToggleFavorite = onToggleFavorite ) } + } - // Domain verification status - result.domainVerification?.let { verification -> - if (verification.domains.isNotEmpty()) { - item { - DomainVerificationCard(verification) - } - } + // Custom scheme links + if (info.customSchemeLinks.isNotEmpty()) { + item { + DeepLinksCard( + title = "Custom Scheme Links", + deepLinks = info.customSchemeLinks, + isAppLink = false, + domainVerification = null, + favoriteUris = favoriteUris, + onTestDeepLink = onTestDeepLink, + onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, + onToggleFavorite = onToggleFavorite + ) } + } - // Deep links summary + // All deep links (if different from above) + val httpLinks = info.deepLinks.filter { + (it.scheme == "http" || it.scheme == "https") && !it.autoVerify + } + if (httpLinks.isNotEmpty()) { item { - DeepLinksSummaryCard(info) + DeepLinksCard( + title = "HTTP/HTTPS Links (Not Auto-Verified)", + deepLinks = httpLinks, + isAppLink = false, + domainVerification = result.domainVerification, + favoriteUris = favoriteUris, + onTestDeepLink = onTestDeepLink, + onSendDeepLink = onSendDeepLink, + onShowQr = onShowQr, + onToggleFavorite = onToggleFavorite + ) } + } + } + } + } +} - // App Links (verified) - if (info.appLinks.isNotEmpty()) { - item { - DeepLinksCard( - title = "App Links (Auto-Verified)", - deepLinks = info.appLinks, - isAppLink = true, - domainVerification = result.domainVerification, - favoriteUris = favoriteUris, - onTestDeepLink = onTestDeepLink, - onSendDeepLink = onSendDeepLink, - onShowQr = onShowQr, - onToggleFavorite = onToggleFavorite - ) - } - } +/** + * Topology map content showing tree visualization + */ +@Composable +private fun TopologyMapContent( + topologyResult: TopologyAnalysisResult?, + searchQuery: String, + highlightedNodeIds: Set, + onSearchQueryChange: (String) -> Unit, + onNodeClick: (TopologyNode) -> Unit, + onInsightClick: (TopologyInsight) -> Unit +) { + if (topologyResult == null) { + EmptyState( + title = "No topology data", + description = "Select a package to view its deep link topology", + icon = Icons.Default.Search + ) + return + } - // Custom scheme links - if (info.customSchemeLinks.isNotEmpty()) { - item { - DeepLinksCard( - title = "Custom Scheme Links", - deepLinks = info.customSchemeLinks, - isAppLink = false, - domainVerification = null, - favoriteUris = favoriteUris, - onTestDeepLink = onTestDeepLink, - onSendDeepLink = onSendDeepLink, - onShowQr = onShowQr, - onToggleFavorite = onToggleFavorite - ) - } - } + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Stats bar + TopologyStatsBar(analysisResult = topologyResult) - // All deep links (if different from above) - val httpLinks = info.deepLinks.filter { - (it.scheme == "http" || it.scheme == "https") && !it.autoVerify - } - if (httpLinks.isNotEmpty()) { - item { - DeepLinksCard( - title = "HTTP/HTTPS Links (Not Auto-Verified)", - deepLinks = httpLinks, - isAppLink = false, - domainVerification = result.domainVerification, - favoriteUris = favoriteUris, - onTestDeepLink = onTestDeepLink, - onSendDeepLink = onSendDeepLink, - onShowQr = onShowQr, - onToggleFavorite = onToggleFavorite - ) - } + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Search topology") }, + placeholder = { Text("scheme, host, path, activity...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") } } } + ) + + // Insights panel + if (topologyResult.insights.isNotEmpty()) { + TopologyInsightsPanel( + insights = topologyResult.insights, + onInsightClick = onInsightClick + ) } + + // Tree view + TopologyTreeView( + analysisResult = topologyResult, + searchQuery = searchQuery, + highlightedNodeIds = highlightedNodeIds, + onNodeClick = onNodeClick, + modifier = Modifier.weight(1f) + ) } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerViewModel.kt index d76a2d9..cbbea0d 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/manifest/ManifestAnalyzerViewModel.kt @@ -4,6 +4,8 @@ import com.manjee.linkops.di.AppContainer import com.manjee.linkops.domain.model.Device import com.manjee.linkops.domain.model.IntentConfig import com.manjee.linkops.domain.model.ManifestAnalysisResult +import com.manjee.linkops.domain.model.TopologyAnalysisResult +import com.manjee.linkops.domain.model.TopologyInsight import com.manjee.linkops.domain.repository.PackageFilter import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow @@ -13,6 +15,14 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update +/** + * Tab selection for Manifest Analyzer + */ +enum class ManifestTab { + LIST_VIEW, + TOPOLOGY_MAP +} + /** * UI State for Manifest Analyzer Screen */ @@ -27,7 +37,11 @@ data class ManifestAnalyzerUiState( val packageFilter: PackageFilter = PackageFilter.THIRD_PARTY, val error: String? = null, val testResult: DeepLinkTestResult? = null, - val favoriteUris: Set = emptySet() + val favoriteUris: Set = emptySet(), + val selectedTab: ManifestTab = ManifestTab.LIST_VIEW, + val topologyResult: TopologyAnalysisResult? = null, + val topologySearchQuery: String = "", + val highlightedNodeIds: Set = emptySet() ) /** @@ -73,6 +87,7 @@ class ManifestAnalyzerViewModel { packages = emptyList(), selectedPackage = null, analysisResult = null, + topologyResult = null, error = null ) } @@ -172,7 +187,8 @@ class ManifestAnalyzerViewModel { _uiState.update { it.copy( selectedPackage = packageName, - analysisResult = null + analysisResult = null, + topologyResult = null ) } analyzePackage(packageName) @@ -189,9 +205,13 @@ class ManifestAnalyzerViewModel { AppContainer.analyzeManifestUseCase(device.serialNumber, packageName) .onSuccess { result -> + val topologyResult = result.manifestInfo?.let { info -> + AppContainer.buildTopologyTreeUseCase(info, result.domainVerification) + } _uiState.update { it.copy( analysisResult = result, + topologyResult = topologyResult, isAnalyzing = false ) } @@ -207,6 +227,40 @@ class ManifestAnalyzerViewModel { } } + /** + * Switch between List View and Topology Map tabs + * + * @param tab The tab to switch to + */ + fun selectTab(tab: ManifestTab) { + _uiState.update { it.copy(selectedTab = tab) } + } + + /** + * Update topology search query + * + * @param query The search query string + */ + fun updateTopologySearch(query: String) { + _uiState.update { it.copy(topologySearchQuery = query) } + } + + /** + * Highlight nodes affected by an insight + * + * @param insight The topology insight whose affected nodes to highlight + */ + fun highlightInsightNodes(insight: TopologyInsight) { + _uiState.update { it.copy(highlightedNodeIds = insight.affectedNodeIds.toSet()) } + } + + /** + * Clear highlighted nodes + */ + fun clearHighlightedNodes() { + _uiState.update { it.copy(highlightedNodeIds = emptySet()) } + } + /** * Clear analysis result */ @@ -214,7 +268,10 @@ class ManifestAnalyzerViewModel { _uiState.update { it.copy( selectedPackage = null, - analysisResult = null + analysisResult = null, + topologyResult = null, + topologySearchQuery = "", + highlightedNodeIds = emptySet() ) } } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/theme/Color.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/theme/Color.kt index 29b3a34..cc09949 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/theme/Color.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/theme/Color.kt @@ -64,6 +64,20 @@ object LinkOpsColors { val TerminalWarning = Color(0xFFFFD740) val TerminalInfo = Color(0xFF40C4FF) + // Topology node colors + val TopologySchemeHttp = Color(0xFF2196F3) + val TopologySchemeCustom = Color(0xFF7C4DFF) + val TopologyHostVerified = Color(0xFF4CAF50) + val TopologyHostFailed = Color(0xFFF44336) + val TopologyHostUnknown = Color(0xFF9E9E9E) + val TopologyPath = Color(0xFF607D8B) + val TopologyActivity = Color(0xFF78909C) + + // Topology insight severity backgrounds + val InsightInfoBg = Color(0xFFE3F2FD) + val InsightWarningBg = Color(0xFFFFF3E0) + val InsightErrorBg = Color(0xFFFFEBEE) + // Divider val Divider = Color(0xFFE0E0E0) val DividerDark = Color(0xFF424242) diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCaseTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCaseTest.kt new file mode 100644 index 0000000..2f5d639 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/domain/usecase/topology/BuildTopologyTreeUseCaseTest.kt @@ -0,0 +1,446 @@ +package com.manjee.linkops.domain.usecase.topology + +import com.manjee.linkops.domain.model.ActivityInfo +import com.manjee.linkops.domain.model.DeepLinkInfo +import com.manjee.linkops.domain.model.DomainVerificationInfo +import com.manjee.linkops.domain.model.DomainVerificationResult +import com.manjee.linkops.domain.model.DomainVerificationStatus +import com.manjee.linkops.domain.model.InsightCategory +import com.manjee.linkops.domain.model.InsightSeverity +import com.manjee.linkops.domain.model.IntentFilterInfo +import com.manjee.linkops.domain.model.ManifestInfo +import com.manjee.linkops.domain.model.TopologyNodeType +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class BuildTopologyTreeUseCaseTest { + + private lateinit var useCase: BuildTopologyTreeUseCase + + @BeforeTest + fun setup() { + useCase = BuildTopologyTreeUseCase() + } + + @Test + fun `should build tree from empty deep links`() { + val manifestInfo = createManifestInfo(emptyList()) + + val result = useCase(manifestInfo, null) + + assertEquals(0, result.totalSchemes) + assertEquals(0, result.totalHosts) + assertEquals(0, result.totalPaths) + assertEquals(0, result.totalActivities) + assertTrue(result.tree.children.isEmpty()) + assertEquals(TopologyNodeType.APP_ROOT, result.tree.type) + assertEquals("com.example.app", result.tree.label) + } + + @Test + fun `should group deep links by scheme`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home"), + createDeepLink(scheme = "myapp", host = "open", path = "/main"), + createDeepLink(scheme = "https", host = "example.com", path = "/about") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + assertEquals(2, result.totalSchemes) + assertEquals(2, result.tree.children.size) + + val httpsScheme = result.tree.children.find { it.label == "https://" } + assertNotNull(httpsScheme) + assertEquals(TopologyNodeType.SCHEME, httpsScheme.type) + + val customScheme = result.tree.children.find { it.label == "myapp://" } + assertNotNull(customScheme) + assertEquals(TopologyNodeType.SCHEME, customScheme.type) + } + + @Test + fun `should group deep links by host within scheme`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home"), + createDeepLink(scheme = "https", host = "test.com", path = "/login"), + createDeepLink(scheme = "https", host = "example.com", path = "/about") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + assertEquals(1, result.totalSchemes) + val httpsScheme = result.tree.children.first() + assertEquals(2, httpsScheme.children.size) + + val exampleHost = httpsScheme.children.find { it.label == "example.com" } + assertNotNull(exampleHost) + assertEquals(TopologyNodeType.HOST, exampleHost.type) + assertEquals(2, exampleHost.metadata.deepLinkCount) + + val testHost = httpsScheme.children.find { it.label == "test.com" } + assertNotNull(testHost) + assertEquals(1, testHost.metadata.deepLinkCount) + } + + @Test + fun `should group deep links by path within host`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home"), + createDeepLink(scheme = "https", host = "example.com", path = "/about"), + createDeepLink(scheme = "https", host = "example.com", path = "/home") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val httpsScheme = result.tree.children.first() + val exampleHost = httpsScheme.children.first() + assertEquals(2, exampleHost.children.size) + + val homePath = exampleHost.children.find { it.label == "/home" } + assertNotNull(homePath) + assertEquals(TopologyNodeType.PATH, homePath.type) + assertEquals(2, homePath.children.size) + } + + @Test + fun `should create activity nodes as leaves`() { + val deepLinks = listOf( + createDeepLink( + scheme = "https", + host = "example.com", + path = "/home", + activityName = "com.example.app.HomeActivity" + ) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val httpsScheme = result.tree.children.first() + val host = httpsScheme.children.first() + val path = host.children.first() + val activity = path.children.first() + + assertEquals(TopologyNodeType.ACTIVITY, activity.type) + assertEquals("HomeActivity", activity.label) + assertEquals("com.example.app.HomeActivity", activity.metadata.activityName) + assertTrue(activity.children.isEmpty()) + } + + @Test + fun `should handle deep links with no host`() { + val deepLinks = listOf( + createDeepLink(scheme = "myapp", host = null, path = null) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + assertEquals("(no host)", host.label) + } + + @Test + fun `should handle deep links with path prefix`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = null, pathPrefix = "/products/") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + val path = host.children.first() + assertEquals("/products/*", path.label) + } + + @Test + fun `should handle deep links with path pattern`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = null, pathPattern = "/items/.*") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + val path = host.children.first() + assertEquals("/items/.*", path.label) + } + + @Test + fun `should handle deep links with no path`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = null) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + val path = host.children.first() + assertEquals("/", path.label) + } + + @Test + fun `should include verification status from domain verification result`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true) + ) + val manifestInfo = createManifestInfo(deepLinks) + val domainVerification = DomainVerificationResult( + packageName = "com.example.app", + domains = listOf( + DomainVerificationInfo("example.com", DomainVerificationStatus.VERIFIED) + ) + ) + + val result = useCase(manifestInfo, domainVerification) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + assertEquals(DomainVerificationStatus.VERIFIED, host.metadata.verificationStatus) + } + + @Test + fun `should detect duplicate scheme with multiple activities handling same host`() { + val deepLinks = listOf( + createDeepLink( + scheme = "https", + host = "example.com", + path = "/home", + activityName = "com.example.app.HomeActivity" + ), + createDeepLink( + scheme = "https", + host = "example.com", + path = "/home", + activityName = "com.example.app.MainActivity" + ) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val duplicateInsights = result.insights.filter { it.category == InsightCategory.DUPLICATE_SCHEME } + assertTrue(duplicateInsights.isNotEmpty()) + assertEquals(InsightSeverity.WARNING, duplicateInsights.first().severity) + } + + @Test + fun `should detect unverified domains`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true) + ) + val manifestInfo = createManifestInfo(deepLinks) + val domainVerification = DomainVerificationResult( + packageName = "com.example.app", + domains = listOf( + DomainVerificationInfo("example.com", DomainVerificationStatus.NONE) + ) + ) + + val result = useCase(manifestInfo, domainVerification) + + val unverifiedInsights = result.insights.filter { it.category == InsightCategory.UNVERIFIED_DOMAIN } + assertTrue(unverifiedInsights.isNotEmpty()) + assertEquals(InsightSeverity.ERROR, unverifiedInsights.first().severity) + assertTrue(unverifiedInsights.first().title.contains("example.com")) + } + + @Test + fun `should not detect unverified domains when domain verification is null`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val unverifiedInsights = result.insights.filter { it.category == InsightCategory.UNVERIFIED_DOMAIN } + assertTrue(unverifiedInsights.isEmpty()) + } + + @Test + fun `should detect mixed verification states`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true), + createDeepLink(scheme = "https", host = "example.com", path = "/about", autoVerify = false) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val mixedInsights = result.insights.filter { it.category == InsightCategory.MIXED_VERIFICATION } + assertTrue(mixedInsights.isNotEmpty()) + assertEquals(InsightSeverity.WARNING, mixedInsights.first().severity) + } + + @Test + fun `should calculate correct stats`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", activityName = "com.example.HomeActivity"), + createDeepLink(scheme = "https", host = "test.com", path = "/login", activityName = "com.example.LoginActivity"), + createDeepLink(scheme = "myapp", host = "open", path = null, activityName = "com.example.HomeActivity") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + assertEquals(2, result.totalSchemes) + assertEquals(3, result.totalHosts) + assertEquals(2, result.totalPaths) + assertEquals(2, result.totalActivities) + } + + @Test + fun `should set autoVerify metadata on scheme nodes`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true) + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + assertTrue(scheme.metadata.autoVerify) + } + + @Test + fun `should set sample URI on host and path nodes`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val scheme = result.tree.children.first() + val host = scheme.children.first() + assertEquals("https://example.com", host.metadata.sampleUri) + + val path = host.children.first() + assertNotNull(path.metadata.sampleUri) + assertTrue(path.metadata.sampleUri!!.contains("example.com")) + } + + @Test + fun `should assign unique IDs to all nodes`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home"), + createDeepLink(scheme = "myapp", host = "open", path = "/main") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + val allIds = collectAllNodeIds(result.tree) + assertEquals(allIds.size, allIds.toSet().size, "All node IDs should be unique") + } + + @Test + fun `should handle large number of deep links`() { + val deepLinks = (1..100).map { i -> + createDeepLink( + scheme = if (i % 3 == 0) "myapp" else "https", + host = "host${i % 10}.com", + path = "/path/$i", + activityName = "com.example.Activity${i % 5}" + ) + } + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + assertEquals(2, result.totalSchemes) + assertTrue(result.tree.children.isNotEmpty()) + assertTrue(result.totalPaths > 0) + } + + @Test + fun `should not report verified domains as unverified`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home", autoVerify = true) + ) + val manifestInfo = createManifestInfo(deepLinks) + val domainVerification = DomainVerificationResult( + packageName = "com.example.app", + domains = listOf( + DomainVerificationInfo("example.com", DomainVerificationStatus.VERIFIED) + ) + ) + + val result = useCase(manifestInfo, domainVerification) + + val unverifiedInsights = result.insights.filter { it.category == InsightCategory.UNVERIFIED_DOMAIN } + assertTrue(unverifiedInsights.isEmpty()) + } + + @Test + fun `should set deep link count on root node`() { + val deepLinks = listOf( + createDeepLink(scheme = "https", host = "example.com", path = "/home"), + createDeepLink(scheme = "https", host = "example.com", path = "/about"), + createDeepLink(scheme = "myapp", host = "open", path = "/main") + ) + val manifestInfo = createManifestInfo(deepLinks) + + val result = useCase(manifestInfo, null) + + assertEquals(3, result.tree.metadata.deepLinkCount) + } + + // -- Helper functions -- + + private fun createManifestInfo( + deepLinks: List, + activities: List = emptyList() + ): ManifestInfo { + return ManifestInfo( + packageName = "com.example.app", + versionName = "1.0.0", + versionCode = 1, + activities = activities, + deepLinks = deepLinks + ) + } + + private fun createDeepLink( + scheme: String = "https", + host: String? = "example.com", + path: String? = "/home", + pathPrefix: String? = null, + pathPattern: String? = null, + activityName: String = "com.example.app.MainActivity", + autoVerify: Boolean = false + ): DeepLinkInfo { + return DeepLinkInfo( + scheme = scheme, + host = host, + path = path, + pathPrefix = pathPrefix, + pathPattern = pathPattern, + activityName = activityName, + autoVerify = autoVerify + ) + } + + private fun collectAllNodeIds(node: com.manjee.linkops.domain.model.TopologyNode): List { + val ids = mutableListOf(node.id) + node.children.forEach { child -> + ids.addAll(collectAllNodeIds(child)) + } + return ids + } +}