[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
---
|
||||
id: mobile-deep-link-verification
|
||||
title: Deep Link Verification — Universal / App Links / 검증
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [mobile, deep-link, universal-link, app-link, vibe-coding]
|
||||
tech_stack: { language: "Swift / Kotlin", applicable_to: ["iOS", "Android"] }
|
||||
applied_in: []
|
||||
aliases: [App Links, Universal Links, AASA, assetlinks, deep link verification, deferred deep link]
|
||||
---
|
||||
|
||||
# Deep Link Verification
|
||||
|
||||
> Web URL → app. **iOS = Universal Links + AASA, Android = App Links + assetlinks.json**. 검증 안 되면 browser 가 처리. Deferred = install 후 의도 복원.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Verification: 서버 가 owns this domain.
|
||||
- AASA (iOS): apple-app-site-association.
|
||||
- assetlinks (Android): .well-known/assetlinks.json.
|
||||
- Deferred: install 후 의도 복원 (Branch / Adjust).
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### iOS — AASA
|
||||
```json
|
||||
// https://example.com/.well-known/apple-app-site-association
|
||||
// (HTTP/2 + HTTPS + 정확 Content-Type: application/json)
|
||||
{
|
||||
"applinks": {
|
||||
"details": [
|
||||
{
|
||||
"appIDs": ["TEAMID.com.example.app"],
|
||||
"components": [
|
||||
{ "/": "/order/*" },
|
||||
{ "/": "/user/*", "?": { "ref": "?*" } },
|
||||
{ "/": "/admin/*", "exclude": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"webcredentials": {
|
||||
"apps": ["TEAMID.com.example.app"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### iOS — Associated Domains
|
||||
```
|
||||
Xcode → Signing & Capabilities → Associated Domains
|
||||
+ applinks:example.com
|
||||
+ webcredentials:example.com # password autofill
|
||||
```
|
||||
|
||||
```swift
|
||||
// SwiftUI handle
|
||||
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
||||
guard let url = activity.webpageURL else { return }
|
||||
handle(url)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handle(url)
|
||||
}
|
||||
|
||||
func handle(_ url: URL) {
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
guard let path = comps?.path else { return }
|
||||
|
||||
// ⚠️ Path / params 검증
|
||||
if path.hasPrefix("/order/") {
|
||||
let id = String(path.dropFirst("/order/".count))
|
||||
guard isValidUuid(id) else { return }
|
||||
navigationStore.navigate(.order(id))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android — assetlinks.json
|
||||
```json
|
||||
// https://example.com/.well-known/assetlinks.json
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.example.app",
|
||||
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
→ SHA256 = signing cert. release / debug 둘 다 포함 가능.
|
||||
|
||||
```bash
|
||||
# Cert fingerprint 추출
|
||||
keytool -list -v -keystore my-release-key.keystore
|
||||
# 또는 Play Console — App signing
|
||||
```
|
||||
|
||||
### Android — Manifest
|
||||
```xml
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="example.com" />
|
||||
<data android:pathPattern="/order/.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
```
|
||||
|
||||
→ `android:autoVerify="true"` = OS 가 install 시 verify.
|
||||
|
||||
### Android — handle
|
||||
```kotlin
|
||||
class MainActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
intent?.data?.let(::handleDeepLink)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.data?.let(::handleDeepLink)
|
||||
}
|
||||
|
||||
private fun handleDeepLink(uri: Uri) {
|
||||
val path = uri.path ?: return
|
||||
if (path.startsWith("/order/")) {
|
||||
val id = path.removePrefix("/order/")
|
||||
navigate(Screen.Order(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verification check
|
||||
```bash
|
||||
# iOS
|
||||
swcutil verify --domain example.com --bundle-identifier com.example.app
|
||||
|
||||
# Android
|
||||
adb shell pm get-app-links com.example.app
|
||||
# 또는 Play Console → App Links
|
||||
|
||||
# Apple validator
|
||||
https://branch.io/resources/aasa-validator/
|
||||
```
|
||||
|
||||
### 검증 실패 원인
|
||||
```
|
||||
iOS:
|
||||
- AASA 가 redirect / 401
|
||||
- Content-Type 가 application/json X
|
||||
- 잘못된 appID (TeamID 정확)
|
||||
- 옛 cache (다음 install 까지 wait)
|
||||
|
||||
Android:
|
||||
- assetlinks.json HTTP / 잘못
|
||||
- SHA256 fingerprint 불일치 (debug vs release)
|
||||
- pathPattern 불일치
|
||||
- autoVerify 안 한 channel
|
||||
```
|
||||
|
||||
### Cache 무효화
|
||||
```bash
|
||||
# iOS
|
||||
xcrun simctl openurl booted https://example.com/test
|
||||
|
||||
# Android — clear app links
|
||||
adb shell pm clear-app-links com.example.app
|
||||
adb shell pm verify-app-links --re-verify com.example.app
|
||||
```
|
||||
|
||||
### Deferred deep link (install 후 의도 복원)
|
||||
```
|
||||
사용자가 ad → install → first launch.
|
||||
원래 URL 의 의도 (특정 product) 복원.
|
||||
|
||||
iOS / Android 자체 X — 외부 SDK:
|
||||
- Branch.io
|
||||
- Adjust
|
||||
- AppsFlyer
|
||||
- Singular
|
||||
```
|
||||
|
||||
```ts
|
||||
// Branch
|
||||
import branch from 'react-native-branch';
|
||||
|
||||
const { params } = await branch.getLatestReferringParams();
|
||||
if (params.product_id) navigation.navigate('Product', { id: params.product_id });
|
||||
```
|
||||
|
||||
### Custom URL scheme (legacy)
|
||||
```xml
|
||||
<!-- iOS — Info.plist -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array><string>myapp</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
```kotlin
|
||||
<!-- Android -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="myapp" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
→ `myapp://order/123` — but Universal/App Links 권장. Custom scheme 위조 가능.
|
||||
|
||||
### 보안 — Path / params 검증
|
||||
```swift
|
||||
func handle(_ url: URL) {
|
||||
// 1. Whitelist host
|
||||
guard url.host == "example.com" else { return }
|
||||
|
||||
// 2. Path validation
|
||||
guard let path = url.path.split(...) else { return }
|
||||
|
||||
// 3. Param sanitization
|
||||
guard let id = url.queryItems["id"], isValidUuid(id) else { return }
|
||||
|
||||
// 4. Auth — link 가 login 우회 X
|
||||
if requiresLogin(path) && !isLoggedIn() {
|
||||
navigateAfterLogin(.path(path))
|
||||
return
|
||||
}
|
||||
|
||||
navigate(...)
|
||||
}
|
||||
```
|
||||
|
||||
### Notification + deep link
|
||||
```ts
|
||||
// Push payload
|
||||
{
|
||||
aps: { alert: { ... } },
|
||||
link: 'https://example.com/order/42'
|
||||
}
|
||||
```
|
||||
|
||||
```swift
|
||||
// Tap notification → openURL
|
||||
let link = userInfo["link"] as? String
|
||||
if let url = URL(string: link) { handle(url) }
|
||||
```
|
||||
|
||||
### Web fallback
|
||||
```html
|
||||
<!-- App 미설치 시 web 표시 -->
|
||||
<!-- example.com/order/42 -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Order 42</title>
|
||||
<meta name="apple-itunes-app" content="app-id=123456789">
|
||||
<meta property="al:android:url" content="myapp://order/42">
|
||||
<meta property="al:android:package" content="com.example.app">
|
||||
</head>
|
||||
<body>
|
||||
<p>Open in app</p>
|
||||
<a href="myapp://order/42">Open in app</a>
|
||||
<a href="https://apps.apple.com/app/...">Install on iOS</a>
|
||||
<a href="https://play.google.com/store/apps/...">Install on Android</a>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### App Clip (iOS) / Instant App (Android)
|
||||
```
|
||||
사용자가 install 안 해도 app 의 작은 부분 실행.
|
||||
QR / NFC / link 로.
|
||||
```
|
||||
|
||||
→ [[iOS_App_Clips]].
|
||||
|
||||
### Test
|
||||
```ts
|
||||
// Maestro
|
||||
- launchApp:
|
||||
arguments:
|
||||
url: "https://example.com/order/42"
|
||||
|
||||
# 또는 adb
|
||||
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/order/42" com.example.app
|
||||
|
||||
# iOS
|
||||
xcrun simctl openurl booted "https://example.com/order/42"
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 일반 deep link | Universal Links (iOS) + App Links (Android) |
|
||||
| Marketing campaign | + Deferred (Branch / Adjust) |
|
||||
| Private (auth callback) | Custom scheme (잠시 OK) |
|
||||
| App Clip / Instant App | 가벼운 entry point |
|
||||
| Notification 안 link | Universal Link 또는 custom scheme |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **AASA / assetlinks 가 redirect**: verification 깨짐.
|
||||
- **SHA256 fingerprint 불일치 (release / debug 다름)**: 옛 release 가 안 됨.
|
||||
- **Path / params 검증 X**: SQL / XSS injection.
|
||||
- **Deep link 가 auth 우회**: 인증 검사 매번.
|
||||
- **Custom scheme 만 의존**: 위조 가능.
|
||||
- **Wildcard path (`*`)**: 의도 외 link.
|
||||
- **Web fallback 없음**: 미설치 시 dead link.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- AASA + assetlinks + Associated Domains + autoVerify 4종.
|
||||
- Path / params validate (zod-style).
|
||||
- Auth check 매번.
|
||||
- Deferred = Branch / Adjust.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[iOS_Universal_Links_Deep_Linking]]
|
||||
- [[Android_Bluetooth_LE_Scanning]]
|
||||
- [[Mobile_Push_Deep]]
|
||||
Reference in New Issue
Block a user