接收入口
已安装:App Link / Universal Link / 自定义 Scheme。Android 新安装:Google Play Install Referrer。
把 Facebook 用户从 FlowPage 无缝带进 App,并保留点击、活动、公会、房间或业务目标上下文。
接收链接参数 → 调用 first-open 换取服务端可信上下文 → 用原生 Navigator 打开公会、房间或目标页。不要直接使用 Facebook URL 或 Referrer 中的目标 ID 导航。
Android 包名与下载地址已经进入线上平台配置。Scheme 必须与每个客户端真实配置一致。
| App | platform_code | Android package | Deep Link Scheme | Google Play |
|---|---|---|---|---|
Pepstar | pepstar | com.pepstar | pepstar | ● 已配置 |
PepLive | peplive | com.peplive | 待客户端确认 | ● 已配置 |
Veco | veco | com.vecochat | 待客户端确认 | ● 已配置 |
Uho | uho | com.zelix | 待客户端确认 | ● 已配置 |
同一套服务端上下文适用于 GuildLink 与 FlowLink。
已安装:App Link / Universal Link / 自定义 Scheme。Android 新安装:Google Play Install Referrer。
只保留 link_type、click_id、short_code、platform_code 与 package_name。
调用 first-open。服务端核对短码、点击、平台和包名,返回可信 context。
guild 使用 guild_id / room_id;flow 使用 destination_type / destination_external_id。
下面代码是可落地的接入骨架;Navigator 名称需替换为各 App 已有的路由实现。
只在 Android 首次启动读取,读取成功并且 first-open 成功后持久化完成标记。
// app/build.gradle.kts
dependencies {
implementation("com.android.installreferrer:installreferrer:2.2")
}HANDOFF_SCHEME 由 manifest placeholder 按 App 注入。MainActivity 使用 singleTask,确保已运行时走 onNewIntent。
<!-- AndroidManifest.xml -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- 已安装 App:接收 App Link -->
<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" android:host="loop.pepstar.tw" android:pathPrefix="/f/" />
<data android:scheme="https" android:host="loop.pepstar.tw" android:pathPrefix="/g/" />
</intent-filter>
<!-- 兼容当前网页使用的自定义 Scheme,例如 pepstar://guild/10001 -->
<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="${HANDOFF_SCHEME}" />
</intent-filter>
</activity>data class HandoffSeed(
val linkType: String,
val clickId: String?,
val shortCode: String,
val platformCode: String,
val packageName: String
)
fun Uri.toHandoffSeed(platformCode: String, packageName: String): HandoffSeed? {
val firstPath = pathSegments.firstOrNull()?.lowercase()
val linkType = getQueryParameter("gl_link_type")
?: getQueryParameter("link_type")
?: if (host == "guild" || firstPath == "g") "guild" else "flow"
val shortCode = getQueryParameter("gl_short_code")
?: getQueryParameter("short_code")
?: getQueryParameter("code")
?: pathSegments.lastOrNull()
?: return null
return HandoffSeed(
linkType = linkType,
clickId = getQueryParameter("gl_click_id") ?: getQueryParameter("click_id"),
shortCode = shortCode.uppercase(),
platformCode = getQueryParameter("gl_platform_code") ?: platformCode,
packageName = getQueryParameter("gl_package_name") ?: packageName
)
}class GuildLoopInstallReferrer(
private val context: Context,
private val onSeed: (HandoffSeed) -> Unit
) : InstallReferrerStateListener {
private val client = InstallReferrerClient.newBuilder(context).build()
private val prefs = context.getSharedPreferences("guildloop_handoff", Context.MODE_PRIVATE)
fun start() {
if (prefs.getBoolean("install_referrer_resolved", false)) return
client.startConnection(this)
}
override fun onInstallReferrerSetupFinished(code: Int) {
if (code != InstallReferrerClient.InstallReferrerResponse.OK) {
client.endConnection() // 网络或 Play 服务恢复后重试
return
}
try {
val raw = client.installReferrer.installReferrer
val uri = Uri.parse("https://referrer.local/?" + raw)
uri.toHandoffSeed(BuildConfig.PLATFORM_CODE, context.packageName)?.let(onSeed)
// first-open API 成功后再写 true;不要在网络失败时永久丢弃
} finally {
client.endConnection()
}
}
override fun onInstallReferrerServiceDisconnected() = Unit
fun markResolved() {
prefs.edit().putBoolean("install_referrer_resolved", true).apply()
}
}class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleHandoff(intent)
GuildLoopInstallReferrer(this) { seed -> resolveAndRoute(seed) }.start()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleHandoff(intent)
}
private fun handleHandoff(intent: Intent?) {
val seed = intent?.data?.toHandoffSeed(BuildConfig.PLATFORM_CODE, packageName) ?: return
resolveAndRoute(seed)
}
private fun resolveAndRoute(seed: HandoffSeed) {
// 调用下方 first-open API;成功后只按 response.context 导航
HandoffApi.resolve(seed, BuildConfig.VERSION_NAME, InstallId.get(this)) { context ->
when (context.linkType) {
"guild" -> GuildNavigator.open(context.guildId, context.roomId)
"flow" -> DestinationNavigator.open(context.destinationType, context.destinationExternalId)
}
}
}
}示例使用系统网络类。生产 App 可放进现有 Retrofit/OkHttp 层,但请求字段不能改名。
object HandoffApi {
private const val ENDPOINT =
"https://loop.pepstar.tw/api/v1/public/app-handoff/first-open"
// 必须从 IO 线程/协程调用;这里使用 JDK 网络类,避免绑定具体 HTTP SDK。
fun resolveBlocking(seed: HandoffSeed, version: String, installId: String): JSONObject {
val body = JSONObject()
.put("link_type", seed.linkType)
.put("click_id", seed.clickId ?: "")
.put("short_code", seed.shortCode)
.put("platform_code", seed.platformCode)
.put("package_name", seed.packageName)
.put("client_os", "android")
.put("device_id", installId)
.put("app_version", version)
val connection = URL(ENDPOINT).openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
connection.outputStream.use { it.write(body.toString().toByteArray(Charsets.UTF_8)) }
val stream = if (connection.responseCode in 200..299) {
connection.inputStream
} else {
throw IOException("first-open HTTP " + connection.responseCode)
}
return stream.bufferedReader().use { JSONObject(it.readText()) }
}
}Manifest/Entitlement 只声明客户端能力;系统验证还需要域名下的关联文件。
四个正式包都要填写各自 Play 正式签名证书的 SHA-256 指纹。
// https://loop.pepstar.tw/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.pepstar",
"sha256_cert_fingerprints": ["<PEPSTAR_RELEASE_SHA256>"]
}
}
// PepLive、Veco、Uho 各增加一项;必须使用正式签名证书 SHA-256
]每个 iOS App 填写真实 Team ID 与 Bundle ID,文件不能使用占位符上线。
// https://loop.pepstar.tw/.well-known/apple-app-site-association
{
"applinks": {
"details": [
{
"appIDs": ["<APPLE_TEAM_ID>.<IOS_BUNDLE_ID>"],
"components": [
{ "/": "/f/*", "comment": "DomiFlow FlowLink" },
{ "/": "/g/*", "comment": "GuildLoop GuildLink" }
]
}
]
}
}公网接口无需把后台密钥放进 App;返回目标必须由服务端重新解析。
POST https://loop.pepstar.tw/api/v1/public/app-handoff/first-open
Content-Type: application/json
{
"link_type": "flow",
"click_id": "c79c1a8f-7a2a-4204-ad52-570d50095f40",
"short_code": "JR9RYL3Z",
"platform_code": "pepstar",
"package_name": "com.pepstar",
"client_os": "android",
"device_id": "App 生成并持久化的安装级 UUID",
"app_version": "1.2.3"
}{
"status": "resolved",
"first_open_recorded": true,
"context": {
"link_type": "flow",
"click_id": "c79c1a8f-7a2a-4204-ad52-570d50095f40",
"short_code": "JR9RYL3Z",
"platform_code": "pepstar",
"package_name": "com.pepstar",
"deep_link": "pepstar://guild/10001?click_id=...&short_code=JR9RYL3Z",
"destination_type": "guild",
"destination_external_id": "10001"
}
}同一 link_type + click_id + platform 只记录一次首次打开。
device_id 使用 App 生成的安装级 UUID,不使用 IMEI、IDFA 或广告 ID。
EVENT_INGEST_KEY、管理 Token、数据库凭证绝不能打包进客户端。
每个 App 都必须覆盖“已安装”和“未安装后首次打开”两条路径。
# 验证域名关联状态
adb shell pm verify-app-links --re-verify com.pepstar
adb shell pm get-app-links com.pepstar
# 模拟 Facebook/浏览器打开 FlowLink
adb shell am start -a android.intent.action.VIEW -d "https://loop.pepstar.tw/f/JR9RYL3Z?click_id=c79c1a8f-7a2a-4204-ad52-570d50095f40" com.pepstar# iOS Simulator;真机仍需从 Messages、Mail 或网页点击验证
xcrun simctl openurl booted "https://loop.pepstar.tw/f/JR9RYL3Z?click_id=c79c1a8f-7a2a-4204-ad52-570d50095f40"