diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AasaParser.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AasaParser.kt new file mode 100644 index 0000000..0dfdafb --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/parser/AasaParser.kt @@ -0,0 +1,263 @@ +package com.manjee.linkops.data.parser + +import com.manjee.linkops.domain.model.AasaIssue +import com.manjee.linkops.domain.model.AasaPathComponent +import com.manjee.linkops.domain.model.AppLinkDetail +import com.manjee.linkops.domain.model.AppLinksSection +import com.manjee.linkops.domain.model.AppleAppSiteAssociation +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Parser for `apple-app-site-association` JSON. + * + * AASA has two coexisting schemas Apple still ships: + * - **Legacy** (pre-iOS 14): `appID` (singular) + `paths` (array of glob strings) + * - **iOS 14+**: `appIDs` (plural array) + `components` (array of objects with + * `/`, `?`, `#`, `exclude`, `caseSensitive`, `percentEncoded`) + * + * Both schemas can coexist inside the same `details` array; some real-world + * configs even mix legacy and modern entries to support older iOS versions. + * We don't pick one — we hand both forms back to the UI in [AppLinkDetail] so + * the developer can see exactly what's deployed and flag legacy-only entries. + * + * We hand-roll on JsonObject rather than declaring a fixed @Serializable shape + * because the spec lets keys appear in either schema and the JSON `/` key in + * components can't be a Kotlin identifier. + */ +class AasaParser { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parse(content: String): ParseResult { + val issues = mutableListOf() + + val root = try { + json.parseToJsonElement(content).jsonObject + } catch (e: Exception) { + return ParseResult.Error( + message = "Failed to parse JSON: ${e.message}", + cause = e, + issues = listOf( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.INVALID_JSON_SYNTAX, + message = "Invalid JSON syntax", + details = e.message + ) + ) + ) + } + + val applinks = root["applinks"]?.let { parseApplinks(it.jsonObject, issues) } + if (applinks == null) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.MISSING_APPLINKS, + message = "AASA file has no `applinks` section", + details = "Without applinks, iOS will not register any Universal Links for this domain." + ) + ) + } + + val hasWebcredentials = root["webcredentials"] != null + if (hasWebcredentials) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.INFO, + code = AasaIssue.AasaIssueCode.WEB_CREDENTIALS_PRESENT, + message = "Domain ships Shared Web Credentials (`webcredentials`)" + ) + ) + } + + val hasAppclips = root["appclips"] != null + if (hasAppclips) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.INFO, + code = AasaIssue.AasaIssueCode.APP_CLIPS_PRESENT, + message = "Domain ships App Clips (`appclips`)" + ) + ) + } + + return ParseResult.Success( + content = AppleAppSiteAssociation( + applinks = applinks, + hasWebcredentials = hasWebcredentials, + hasAppclips = hasAppclips + ), + issues = issues + ) + } + + private fun parseApplinks( + applinks: JsonObject, + issues: MutableList + ): AppLinksSection { + val apps = (applinks["apps"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?: emptyList() + + if (apps.isNotEmpty()) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.WARNING, + code = AasaIssue.AasaIssueCode.NON_EMPTY_APPS_ARRAY, + message = "`applinks.apps` should be an empty array", + details = "Apple has required this to be empty for years; values here are ignored by iOS." + ) + ) + } + + val detailsArray = applinks["details"] as? JsonArray + if (detailsArray == null || detailsArray.isEmpty()) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.EMPTY_DETAILS, + message = "`applinks.details` is missing or empty", + details = "iOS needs at least one entry in details to register Universal Links." + ) + ) + } + + val details = detailsArray + ?.mapNotNull { (it as? JsonObject)?.let { obj -> parseDetail(obj, issues) } } + ?: emptyList() + + return AppLinksSection(apps = apps, details = details) + } + + private fun parseDetail( + detail: JsonObject, + issues: MutableList + ): AppLinkDetail? { + val legacyAppId = (detail["appID"] as? JsonPrimitive)?.contentOrNull + val modernAppIds = (detail["appIDs"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?: emptyList() + + val appIds = when { + modernAppIds.isNotEmpty() -> modernAppIds + legacyAppId != null -> listOf(legacyAppId) + else -> emptyList() + } + if (appIds.isEmpty()) return null + + val usesLegacy = legacyAppId != null && modernAppIds.isEmpty() + if (usesLegacy) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.WARNING, + code = AasaIssue.AasaIssueCode.LEGACY_SCHEMA, + message = "Detail uses legacy `appID` + `paths` schema", + details = "Consider migrating to `appIDs` + `components` for iOS 14+ features (query, fragment, exclude)." + ) + ) + } + + appIds.forEach { id -> + if (!isLikelyAppId(id)) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.INVALID_APP_ID_FORMAT, + message = "App ID does not look like TEAM_ID.bundle.id format: $id", + details = "Expected something like ABCDE12345.com.example.app" + ) + ) + } + } + + val paths = (detail["paths"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?: emptyList() + + val components = (detail["components"] as? JsonArray) + ?.mapNotNull { (it as? JsonObject)?.let { obj -> parseComponent(obj) } } + ?: emptyList() + + if (paths.isEmpty() && components.isEmpty()) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.WARNING, + code = AasaIssue.AasaIssueCode.MISSING_PATHS_AND_COMPONENTS, + message = "Detail for ${appIds.first()} has neither `paths` nor `components`", + details = "iOS will not match any URL for this entry." + ) + ) + } + + return AppLinkDetail( + appIDs = appIds, + paths = paths, + components = components, + usesLegacySchema = usesLegacy + ) + } + + /** + * The `/` key is the canonical "path" field in iOS 14+ components but isn't + * a valid Kotlin identifier, so we read the JSON manually instead of using + * @Serializable. + */ + private fun parseComponent(component: JsonObject): AasaPathComponent? { + val path = (component["/"] as? JsonPrimitive)?.contentOrNull + val query = (component["?"] as? JsonPrimitive)?.contentOrNull + val fragment = (component["#"] as? JsonPrimitive)?.contentOrNull + val exclude = (component["exclude"] as? JsonPrimitive)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() + ?: false + val caseSensitive = (component["caseSensitive"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() + ?: true + val percentEncoded = (component["percentEncoded"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() + ?: true + + if (path == null && query == null && fragment == null) return null + + return AasaPathComponent( + path = path, + query = query, + fragment = fragment, + exclude = exclude, + caseSensitive = caseSensitive, + percentEncoded = percentEncoded + ) + } + + /** + * Cheap heuristic: TEAM_ID is 10 alphanumerics, then a dot, then a reverse-DNS + * bundle id. Bundle ids legally allow letters / digits / hyphens / dots so we + * only check the prefix shape and the dot — not perfect, but catches typos + * like a missing team prefix without rejecting valid configs. + */ + private fun isLikelyAppId(id: String): Boolean { + val firstDot = id.indexOf('.') + if (firstDot != 10) return false + val team = id.substring(0, 10) + return team.all { it.isLetterOrDigit() } && id.length > 11 + } + + sealed class ParseResult { + data class Success( + val content: AppleAppSiteAssociation, + val issues: List + ) : ParseResult() + + data class Error( + val message: String, + val cause: Throwable?, + val issues: List + ) : ParseResult() + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/AasaRepositoryImpl.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/AasaRepositoryImpl.kt new file mode 100644 index 0000000..c0857ec --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/data/repository/AasaRepositoryImpl.kt @@ -0,0 +1,154 @@ +package com.manjee.linkops.data.repository + +import com.manjee.linkops.data.parser.AasaParser +import com.manjee.linkops.domain.model.AasaIssue +import com.manjee.linkops.domain.model.AasaStatus +import com.manjee.linkops.domain.model.AasaValidation +import com.manjee.linkops.domain.repository.AasaRepository +import com.manjee.linkops.infrastructure.network.AasaClient +import com.manjee.linkops.infrastructure.network.AasaResponse + +/** + * Validates `apple-app-site-association` files. Mirrors AssetLinksRepositoryImpl — + * the issue list grows as we see warnings (wrong Content-Type, root fallback, + * non-empty apps array, etc.) and the final status is the worst of what we found. + */ +class AasaRepositoryImpl( + private val client: AasaClient, + private val parser: AasaParser +) : AasaRepository { + + override suspend fun validateAasa(domain: String): Result = runCatching { + when (val response = client.fetch(domain)) { + is AasaResponse.Success -> handleSuccess(domain, response) + is AasaResponse.NotFound -> AasaValidation( + domain = domain, + url = response.url, + status = AasaStatus.NOT_FOUND, + issues = listOf( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.FILE_NOT_FOUND, + message = "AASA file not found at either /.well-known/ or root path", + details = "Last tried: ${response.url}" + ) + ) + ) + is AasaResponse.Redirect -> AasaValidation( + domain = domain, + url = response.originalUrl, + status = AasaStatus.REDIRECT, + issues = listOf( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.REDIRECT_DETECTED, + message = "AASA was served via redirect", + details = "Apple does not follow redirects when fetching AASA. Redirect target: ${response.redirectUrl}" + ) + ) + ) + is AasaResponse.HttpError -> AasaValidation( + domain = domain, + url = response.url, + status = AasaStatus.NETWORK_ERROR, + issues = listOf( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = AasaIssue.AasaIssueCode.NETWORK_ERROR, + message = "HTTP error: ${response.statusCode}", + details = response.message + ) + ) + ) + is AasaResponse.NetworkError -> { + val code = when { + response.message.contains("timeout", ignoreCase = true) -> + AasaIssue.AasaIssueCode.NETWORK_TIMEOUT + response.message.contains("ssl", ignoreCase = true) || + response.message.contains("certificate", ignoreCase = true) -> + AasaIssue.AasaIssueCode.SSL_ERROR + else -> AasaIssue.AasaIssueCode.NETWORK_ERROR + } + AasaValidation( + domain = domain, + url = response.url, + status = AasaStatus.NETWORK_ERROR, + issues = listOf( + AasaIssue( + severity = AasaIssue.Severity.ERROR, + code = code, + message = "Network error: ${response.message}" + ) + ) + ) + } + } + } + + private fun handleSuccess( + domain: String, + response: AasaResponse.Success + ): AasaValidation { + val issues = mutableListOf() + + // AASA is allowed to be served as `application/json` OR `application/pkcs7-mime` + // (Apple historically signed AASAs). Anything else is a warning. + val isAcceptableContentType = response.contentType.let { ct -> + ct.contains("application/json", ignoreCase = true) || + ct.contains("application/pkcs7-mime", ignoreCase = true) + } + if (!isAcceptableContentType) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.WARNING, + code = AasaIssue.AasaIssueCode.WRONG_CONTENT_TYPE, + message = "Content-Type is not application/json", + details = "Received: ${response.contentType}" + ) + ) + } + + if (!response.fromWellKnown) { + issues.add( + AasaIssue( + severity = AasaIssue.Severity.WARNING, + code = AasaIssue.AasaIssueCode.ROOT_PATH_FALLBACK, + message = "AASA was found only at the legacy root path", + details = "iOS 13+ recommends serving it at /.well-known/apple-app-site-association." + ) + ) + } + + return when (val parseResult = parser.parse(response.content)) { + is AasaParser.ParseResult.Success -> { + issues.addAll(parseResult.issues) + val status = when { + issues.any { it.severity == AasaIssue.Severity.ERROR && it.code != AasaIssue.AasaIssueCode.MISSING_APPLINKS } -> + AasaStatus.INVALID_JSON + parseResult.content.applinks == null -> AasaStatus.NO_APPLINKS_SECTION + else -> AasaStatus.VALID + } + AasaValidation( + domain = domain, + url = response.finalUrl, + status = status, + issues = issues, + content = parseResult.content, + rawJson = response.content, + servedFromWellKnown = response.fromWellKnown + ) + } + is AasaParser.ParseResult.Error -> { + issues.addAll(parseResult.issues) + AasaValidation( + domain = domain, + url = response.finalUrl, + status = AasaStatus.INVALID_JSON, + issues = issues, + rawJson = response.content, + servedFromWellKnown = response.fromWellKnown + ) + } + } + } +} 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 94fe827..8188a2d 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/di/AppContainer.kt @@ -7,6 +7,7 @@ import com.manjee.linkops.data.mapper.DeviceMapper import com.manjee.linkops.data.mapper.ParameterSubstituter import com.manjee.linkops.data.mapper.ScenarioMapper import com.manjee.linkops.data.parser.AmStartOutputParser +import com.manjee.linkops.data.parser.AasaParser import com.manjee.linkops.data.parser.AssetLinksParser import com.manjee.linkops.data.parser.DumpsysParser import com.manjee.linkops.data.parser.FingerprintParser @@ -15,6 +16,7 @@ import com.manjee.linkops.data.parser.IntentFilterParser import com.manjee.linkops.data.parser.IntentPayloadParser import com.manjee.linkops.data.parser.LogcatParser import com.manjee.linkops.data.parser.ManifestParser +import com.manjee.linkops.data.repository.AasaRepositoryImpl import com.manjee.linkops.data.repository.AppLinkRepositoryImpl import com.manjee.linkops.data.repository.AssetLinksRepositoryImpl import com.manjee.linkops.data.repository.BatchTestRepositoryImpl @@ -28,6 +30,7 @@ import com.manjee.linkops.data.repository.ManifestRepositoryImpl import com.manjee.linkops.data.repository.SettingsRepositoryImpl import com.manjee.linkops.data.repository.VerificationDiagnosticsRepositoryImpl import com.manjee.linkops.data.strategy.AdbCommandStrategyFactory +import com.manjee.linkops.domain.repository.AasaRepository import com.manjee.linkops.domain.repository.AppLinkRepository import com.manjee.linkops.domain.repository.AssetLinksRepository import com.manjee.linkops.domain.repository.BatchTestRepository @@ -51,6 +54,7 @@ import com.manjee.linkops.domain.usecase.device.DetectDevicesUseCase import com.manjee.linkops.domain.usecase.device.RefreshDevicesUseCase import com.manjee.linkops.domain.usecase.diagnostics.AnalyzeVerificationUseCase import com.manjee.linkops.domain.usecase.diagnostics.DetectCollisionsUseCase +import com.manjee.linkops.domain.usecase.diagnostics.ValidateAasaUseCase import com.manjee.linkops.domain.usecase.diagnostics.ValidateAssetLinksUseCase import com.manjee.linkops.domain.usecase.favorite.AddFavoriteUseCase import com.manjee.linkops.domain.usecase.favorite.ObserveFavoritesUseCase @@ -73,6 +77,7 @@ import com.manjee.linkops.domain.usecase.manifest.TestDeepLinkUseCase import com.manjee.linkops.domain.model.IntentFiredEvent import com.manjee.linkops.infrastructure.adb.AdbBinaryManager import com.manjee.linkops.infrastructure.adb.AdbShellExecutor +import com.manjee.linkops.infrastructure.network.AasaClient import com.manjee.linkops.infrastructure.network.AssetLinksClient import com.manjee.linkops.infrastructure.qr.QrCodeGenerator import com.manjee.linkops.infrastructure.server.AssetLinksServer @@ -105,6 +110,10 @@ object AppContainer { AssetLinksClient() } + private val aasaClient: AasaClient by lazy { + AasaClient() + } + // Infrastructure - Server private val assetLinksServer: AssetLinksServer by lazy { AssetLinksServer() @@ -127,6 +136,10 @@ object AppContainer { AssetLinksParser() } + private val aasaParser: AasaParser by lazy { + AasaParser() + } + private val manifestParser: ManifestParser by lazy { ManifestParser() } @@ -196,6 +209,10 @@ object AppContainer { AssetLinksRepositoryImpl(assetLinksClient, assetLinksParser) } + val aasaRepository: AasaRepository by lazy { + AasaRepositoryImpl(aasaClient, aasaParser) + } + val manifestRepository: ManifestRepository by lazy { ManifestRepositoryImpl(adbShellExecutor, manifestParser) } @@ -273,6 +290,10 @@ object AppContainer { ValidateAssetLinksUseCase(assetLinksRepository) } + val validateAasaUseCase: ValidateAasaUseCase by lazy { + ValidateAasaUseCase(aasaRepository) + } + val analyzeVerificationUseCase: AnalyzeVerificationUseCase by lazy { AnalyzeVerificationUseCase(verificationDiagnosticsRepository) } diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppleAppSiteAssociation.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppleAppSiteAssociation.kt new file mode 100644 index 0000000..bea3125 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/model/AppleAppSiteAssociation.kt @@ -0,0 +1,130 @@ +package com.manjee.linkops.domain.model + +/** + * Result of validating an `apple-app-site-association` (AASA) file for a domain. + * Mirrors [AssetLinksValidation] so the UI can render Android and iOS results + * with the same shape. + */ +data class AasaValidation( + val domain: String, + /** + * The exact URL that returned the AASA — useful because there are two valid + * locations and the user needs to know which one was found. + */ + val url: String, + val status: AasaStatus, + val issues: List = emptyList(), + val content: AppleAppSiteAssociation? = null, + val rawJson: String? = null, + /** + * True when the file was served from `/.well-known/apple-app-site-association` + * (the iOS 13+ recommended location). False when only the legacy root path worked. + */ + val servedFromWellKnown: Boolean = false +) + +/** + * Parsed AASA payload, normalized across the legacy and iOS 14+ schemas. + * + * MVP scope is `applinks` only — `webcredentials` and `appclips` are surfaced + * as presence flags so the UI can hint at related capabilities without us + * promising to validate them yet. + */ +data class AppleAppSiteAssociation( + val applinks: AppLinksSection? = null, + val hasWebcredentials: Boolean = false, + val hasAppclips: Boolean = false +) + +/** + * The `applinks` block. iOS allows two coexisting shapes; this is the union. + */ +data class AppLinksSection( + /** + * Legacy `apps: []` array. Apple has required this to be empty for years + * but old configs still ship with values — surfaced for visibility. + */ + val apps: List = emptyList(), + val details: List +) + +/** + * Single entry inside `applinks.details`. + * + * Both schemas are normalized into this: + * - Legacy: `appID` (singular) + `paths` + * - iOS 14+: `appIDs` (plural) + `components` + * + * [usesLegacySchema] preserves the source so the UI can flag old configurations + * worth migrating. + */ +data class AppLinkDetail( + val appIDs: List, + val paths: List = emptyList(), + val components: List = emptyList(), + val usesLegacySchema: Boolean = false +) { + val hasAnyPattern: Boolean + get() = paths.isNotEmpty() || components.isNotEmpty() +} + +/** + * iOS 14+ path component. Only the most common keys are modeled — the JSON spec + * is open-ended and the goal here is to surface what the developer wrote, not + * to evaluate it. + */ +data class AasaPathComponent( + val path: String? = null, + val query: String? = null, + val fragment: String? = null, + val exclude: Boolean = false, + val caseSensitive: Boolean = true, + val percentEncoded: Boolean = true +) + +enum class AasaStatus { + VALID, + INVALID_JSON, + NOT_FOUND, + REDIRECT, + NETWORK_ERROR, + INVALID_CONTENT_TYPE, + /** + * The file exists and is valid JSON but contains no `applinks` section — + * possible if the domain only ships `webcredentials` or `appclips`. + */ + NO_APPLINKS_SECTION +} + +data class AasaIssue( + val severity: Severity, + val code: AasaIssueCode, + val message: String, + val details: String? = null +) { + enum class Severity { ERROR, WARNING, INFO } + + enum class AasaIssueCode { + // Errors + FILE_NOT_FOUND, + INVALID_JSON_SYNTAX, + MISSING_APPLINKS, + EMPTY_DETAILS, + INVALID_APP_ID_FORMAT, + NETWORK_TIMEOUT, + NETWORK_ERROR, + SSL_ERROR, + + // Warnings + REDIRECT_DETECTED, + WRONG_CONTENT_TYPE, + ROOT_PATH_FALLBACK, + MISSING_PATHS_AND_COMPONENTS, + LEGACY_SCHEMA, + NON_EMPTY_APPS_ARRAY, + + // Info + WEB_CREDENTIALS_PRESENT, + APP_CLIPS_PRESENT + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/AasaRepository.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/AasaRepository.kt new file mode 100644 index 0000000..ce115bd --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/repository/AasaRepository.kt @@ -0,0 +1,11 @@ +package com.manjee.linkops.domain.repository + +import com.manjee.linkops.domain.model.AasaValidation + +interface AasaRepository { + /** + * Fetches the AASA file for [domain] (trying `/.well-known/` first then the + * legacy root path) and validates its content. + */ + suspend fun validateAasa(domain: String): Result +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/ValidateAasaUseCase.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/ValidateAasaUseCase.kt new file mode 100644 index 0000000..7d1f31b --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/domain/usecase/diagnostics/ValidateAasaUseCase.kt @@ -0,0 +1,12 @@ +package com.manjee.linkops.domain.usecase.diagnostics + +import com.manjee.linkops.domain.model.AasaValidation +import com.manjee.linkops.domain.repository.AasaRepository + +class ValidateAasaUseCase( + private val aasaRepository: AasaRepository +) { + suspend operator fun invoke(domain: String): Result { + return aasaRepository.validateAasa(domain) + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/network/AasaClient.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/network/AasaClient.kt new file mode 100644 index 0000000..af00392 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/infrastructure/network/AasaClient.kt @@ -0,0 +1,123 @@ +package com.manjee.linkops.infrastructure.network + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* + +/** + * HTTP client for fetching `apple-app-site-association` (AASA) files. + * + * Apple supports two locations: + * 1. `https:///.well-known/apple-app-site-association` (iOS 13+ recommended) + * 2. `https:///apple-app-site-association` (legacy) + * + * We try the well-known path first. Only if it returns 404 do we fall back to + * the root path — any other failure on the well-known path is reported as-is + * because falling back would mask real problems (e.g. an SSL error at the + * domain, not a missing file). + */ +class AasaClient { + private val httpClient = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 10_000 + connectTimeoutMillis = 5_000 + socketTimeoutMillis = 10_000 + } + followRedirects = false + } + + suspend fun fetch(domain: String): AasaResponse { + val wellKnownResult = fetchOne(buildUrl(domain, wellKnown = true), wellKnown = true) + if (wellKnownResult !is AasaResponse.NotFound) return wellKnownResult + + // Only fall back when the well-known location is genuinely 404 — any + // other status is a real signal we want to preserve. + return fetchOne(buildUrl(domain, wellKnown = false), wellKnown = false) + } + + private suspend fun fetchOne(url: String, wellKnown: Boolean): AasaResponse { + return try { + val response: HttpResponse = httpClient.get(url) { + header(HttpHeaders.Accept, "application/json") + header(HttpHeaders.UserAgent, "LinkOps/1.0") + } + + when (response.status) { + HttpStatusCode.OK -> AasaResponse.Success( + content = response.bodyAsText(), + contentType = response.contentType()?.toString() ?: "", + finalUrl = url, + fromWellKnown = wellKnown + ) + HttpStatusCode.NotFound -> AasaResponse.NotFound(url) + HttpStatusCode.MovedPermanently, + HttpStatusCode.Found, + HttpStatusCode.TemporaryRedirect, + HttpStatusCode.PermanentRedirect -> AasaResponse.Redirect( + originalUrl = url, + redirectUrl = response.headers[HttpHeaders.Location] + ) + else -> AasaResponse.HttpError( + statusCode = response.status.value, + message = response.status.description, + url = url + ) + } + } catch (e: HttpRequestTimeoutException) { + AasaResponse.NetworkError( + message = "Request timed out", + url = url, + cause = e + ) + } catch (e: Exception) { + AasaResponse.NetworkError( + message = e.message ?: "Unknown network error", + url = url, + cause = e + ) + } + } + + private fun buildUrl(domain: String, wellKnown: Boolean): String { + return if (wellKnown) { + "https://$domain/.well-known/apple-app-site-association" + } else { + "https://$domain/apple-app-site-association" + } + } + + fun close() { + httpClient.close() + } +} + +sealed class AasaResponse { + data class Success( + val content: String, + val contentType: String, + val finalUrl: String, + val fromWellKnown: Boolean + ) : AasaResponse() + + data class NotFound(val url: String) : AasaResponse() + + data class Redirect( + val originalUrl: String, + val redirectUrl: String? + ) : AasaResponse() + + data class HttpError( + val statusCode: Int, + val message: String, + val url: String + ) : AasaResponse() + + data class NetworkError( + val message: String, + val url: String, + val cause: Throwable? = null + ) : AasaResponse() +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt index e34c77b..b7207eb 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsScreen.kt @@ -108,15 +108,21 @@ private fun AssetLinksContent( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "AssetLinks Diagnostics", + text = "Domain Diagnostics", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold ) + PlatformToggle( + selected = uiState.platform, + onSelect = { viewModel.switchPlatform(it) } + ) + HorizontalDivider() DomainInputSection( domain = uiState.domain, + platform = uiState.platform, onDomainChange = { viewModel.updateDomain(it) }, onValidate = { viewModel.validateDomain() }, isLoading = uiState.isLoading, @@ -127,7 +133,7 @@ private fun AssetLinksContent( HistorySection( history = uiState.history, - onSelectDomain = { viewModel.validateFromHistory(it) }, + onSelectItem = { viewModel.validateFromHistory(it) }, onClear = { viewModel.clearHistory() } ) } @@ -141,7 +147,9 @@ private fun AssetLinksContent( .padding(16.dp) ) { ResultsPanel( + platform = uiState.platform, validation = uiState.validation, + aasaValidation = uiState.aasaValidation, isLoading = uiState.isLoading, onClear = { viewModel.clearResult() } ) @@ -149,6 +157,25 @@ private fun AssetLinksContent( } } +@Composable +private fun PlatformToggle( + selected: DiagnosticsPlatform, + onSelect: (DiagnosticsPlatform) -> Unit +) { + val entries = DiagnosticsPlatform.entries + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + entries.forEachIndexed { index, platform -> + SegmentedButton( + selected = platform == selected, + onClick = { onSelect(platform) }, + shape = SegmentedButtonDefaults.itemShape(index, entries.size) + ) { + Text(platform.title) + } + } + } +} + // -- Collision Detector Tab -- /** @@ -622,6 +649,7 @@ private fun CollisionRegistrationItem( @Composable private fun DomainInputSection( domain: String, + platform: DiagnosticsPlatform, onDomainChange: (String) -> Unit, onValidate: () -> Unit, isLoading: Boolean, @@ -668,16 +696,25 @@ private fun DomainInputSection( } else null ) + val (buttonLabel, helperText) = when (platform) { + DiagnosticsPlatform.ANDROID -> + "Validate assetlinks.json" to + "Fetches https:///.well-known/assetlinks.json" + DiagnosticsPlatform.IOS -> + "Validate apple-app-site-association" to + "Tries /.well-known/apple-app-site-association first, then root" + } + Button( onClick = onValidate, enabled = !isLoading && domain.isNotBlank(), modifier = Modifier.fillMaxWidth() ) { - Text("Validate assetlinks.json") + Text(buttonLabel) } Text( - text = "Fetches https:///.well-known/assetlinks.json", + text = helperText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -690,7 +727,7 @@ private fun DomainInputSection( @Composable private fun HistorySection( history: List, - onSelectDomain: (String) -> Unit, + onSelectItem: (ValidationHistoryItem) -> Unit, onClear: () -> Unit ) { Column( @@ -729,7 +766,7 @@ private fun HistorySection( items(history) { item -> HistoryItem( item = item, - onClick = { onSelectDomain(item.domain) } + onClick = { onSelectItem(item) } ) } } @@ -745,12 +782,19 @@ private fun HistoryItem( item: ValidationHistoryItem, onClick: () -> Unit ) { - val statusColor = when (item.status) { - ValidationStatus.VALID -> LinkOpsColors.Success - ValidationStatus.NOT_FOUND -> LinkOpsColors.Error - ValidationStatus.INVALID_JSON -> LinkOpsColors.Error - ValidationStatus.REDIRECT -> LinkOpsColors.Warning - else -> LinkOpsColors.Unknown + val statusColor = when (item.platform) { + DiagnosticsPlatform.ANDROID -> when (item.androidStatus) { + ValidationStatus.VALID -> LinkOpsColors.Success + ValidationStatus.NOT_FOUND, ValidationStatus.INVALID_JSON -> LinkOpsColors.Error + ValidationStatus.REDIRECT, ValidationStatus.INVALID_CONTENT_TYPE -> LinkOpsColors.Warning + else -> LinkOpsColors.Unknown + } + DiagnosticsPlatform.IOS -> when (item.iosStatus) { + AasaStatus.VALID -> LinkOpsColors.Success + AasaStatus.NOT_FOUND, AasaStatus.INVALID_JSON, AasaStatus.REDIRECT -> LinkOpsColors.Error + AasaStatus.INVALID_CONTENT_TYPE, AasaStatus.NO_APPLINKS_SECTION -> LinkOpsColors.Warning + else -> LinkOpsColors.Unknown + } } Card( @@ -772,19 +816,40 @@ private fun HistoryItem( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f) ) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = item.platform.title, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } /** - * Results panel + * Results panel — branches on platform but shares the empty/loading scaffold. */ @Composable private fun ResultsPanel( + platform: DiagnosticsPlatform, validation: AssetLinksValidation?, + aasaValidation: AasaValidation?, isLoading: Boolean, onClear: () -> Unit ) { + val hasResult = when (platform) { + DiagnosticsPlatform.ANDROID -> validation != null + DiagnosticsPlatform.IOS -> aasaValidation != null + } + Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp) @@ -800,50 +865,49 @@ private fun ResultsPanel( fontWeight = FontWeight.Bold ) - if (validation != null) { + if (hasResult) { IconButton(onClick = onClear) { Icon(Icons.Default.Clear, contentDescription = "Clear") } } } - if (validation == null && !isLoading) { + if (!hasResult && !isLoading) { EmptyState( title = "No validation results", - description = "Enter a domain and click Validate", + description = when (platform) { + DiagnosticsPlatform.ANDROID -> "Enter a domain and click Validate" + DiagnosticsPlatform.IOS -> "Enter a domain and validate the iOS AASA file" + }, icon = Icons.Default.Search ) - } else if (validation != null) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Status card - item { - ValidationStatusCard(validation) - } + } else { + when (platform) { + DiagnosticsPlatform.ANDROID -> validation?.let { AndroidResults(it) } + DiagnosticsPlatform.IOS -> aasaValidation?.let { IosResults(it) } + } + } + } +} - // Issues - if (validation.issues.isNotEmpty()) { - item { - IssuesCard(validation.issues) - } - } +@Composable +private fun AndroidResults(validation: AssetLinksValidation) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { ValidationStatusCard(validation) } - // Content - if (validation.content != null) { - item { - ContentCard(validation.content) - } - } + if (validation.issues.isNotEmpty()) { + item { IssuesCard(validation.issues) } + } - // Raw JSON - if (validation.rawJson != null) { - item { - RawJsonCard(validation.rawJson) - } - } - } + if (validation.content != null) { + item { ContentCard(validation.content) } + } + + if (validation.rawJson != null) { + item { RawJsonCard(validation.rawJson) } } } } @@ -1124,3 +1188,304 @@ private fun RawJsonCard(rawJson: String) { } } } + +// -- iOS (AASA) results -- + +@Composable +private fun IosResults(validation: AasaValidation) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { AasaStatusCard(validation) } + + if (validation.issues.isNotEmpty()) { + item { AasaIssuesCard(validation.issues) } + } + + validation.content?.let { content -> + content.applinks?.let { applinks -> + item { AasaApplinksCard(applinks) } + } + } + + if (validation.rawJson != null) { + item { RawJsonCard(validation.rawJson) } + } + } +} + +@Composable +private fun AasaStatusCard(validation: AasaValidation) { + val (statusText, statusColor, statusIcon) = when (validation.status) { + AasaStatus.VALID -> Triple("Valid", LinkOpsColors.Success, "✓") + AasaStatus.INVALID_JSON -> Triple("Invalid JSON", LinkOpsColors.Error, "✗") + AasaStatus.NOT_FOUND -> Triple("Not Found", LinkOpsColors.Error, "✗") + AasaStatus.REDIRECT -> Triple("Redirect (not allowed)", LinkOpsColors.Error, "✗") + AasaStatus.NETWORK_ERROR -> Triple("Network Error", LinkOpsColors.Error, "✗") + AasaStatus.INVALID_CONTENT_TYPE -> Triple("Wrong Content-Type", LinkOpsColors.Warning, "⚠") + AasaStatus.NO_APPLINKS_SECTION -> Triple("No applinks section", LinkOpsColors.Warning, "⚠") + } + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = statusIcon, + color = statusColor, + style = MaterialTheme.typography.headlineSmall + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = statusText, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = statusColor + ) + Text( + text = validation.domain, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Text( + text = validation.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (validation.content != null) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CapabilityChip( + label = "applinks", + present = validation.content.applinks != null + ) + CapabilityChip( + label = "webcredentials", + present = validation.content.hasWebcredentials + ) + CapabilityChip( + label = "appclips", + present = validation.content.hasAppclips + ) + } + } + } + } +} + +@Composable +private fun CapabilityChip(label: String, present: Boolean) { + val bg = if (present) LinkOpsColors.SuccessLight else MaterialTheme.colorScheme.surfaceVariant + val fg = if (present) LinkOpsColors.Success else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = Modifier + .background(bg, RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = fg, + fontWeight = FontWeight.Medium + ) + } +} + +@Composable +private fun AasaIssuesCard(issues: List) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Issues (${issues.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + issues.forEach { issue -> AasaIssueItem(issue) } + } + } +} + +@Composable +private fun AasaIssueItem(issue: AasaIssue) { + val (icon, color) = when (issue.severity) { + AasaIssue.Severity.ERROR -> "✗" to LinkOpsColors.Error + AasaIssue.Severity.WARNING -> "⚠" to LinkOpsColors.Warning + AasaIssue.Severity.INFO -> "ℹ" to LinkOpsColors.Info + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(color.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) + .padding(12.dp), + verticalAlignment = Alignment.Top + ) { + Text(text = icon, color = color) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = issue.message, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + if (issue.details != null) { + Text( + text = issue.details, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun AasaApplinksCard(applinks: AppLinksSection) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = "applinks.details (${applinks.details.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + applinks.details.forEachIndexed { index, detail -> + AasaDetailItem(index + 1, detail) + } + } + } +} + +@Composable +private fun AasaDetailItem(index: Int, detail: AppLinkDetail) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(8.dp) + ) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "Detail #$index", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + if (detail.usesLegacySchema) { + Box( + modifier = Modifier + .background(LinkOpsColors.WarningLight, RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "Legacy", + style = MaterialTheme.typography.labelSmall, + color = LinkOpsColors.Warning, + fontWeight = FontWeight.Medium + ) + } + } + } + + Text( + text = if (detail.usesLegacySchema) "appID" else "appIDs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + detail.appIDs.forEach { id -> + SelectionContainer { + Text( + text = id, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + if (detail.paths.isNotEmpty()) { + Text( + text = "paths (legacy):", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + detail.paths.forEach { path -> + Text( + text = path, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + if (detail.components.isNotEmpty()) { + Text( + text = "components:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + detail.components.forEach { component -> + AasaComponentRow(component) + } + } + + if (!detail.hasAnyPattern) { + Text( + text = "No paths or components — iOS will not match any URL.", + style = MaterialTheme.typography.bodySmall, + color = LinkOpsColors.Warning, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@Composable +private fun AasaComponentRow(component: AasaPathComponent) { + val parts = buildList { + component.path?.let { add("/ = $it") } + component.query?.let { add("? = $it") } + component.fragment?.let { add("# = $it") } + if (component.exclude) add("exclude") + if (!component.caseSensitive) add("caseSensitive=false") + if (!component.percentEncoded) add("percentEncoded=false") + } + Text( + text = parts.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + color = if (component.exclude) LinkOpsColors.Warning else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp) + ) +} diff --git a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt index 35378f9..6f8d02d 100644 --- a/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt +++ b/composeApp/src/jvmMain/kotlin/com/manjee/linkops/ui/screen/diagnostics/DiagnosticsViewModel.kt @@ -1,6 +1,8 @@ package com.manjee.linkops.ui.screen.diagnostics import com.manjee.linkops.di.AppContainer +import com.manjee.linkops.domain.model.AasaStatus +import com.manjee.linkops.domain.model.AasaValidation import com.manjee.linkops.domain.model.AssetLinksValidation import com.manjee.linkops.domain.model.CollisionReport import com.manjee.linkops.domain.model.Device @@ -16,14 +18,25 @@ enum class DiagnosticsTab(val title: String) { COLLISION_DETECTOR("Collision Detector") } +/** + * Which platform's well-known file the user is currently validating. + * The two share the same domain input box; only the fetcher and the result panel differ. + */ +enum class DiagnosticsPlatform(val title: String) { + ANDROID("Android"), + IOS("iOS") +} + /** * UI State for Diagnostics Screen */ data class DiagnosticsUiState( val activeTab: DiagnosticsTab = DiagnosticsTab.ASSET_LINKS, + val platform: DiagnosticsPlatform = DiagnosticsPlatform.ANDROID, val domain: String = "", val isLoading: Boolean = false, val validation: AssetLinksValidation? = null, + val aasaValidation: AasaValidation? = null, val error: String? = null, val history: List = emptyList(), val collisionReport: CollisionReport? = null, @@ -33,11 +46,17 @@ data class DiagnosticsUiState( ) /** - * History item for past validations + * History item for past validations. + * + * Holds either an Android [ValidationStatus] or an iOS [AasaStatus] — exactly one + * is non-null based on which platform produced the entry. Kept as a single class + * so the recent-list UI can render both with one row component. */ data class ValidationHistoryItem( val domain: String, - val status: ValidationStatus, + val platform: DiagnosticsPlatform, + val androidStatus: ValidationStatus? = null, + val iosStatus: AasaStatus? = null, val timestamp: Long = System.currentTimeMillis() ) @@ -59,6 +78,21 @@ class DiagnosticsViewModel { _uiState.update { it.copy(activeTab = tab) } } + /** + * Switch validation platform. Clears the previously shown result so the user + * isn't comparing apples to oranges in the result panel. + */ + fun switchPlatform(platform: DiagnosticsPlatform) { + _uiState.update { + it.copy( + platform = platform, + validation = null, + aasaValidation = null, + error = null + ) + } + } + /** * Update domain input */ @@ -67,7 +101,7 @@ class DiagnosticsViewModel { } /** - * Validate assetlinks.json for the entered domain + * Validate the entered domain for the currently selected platform. */ fun validateDomain() { val domain = _uiState.value.domain.trim() @@ -76,15 +110,22 @@ class DiagnosticsViewModel { return } + when (_uiState.value.platform) { + DiagnosticsPlatform.ANDROID -> validateAndroid(domain) + DiagnosticsPlatform.IOS -> validateIos(domain) + } + } + + private fun validateAndroid(domain: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } AppContainer.validateAssetLinksUseCase(domain) .onSuccess { validation -> - // Add to history val historyItem = ValidationHistoryItem( domain = validation.domain, - status = validation.status + platform = DiagnosticsPlatform.ANDROID, + androidStatus = validation.status ) val newHistory = listOf(historyItem) + _uiState.value.history.take(9) @@ -92,6 +133,40 @@ class DiagnosticsViewModel { it.copy( isLoading = false, validation = validation, + aasaValidation = null, + history = newHistory + ) + } + } + .onFailure { error -> + _uiState.update { + it.copy( + isLoading = false, + error = error.message ?: "Validation failed" + ) + } + } + } + } + + private fun validateIos(domain: String) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + AppContainer.validateAasaUseCase(domain) + .onSuccess { validation -> + val historyItem = ValidationHistoryItem( + domain = validation.domain, + platform = DiagnosticsPlatform.IOS, + iosStatus = validation.status + ) + val newHistory = listOf(historyItem) + _uiState.value.history.take(9) + + _uiState.update { + it.copy( + isLoading = false, + aasaValidation = validation, + validation = null, history = newHistory ) } @@ -108,10 +183,16 @@ class DiagnosticsViewModel { } /** - * Validate a domain from history + * Replays a history entry on its original platform — switching the platform + * toggle and re-running the matching validator. */ - fun validateFromHistory(domain: String) { - _uiState.update { it.copy(domain = domain) } + fun validateFromHistory(item: ValidationHistoryItem) { + _uiState.update { + it.copy( + domain = item.domain, + platform = item.platform + ) + } validateDomain() } @@ -119,7 +200,7 @@ class DiagnosticsViewModel { * Clear current validation result */ fun clearResult() { - _uiState.update { it.copy(validation = null, error = null) } + _uiState.update { it.copy(validation = null, aasaValidation = null, error = null) } } /** diff --git a/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AasaParserTest.kt b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AasaParserTest.kt new file mode 100644 index 0000000..542273c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/com/manjee/linkops/data/parser/AasaParserTest.kt @@ -0,0 +1,178 @@ +package com.manjee.linkops.data.parser + +import com.manjee.linkops.domain.model.AasaIssue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +class AasaParserTest { + + private val parser = AasaParser() + + @Test + fun `parses iOS 14+ schema with appIDs and components`() { + val json = """ + { + "applinks": { + "details": [ + { + "appIDs": ["ABCDE12345.com.example.app"], + "components": [ + { "/": "/promo/*", "?": { "campaign": "?*" } }, + { "/": "/admin", "exclude": true } + ] + } + ] + } + } + """.trimIndent() + + val result = parser.parse(json) as AasaParser.ParseResult.Success + val applinks = assertNotNull(result.content.applinks) + assertEquals(1, applinks.details.size) + + val detail = applinks.details.first() + assertEquals(listOf("ABCDE12345.com.example.app"), detail.appIDs) + assertEquals(2, detail.components.size) + assertFalse(detail.usesLegacySchema, "appIDs schema should not be flagged as legacy") + + val excludeComponent = detail.components.last() + assertTrue(excludeComponent.exclude) + } + + @Test + fun `parses legacy schema with appID and paths and flags as legacy`() { + val json = """ + { + "applinks": { + "apps": [], + "details": [ + { "appID": "ABCDE12345.com.example.app", "paths": ["/promo/*", "/help"] } + ] + } + } + """.trimIndent() + + val result = parser.parse(json) as AasaParser.ParseResult.Success + val detail = result.content.applinks!!.details.first() + + assertEquals(listOf("ABCDE12345.com.example.app"), detail.appIDs) + assertEquals(listOf("/promo/*", "/help"), detail.paths) + assertTrue(detail.usesLegacySchema, "Legacy appID detail should be flagged") + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.LEGACY_SCHEMA }) + } + + @Test + fun `flags missing applinks section`() { + val json = """{ "webcredentials": { "apps": ["ABCDE12345.com.example.app"] } }""" + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertNull(result.content.applinks) + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.MISSING_APPLINKS }) + assertTrue(result.content.hasWebcredentials) + assertFalse(result.content.hasAppclips) + } + + @Test + fun `flags empty details array`() { + val json = """{ "applinks": { "details": [] } }""" + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.EMPTY_DETAILS }) + } + + @Test + fun `flags non-empty applinks apps array`() { + val json = """ + { + "applinks": { + "apps": ["ABCDE12345.com.example.app"], + "details": [ + { "appIDs": ["ABCDE12345.com.example.app"], "components": [{"/": "*"}] } + ] + } + } + """.trimIndent() + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.NON_EMPTY_APPS_ARRAY }) + } + + @Test + fun `flags missing paths and components`() { + val json = """ + { + "applinks": { + "details": [ { "appIDs": ["ABCDE12345.com.example.app"] } ] + } + } + """.trimIndent() + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.MISSING_PATHS_AND_COMPONENTS }) + } + + @Test + fun `flags malformed app ID`() { + val json = """ + { + "applinks": { + "details": [ + { "appIDs": ["just-the-bundle-no-team"], "paths": ["*"] } + ] + } + } + """.trimIndent() + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.INVALID_APP_ID_FORMAT }) + } + + @Test + fun `accepts mixed legacy and modern entries in same details array`() { + val json = """ + { + "applinks": { + "details": [ + { "appID": "ABCDE12345.com.example.app", "paths": ["/legacy"] }, + { "appIDs": ["ABCDE12345.com.example.app"], "components": [{"/": "/modern"}] } + ] + } + } + """.trimIndent() + val result = parser.parse(json) as AasaParser.ParseResult.Success + + val details = result.content.applinks!!.details + assertEquals(2, details.size) + assertTrue(details[0].usesLegacySchema) + assertFalse(details[1].usesLegacySchema) + } + + @Test + fun `surfaces appclips presence`() { + val json = """ + { + "applinks": { + "details": [ + { "appIDs": ["ABCDE12345.com.example.app"], "components": [{"/": "*"}] } + ] + }, + "appclips": { "apps": ["ABCDE12345.com.example.AppClip"] } + } + """.trimIndent() + val result = parser.parse(json) as AasaParser.ParseResult.Success + + assertTrue(result.content.hasAppclips) + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.APP_CLIPS_PRESENT }) + } + + @Test + fun `returns Error for malformed JSON`() { + val result = parser.parse("{ this is not json") + assertTrue(result is AasaParser.ParseResult.Error) + assertTrue(result.issues.any { it.code == AasaIssue.AasaIssueCode.INVALID_JSON_SYNTAX }) + } +}