移动端实现指南
本指南介绍将AgeKit+小部件集成到移动应用程序中的最佳实践。在Web上,小部件通常嵌入在iframe中,但移动应用需要不同的方法来有效显示小部件URL。
概述
AgeKit+小部件是从API调用返回的URL,包含完整的年龄验证界面。将这些小部件集成到移动应用程序时,您有多种显示选项,每种选项都有不同的功能和权衡。主要考虑因素包括:
- AgeKeys支持:用户是否可以为未来的验证创建和使用AgeKeys(基于FIDO的通行密钥)
- 结果通信:验证结果如何传递回您的应用
- 用户体验:集成级别和原生感
移动端实现方法
Android选项
Android提供了三种显示小部件URL的主要方法:
-
Chrome Custom Tabs ⭐ 推荐 - 在保持应用品牌的自定义Chrome浏览器标签页中打开小部件URL。这提供了完整的浏览器功能,同时将用户保持在应用的上下文中。Custom Tabs与Chrome共享Cookie和身份验证状态,实现无缝体验。
-
WebView - 使用Android的原生WebView组件在应用内直接嵌入Web内容。虽然实现简单,但WebView对现代Web标准的支持有限,无法访问某些浏览器功能。
WebView不支持AgeKeys所需的WebAuthn。使用WebView嵌入小部件时,用户将无法创建或使用AgeKeys。要获得具有完整AgeKeys支持的最佳用户体验,请改用Chrome Custom Tabs。
- Trusted Web Activity (TWA) - 以全屏模式显示Web内容,主要为Progressive Web Apps设计。TWA需要在应用和k-ID域之间建立数字资产链接。当未检测到数字资产链接时,TWA会自动回退到Chrome Custom Tabs。
Trusted Web Activities不支持小部件集成。需要在k-ID的域上配置数字资产链接,但这是不可用的。由于在这种情况下TWA会回退到Chrome Custom Tabs,建议直接使用Chrome Custom Tabs以获得更简单的实现。
iOS选项
iOS提供了三种显示小部件URL的主要方法:
-
ASWebAuthenticationSession ⭐ 推荐 - 专为安全身份验证流程设计,此方法在系统管理的浏览器视图中显示Web内容。它与Safari共享Cookie并提供对现代Web功能(如WebAuthn)的访问,使其成为验证流程的理想选择。
-
SFSafariViewController - 在类似Safari的界面中显示Web内容,与Safari共享Cookie和身份验证状态。这提供了熟悉的浏览体验,同时保持应用上下文。
-
WKWebView - Apple的现代Web视图组件,可在应用内嵌入Web内容。与Android的WebView类似,WKWebView对某些Web标准有限制,无法访问所有浏览器功能。
WKWebView不支持AgeKeys所需的WebAuthn。使用WKWebView嵌入小部件时,用户将无法创建或使用AgeKeys。要获得具有完整AgeKeys支持的最佳用户体验,请改用ASWebAuthenticationSession。
AgeKeys支持限制
AgeKeys是基于FIDO和WebAuthn标准的可重用匿名年龄证明凭据。它们允许用户验证一次年龄,并在不同服务之间重复使用该验证,而无需透露个人信息。
AgeKeys需要WebAuthn支持,这在Android WebView或iOS WKWebView中不可用。如果您使用这些组件嵌入小部件,用户在验证期间将看不到AgeKeys作为选项,并且在验证成功后无法创建AgeKeys。
要为您的用户启用AgeKeys,必须使用以下方法之一:
接收验证结果
移动应用需要在用户完成小部件流程后接收验证结果。有两种方法,每种方法都有不同的可用性:
回调URL(通用方法)
所有实现方法的推荐方法是使用回调URL。当您调用API生成小部件URL时,包含redirectUrl参数。验证完成后,小部件会将结果作为查询参数重定向到此URL。
回调URL的优势:
- 适用于所有实现方法
- 比DOM消息更可靠
- 移动应用的标准深度链接模式
- 即使应用移至后台,结果也会始终传递
回调URL的工作原理
- 在应用中注册深度链接处理程序(例如,
myapp://verification-complete) - 调用API时将深度链接作为
redirectUrl包含 - 小部件完成后重定向到您的深度链接
- 应用处理深度链接并提取结果
仅当小部件URL在浏览器或Web视图中直接打开时才会发生重定向,而不是在iframe中嵌入时。
回调URL参数
当小部件重定向到您的回调URL时,它包含以下查询参数:
verificationId:此验证的唯一标识符result:PASS或FAIL
回调URL示例:
myapp://verification-complete?verificationId=7854909b-9124-4bed-9282-24b44c4a3c97&result=PASS
实现回调URL
调用AgeKit+ API时,在options对象中包含redirectUrl。URL可以是:
- HTTPS URL:
https://example.com/verification-complete - 自定义深度链接:
myapp://verification-complete
有关redirectUrl参数的更多详细信息,请参阅瀑布流指南。
DOM消息(仅限WebView/WKWebView)
使用Android WebView或iOS WKWebView时,可以监听从小部件发送的JavaScript消息。这允许您:
- 实时接收验证结果
- 控制Web视图何时关闭
- 根据小部件事件更新应用的UI
DOM消息作为postMessage事件发送,您可以在本机代码中拦截。有关可用事件的详细信息,请参阅DOM事件概述。
DOM消息仅适用于WebView和WKWebView。它们不适用于Chrome Custom Tabs、Trusted Web Activity、ASWebAuthenticationSession或SFSafariViewController。
方法比较
| 方法 | 平台 | AgeKeys | DOM消息 | 回调URL | 最适合 |
|---|---|---|---|---|---|
| WebView | Android | ❌ | ✅ | ✅ | 不推荐(无AgeKeys支持) |
| Chrome Custom Tabs | Android | ✅ | ❌ | ✅ | 大多数用例(推荐) |
| Trusted Web Activity | Android | ✅ | ❌ | ✅ | 不推荐(需要数字资产链接) |
| WKWebView | iOS | ❌ | ✅ | ✅ | 不推荐(无AgeKeys支持) |
| ASWebAuthenticationSession | iOS | ✅ | ❌ | ✅ | 大多数用例(推荐) |
| SFSafariViewController | iOS | ✅ | ❌ | ✅ | 类似Safari的体验 |
推荐实现
Android: Chrome Custom Tabs
使用Chrome Custom Tabs和回调URL,以获得功能与用户体验的最佳平衡。
选择Chrome Custom Tabs的原因:
- 通过WebAuthn完全支持AgeKeys
- 访问所有现代Web功能
- 应用品牌化的无缝用户体验
- 可靠的回调机制
- 与Chrome共享身份验证状态
实现步骤:
- 为回调URL注册深度链接处理程序
- 在API请求中包含
redirectUrl - 使用Chrome Custom Tabs打开小部件URL
- 使用验证结果处理深度链接回调
iOS: ASWebAuthenticationSession
使用ASWebAuthenticationSession和回调URL,实现安全、原生感的验证流程。
选择ASWebAuthenticationSession的原因:
- 通过WebAuthn完全支持AgeKeys
- 访问所有现代Web功能
- 系统管理的安全UI
- 与Safari共享Cookie
- 可靠的回调机制
实现步骤:
- 为回调URL注册URL方案处理程序
- 在API请求中包含
redirectUrl - 使用ASWebAuthenticationSession显示小部件URL
- 使用验证结果处理URL方案回调
完整实现示例
以下是实现推荐方法的逐步示例,包含完整的代码示例:
步骤1:注册深度链接处理程序
- iOS (Swift)
- Android (Kotlin)
在Info.plist中注册URL方案:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>
在AndroidManifest.xml中添加意图过滤器:
<activity
android:name=".MainActivity"
android:exported="true">
<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" android:host="verification-complete" />
</intent-filter>
</activity>
步骤2:生成带回调的小部件URL
- iOS (Swift)
- Android (Kotlin)
import Foundation
func generateWidgetUrl(completion: @escaping (URL?) -> Void) {
guard let url = URL(string: "https://game-api.k-id.com/api/v1/age-verification/perform-access-age-verification") else {
completion(nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer YOUR_API_KEY", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let requestBody: [String: Any] = [
"jurisdiction": "US-CA",
"criteria": [
"ageCategory": "DIGITAL_YOUTH_OR_ADULT"
],
"options": [
"redirectUrl": "myapp://verification-complete"
]
]
guard let httpBody = try? JSONSerialization.data(withJSONObject: requestBody) else {
completion(nil)
return
}
request.httpBody = httpBody
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil)
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode),
let data = data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let widgetUrlString = json["url"] as? String,
let widgetUrl = URL(string: widgetUrlString) else {
completion(nil)
return
}
completion(widgetUrl)
}.resume()
}
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
fun generateWidgetUrl(callback: (String?) -> Unit) {
val client = OkHttpClient()
val mediaType = "application/json".toMediaType()
val requestBody = JSONObject().apply {
put("jurisdiction", "US-CA")
put("criteria", JSONObject().apply {
put("ageCategory", "DIGITAL_YOUTH_OR_ADULT")
})
put("options", JSONObject().apply {
put("redirectUrl", "myapp://verification-complete")
})
}.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url("https://game-api.k-id.com/api/v1/age-verification/perform-access-age-verification")
.post(requestBody)
.addHeader("Authorization", "Bearer YOUR_API_KEY")
.addHeader("Content-Type", "application/json")
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
if (!response.isSuccessful) {
callback(null)
return
}
response.body?.use { body ->
val responseBody = body.string()
try {
val jsonResponse = JSONObject(responseBody)
val widgetUrl = jsonResponse.optString("url", null)
callback(widgetUrl)
} catch (e: Exception) {
callback(null)
}
} ?: callback(null)
}
override fun onFailure(call: Call, e: IOException) {
callback(null)
}
})
}
步骤3:显示小部件
- iOS (Swift)
- Android (Kotlin)
import AuthenticationServices
// 将会话存储为属性以防止释放
var authSession: ASWebAuthenticationSession?
func displayWidget(widgetUrl: URL) {
authSession = ASWebAuthenticationSession(
url: widgetUrl,
callbackURLScheme: "myapp"
) { callbackURL, error in
if let error = error {
// 处理错误(用户取消等)
return
}
if let callbackURL = callbackURL {
handleVerificationCallback(callbackURL)
}
}
authSession?.presentationContextProvider = self
authSession?.start()
}
import android.app.Activity
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
fun displayWidget(activity: Activity, widgetUrl: String?) {
if (widgetUrl == null) {
// 处理错误:生成小部件URL失败
return
}
val builder = CustomTabsIntent.Builder()
val customTabsIntent = builder.build()
val uri = Uri.parse(widgetUrl)
customTabsIntent.launchUrl(activity, uri)
}
步骤4:处理回调
- iOS (Swift)
- Android (Kotlin)
func handleVerificationCallback(_ callbackURL: URL) {
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
return
}
let verificationId = queryItems.first(where: { $0.name == "verificationId" })?.value
let result = queryItems.first(where: { $0.name == "result" })?.value
// 根据验证结果更新UI
if result == "PASS" {
// 处理验证成功
} else if result == "FAIL" {
// 处理验证失败
}
// 可选:在服务器端验证
if let verificationId = verificationId {
verifyResultServerSide(verificationId: verificationId)
}
}
import android.content.Intent
import android.net.Uri
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // 重要:确保使用新意图
val data: Uri? = intent.data
if (data != null && data.scheme == "myapp" && data.host == "verification-complete") {
val verificationId = data.getQueryParameter("verificationId")
val result = data.getQueryParameter("result")
// 根据验证结果更新UI
if (result == "PASS") {
// 处理验证成功
} else {
// 处理验证失败
}
// 可选:在服务器端验证
if (verificationId != null) {
verifyResultServerSide(verificationId)
}
}
}
为了安全性和数据完整性,始终使用/age-verification/get-status端点在服务器端验证验证结果,而不是仅依赖客户端数据。