APP HANDOFF SDK

Android / iOS 接入说明

把 Facebook 用户从 FlowPage 无缝带进 App,并保留点击、活动、公会、房间或业务目标上下文。

服务端接口已上线first-open · v1
客户端最终只做三件事

接收链接参数 → 调用 first-open 换取服务端可信上下文 → 用原生 Navigator 打开公会、房间或目标页。不要直接使用 Facebook URL 或 Referrer 中的目标 ID 导航。

01

四个 App 的接入参数

Android 包名与下载地址已经进入线上平台配置。Scheme 必须与每个客户端真实配置一致。

Appplatform_codeAndroid packageDeep Link SchemeGoogle Play
Pepstarpepstarcom.pepstarpepstar已配置
PepLivepeplivecom.peplive待客户端确认已配置
Vecovecocom.vecochat待客户端确认已配置
Uhouhocom.zelix待客户端确认已配置
iOS 参数还不能标记完成:需要每个 App 的 Apple Team ID、Bundle ID、App Store 地址以及正式 Universal Link 能力。拿到这些值后,才能生成可验证的 AASA 文件并启用 iOS 安装链路。
02

统一处理流程

同一套服务端上下文适用于 GuildLink 与 FlowLink。

1

接收入口

已安装:App Link / Universal Link / 自定义 Scheme。Android 新安装:Google Play Install Referrer。

2

解析最小参数

只保留 link_type、click_id、short_code、platform_code 与 package_name。

3

服务端重新校验

调用 first-open。服务端核对短码、点击、平台和包名,返回可信 context。

4

原生页面导航

guild 使用 guild_id / room_id;flow 使用 destination_type / destination_external_id。

03

客户端接入代码

下面代码是可落地的接入骨架;Navigator 名称需替换为各 App 已有的路由实现。

Android 支持完整链路已安装直达 + Google Play 安装后恢复邀请
A1

加入 Install Referrer 依赖

只在 Android 首次启动读取,读取成功并且 first-open 成功后持久化完成标记。

app/build.gradle.kts
// app/build.gradle.kts
dependencies {
    implementation("com.android.installreferrer:installreferrer:2.2")
}
A2

注册 App Links 与自定义 Scheme

HANDOFF_SCHEME 由 manifest placeholder 按 App 注入。MainActivity 使用 singleTask,确保已运行时走 onNewIntent。

AndroidManifest.xml
<!-- 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>
A3

统一解析入口参数

HandoffSeed.kt
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
    )
}
A4

读取 Google Play Install Referrer

GuildLoopInstallReferrer.kt
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()
    }
}
A5

接入 Activity 生命周期与原生导航

MainActivity.kt
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)
            }
        }
    }
}
A6

调用 first-open

示例使用系统网络类。生产 App 可放进现有 Retrofit/OkHttp 层,但请求字段不能改名。

HandoffApi.kt
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()) }
    }
}
04

域名关联文件

Manifest/Entitlement 只声明客户端能力;系统验证还需要域名下的关联文件。

Android · Digital Asset Links

四个正式包都要填写各自 Play 正式签名证书的 SHA-256 指纹。

.well-known/assetlinks.json
// 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 · Apple App Site Association

每个 iOS App 填写真实 Team ID 与 Bundle ID,文件不能使用占位符上线。

.well-known/apple-app-site-association
// 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" }
        ]
      }
    ]
  }
}
05

first-open 接口契约

公网接口无需把后台密钥放进 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、数据库凭证绝不能打包进客户端。

06

联调与验收

每个 App 都必须覆盖“已安装”和“未安装后首次打开”两条路径。

Android 调试命令

Terminal
# 验证域名关联状态
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 调试命令

Terminal
# iOS Simulator;真机仍需从 Messages、Mail 或网页点击验证
xcrun simctl openurl booted   "https://loop.pepstar.tw/f/JR9RYL3Z?click_id=c79c1a8f-7a2a-4204-ad52-570d50095f40"

上线前检查表