Skip to content

Commit 15cf565

Browse files
authored
feat(diagnostics): iOS Universal Links validation (apple-app-site-association)
2 parents 07b858f + 073ad75 commit 15cf565

10 files changed

Lines changed: 1391 additions & 53 deletions

File tree

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package com.manjee.linkops.data.parser
2+
3+
import com.manjee.linkops.domain.model.AasaIssue
4+
import com.manjee.linkops.domain.model.AasaPathComponent
5+
import com.manjee.linkops.domain.model.AppLinkDetail
6+
import com.manjee.linkops.domain.model.AppLinksSection
7+
import com.manjee.linkops.domain.model.AppleAppSiteAssociation
8+
import kotlinx.serialization.json.Json
9+
import kotlinx.serialization.json.JsonArray
10+
import kotlinx.serialization.json.JsonObject
11+
import kotlinx.serialization.json.JsonPrimitive
12+
import kotlinx.serialization.json.contentOrNull
13+
import kotlinx.serialization.json.jsonArray
14+
import kotlinx.serialization.json.jsonObject
15+
import kotlinx.serialization.json.jsonPrimitive
16+
17+
/**
18+
* Parser for `apple-app-site-association` JSON.
19+
*
20+
* AASA has two coexisting schemas Apple still ships:
21+
* - **Legacy** (pre-iOS 14): `appID` (singular) + `paths` (array of glob strings)
22+
* - **iOS 14+**: `appIDs` (plural array) + `components` (array of objects with
23+
* `/`, `?`, `#`, `exclude`, `caseSensitive`, `percentEncoded`)
24+
*
25+
* Both schemas can coexist inside the same `details` array; some real-world
26+
* configs even mix legacy and modern entries to support older iOS versions.
27+
* We don't pick one — we hand both forms back to the UI in [AppLinkDetail] so
28+
* the developer can see exactly what's deployed and flag legacy-only entries.
29+
*
30+
* We hand-roll on JsonObject rather than declaring a fixed @Serializable shape
31+
* because the spec lets keys appear in either schema and the JSON `/` key in
32+
* components can't be a Kotlin identifier.
33+
*/
34+
class AasaParser {
35+
private val json = Json {
36+
ignoreUnknownKeys = true
37+
isLenient = true
38+
}
39+
40+
fun parse(content: String): ParseResult {
41+
val issues = mutableListOf<AasaIssue>()
42+
43+
val root = try {
44+
json.parseToJsonElement(content).jsonObject
45+
} catch (e: Exception) {
46+
return ParseResult.Error(
47+
message = "Failed to parse JSON: ${e.message}",
48+
cause = e,
49+
issues = listOf(
50+
AasaIssue(
51+
severity = AasaIssue.Severity.ERROR,
52+
code = AasaIssue.AasaIssueCode.INVALID_JSON_SYNTAX,
53+
message = "Invalid JSON syntax",
54+
details = e.message
55+
)
56+
)
57+
)
58+
}
59+
60+
val applinks = root["applinks"]?.let { parseApplinks(it.jsonObject, issues) }
61+
if (applinks == null) {
62+
issues.add(
63+
AasaIssue(
64+
severity = AasaIssue.Severity.ERROR,
65+
code = AasaIssue.AasaIssueCode.MISSING_APPLINKS,
66+
message = "AASA file has no `applinks` section",
67+
details = "Without applinks, iOS will not register any Universal Links for this domain."
68+
)
69+
)
70+
}
71+
72+
val hasWebcredentials = root["webcredentials"] != null
73+
if (hasWebcredentials) {
74+
issues.add(
75+
AasaIssue(
76+
severity = AasaIssue.Severity.INFO,
77+
code = AasaIssue.AasaIssueCode.WEB_CREDENTIALS_PRESENT,
78+
message = "Domain ships Shared Web Credentials (`webcredentials`)"
79+
)
80+
)
81+
}
82+
83+
val hasAppclips = root["appclips"] != null
84+
if (hasAppclips) {
85+
issues.add(
86+
AasaIssue(
87+
severity = AasaIssue.Severity.INFO,
88+
code = AasaIssue.AasaIssueCode.APP_CLIPS_PRESENT,
89+
message = "Domain ships App Clips (`appclips`)"
90+
)
91+
)
92+
}
93+
94+
return ParseResult.Success(
95+
content = AppleAppSiteAssociation(
96+
applinks = applinks,
97+
hasWebcredentials = hasWebcredentials,
98+
hasAppclips = hasAppclips
99+
),
100+
issues = issues
101+
)
102+
}
103+
104+
private fun parseApplinks(
105+
applinks: JsonObject,
106+
issues: MutableList<AasaIssue>
107+
): AppLinksSection {
108+
val apps = (applinks["apps"] as? JsonArray)
109+
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
110+
?: emptyList()
111+
112+
if (apps.isNotEmpty()) {
113+
issues.add(
114+
AasaIssue(
115+
severity = AasaIssue.Severity.WARNING,
116+
code = AasaIssue.AasaIssueCode.NON_EMPTY_APPS_ARRAY,
117+
message = "`applinks.apps` should be an empty array",
118+
details = "Apple has required this to be empty for years; values here are ignored by iOS."
119+
)
120+
)
121+
}
122+
123+
val detailsArray = applinks["details"] as? JsonArray
124+
if (detailsArray == null || detailsArray.isEmpty()) {
125+
issues.add(
126+
AasaIssue(
127+
severity = AasaIssue.Severity.ERROR,
128+
code = AasaIssue.AasaIssueCode.EMPTY_DETAILS,
129+
message = "`applinks.details` is missing or empty",
130+
details = "iOS needs at least one entry in details to register Universal Links."
131+
)
132+
)
133+
}
134+
135+
val details = detailsArray
136+
?.mapNotNull { (it as? JsonObject)?.let { obj -> parseDetail(obj, issues) } }
137+
?: emptyList()
138+
139+
return AppLinksSection(apps = apps, details = details)
140+
}
141+
142+
private fun parseDetail(
143+
detail: JsonObject,
144+
issues: MutableList<AasaIssue>
145+
): AppLinkDetail? {
146+
val legacyAppId = (detail["appID"] as? JsonPrimitive)?.contentOrNull
147+
val modernAppIds = (detail["appIDs"] as? JsonArray)
148+
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
149+
?: emptyList()
150+
151+
val appIds = when {
152+
modernAppIds.isNotEmpty() -> modernAppIds
153+
legacyAppId != null -> listOf(legacyAppId)
154+
else -> emptyList()
155+
}
156+
if (appIds.isEmpty()) return null
157+
158+
val usesLegacy = legacyAppId != null && modernAppIds.isEmpty()
159+
if (usesLegacy) {
160+
issues.add(
161+
AasaIssue(
162+
severity = AasaIssue.Severity.WARNING,
163+
code = AasaIssue.AasaIssueCode.LEGACY_SCHEMA,
164+
message = "Detail uses legacy `appID` + `paths` schema",
165+
details = "Consider migrating to `appIDs` + `components` for iOS 14+ features (query, fragment, exclude)."
166+
)
167+
)
168+
}
169+
170+
appIds.forEach { id ->
171+
if (!isLikelyAppId(id)) {
172+
issues.add(
173+
AasaIssue(
174+
severity = AasaIssue.Severity.ERROR,
175+
code = AasaIssue.AasaIssueCode.INVALID_APP_ID_FORMAT,
176+
message = "App ID does not look like TEAM_ID.bundle.id format: $id",
177+
details = "Expected something like ABCDE12345.com.example.app"
178+
)
179+
)
180+
}
181+
}
182+
183+
val paths = (detail["paths"] as? JsonArray)
184+
?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
185+
?: emptyList()
186+
187+
val components = (detail["components"] as? JsonArray)
188+
?.mapNotNull { (it as? JsonObject)?.let { obj -> parseComponent(obj) } }
189+
?: emptyList()
190+
191+
if (paths.isEmpty() && components.isEmpty()) {
192+
issues.add(
193+
AasaIssue(
194+
severity = AasaIssue.Severity.WARNING,
195+
code = AasaIssue.AasaIssueCode.MISSING_PATHS_AND_COMPONENTS,
196+
message = "Detail for ${appIds.first()} has neither `paths` nor `components`",
197+
details = "iOS will not match any URL for this entry."
198+
)
199+
)
200+
}
201+
202+
return AppLinkDetail(
203+
appIDs = appIds,
204+
paths = paths,
205+
components = components,
206+
usesLegacySchema = usesLegacy
207+
)
208+
}
209+
210+
/**
211+
* The `/` key is the canonical "path" field in iOS 14+ components but isn't
212+
* a valid Kotlin identifier, so we read the JSON manually instead of using
213+
* @Serializable.
214+
*/
215+
private fun parseComponent(component: JsonObject): AasaPathComponent? {
216+
val path = (component["/"] as? JsonPrimitive)?.contentOrNull
217+
val query = (component["?"] as? JsonPrimitive)?.contentOrNull
218+
val fragment = (component["#"] as? JsonPrimitive)?.contentOrNull
219+
val exclude = (component["exclude"] as? JsonPrimitive)?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull()
220+
?: false
221+
val caseSensitive = (component["caseSensitive"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull()
222+
?: true
223+
val percentEncoded = (component["percentEncoded"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull()
224+
?: true
225+
226+
if (path == null && query == null && fragment == null) return null
227+
228+
return AasaPathComponent(
229+
path = path,
230+
query = query,
231+
fragment = fragment,
232+
exclude = exclude,
233+
caseSensitive = caseSensitive,
234+
percentEncoded = percentEncoded
235+
)
236+
}
237+
238+
/**
239+
* Cheap heuristic: TEAM_ID is 10 alphanumerics, then a dot, then a reverse-DNS
240+
* bundle id. Bundle ids legally allow letters / digits / hyphens / dots so we
241+
* only check the prefix shape and the dot — not perfect, but catches typos
242+
* like a missing team prefix without rejecting valid configs.
243+
*/
244+
private fun isLikelyAppId(id: String): Boolean {
245+
val firstDot = id.indexOf('.')
246+
if (firstDot != 10) return false
247+
val team = id.substring(0, 10)
248+
return team.all { it.isLetterOrDigit() } && id.length > 11
249+
}
250+
251+
sealed class ParseResult {
252+
data class Success(
253+
val content: AppleAppSiteAssociation,
254+
val issues: List<AasaIssue>
255+
) : ParseResult()
256+
257+
data class Error(
258+
val message: String,
259+
val cause: Throwable?,
260+
val issues: List<AasaIssue>
261+
) : ParseResult()
262+
}
263+
}

0 commit comments

Comments
 (0)