前言:SSO 是什么 SSO(Single Sign-On,单点登录)本质上是一种票据中转 机制:用户在公司统一登录页输入密码,登录页生成一张一次性凭证(ticket),App 拿到这张票去服务器换取真正的身份 Cookie。全程用户的密码只交给公司统一登录页,App 本身永远看不到密码。
一、参与方 在理解流程之前,先明确有哪些系统参与了这件事:
二、完整时序图(文字版) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 用户 App(UI) AuthViewModel AuthRepository 系统浏览器 SSO服务 UserService | | | | | | | | 点击"SSO登录" | | | | | | |---------------->| | | | | | | | startSsoLogin() | | | | | | |------------------>| | | | | | | | prepareSsoLogin()| | | | | | |----------------->| | | | | | | | 开ServerSocket | | | | | | | 构造SSO URL | | | | | | | | | | | | | SsoEffect. | | | | | | | LaunchUrl(url) | | | | | |<------------------| | | | | | | startActivity | | | | | | |------------------------------------------------->| | | | | | | | | | | | | awaitSsoCallback()| | | | | | |----------------->| | | | | | | | accept()阻塞等待 | | | | | | | | | | | | | | | 打开SSO登录页 | | | 输入账号密码 | | | |----------------->| | |------------------------------------------------------------------------------------------------>| | | | | | | | 验证密码 | | | | | | | 颁发ticket | | | | | | redirect到 | | | | | | | 127.0.0.1/callback?ticket=xxx | | | | |<-----------------| | | | | | | 收到HTTP请求 | | | | | | | 提取ticket | | | | | | | 返回成功HTML | | | | | | |----------------->| | | | 看到"登录成功"页| | | | | | |<--------------------------------------------------------| | | | | | | | | | | | | | | exchangeTicket() | | | | | | |---------------------------------------------------->| | | | | | | 验证ticket | | | | | | | 返回Cookie+用户信息 | | | |<----------------------------------------------------| | | | | 存加密本地存储 | | | | | 状态→Idle | | | | | | |<------------------| | | | | | 进入主界面 | | | | | | |<-----------------| | | | | |
三、逐步拆解每个环节 第 1 步:用户点击按钮 LoginScreen.kt 中的 SSO 登录按钮绑定了 onSsoLoginClick 回调,点击后调用:
1 authViewModel.startSsoLogin(subsystemAlias = "userservice" )
subsystemAlias 固定为 “userservice”,用于告知 SSO 服务和 UserService 这次登录属于哪个子系统。
第 2 步:并发保护检查 startSsoLogin 第一件事就是防止重复触发:
1 2 3 4 5 6 7 fun startSsoLogin (subsystemAlias: String ) { if (ssoLoginJob?.isActive == true || _ssoLoginState.value.isBusy()) { return } _ssoLoginState.value = SsoLoginState.LaunchingLogin ssoLoginJob = viewModelScope.launch { runSsoLogin(subsystemAlias) } }
“忙碌”的定义是:当前状态是 LaunchingLogin、HandlingCallback、LaunchingImAuthorization、HandlingImCallback 其中任意一种。也就是说,从点击按钮到登录完成的整个过程中,再次点击都会被静默忽略。
第 3 步:准备阶段——在本机开一个 HTTP 服务器 这是 SSO 流程最特殊的地方,也是和 IM 登录最大的区别。
authRepository.prepareSsoLogin(“userservice”) 做了这几件事:
3a. 关闭旧服务器 (防止上次未正常关闭留下的残留)
3b. 创建新的 AuthCallbackServer
1 2 val server = AuthCallbackServer()callbackServer = server
AuthCallbackServer 的构造函数:
1 2 3 4 5 6 7 8 9 10 11 private val serverSocket = ServerSocket( 0 , 1 , InetAddress.getByName("127.0.0.1" ) ) val port: Int = serverSocket.localPort val callbackUrl: String = "http://127.0.0.1:$port /callback" init { serverSocket.soTimeout = 5 * 60 * 1000 }
为什么要在本机开服务器?
SSO 的安全机制要求 ticket 必须被”redirect”回一个预先登记的地址(service 参数)。桌面端可以用 localhost,移动端也用同样的思路——在本机开一个临时 HTTP 服务器监听回调。
3c. 构造 SSO 登录 URL
1 2 3 4 fun buildLoginUrl (callbackUrl: String , subsystemAlias: String ) : String { val encodedService = URLEncoder.encode(callbackUrl, "UTF-8" ) return "${config.ssoHost} ?service=$encodedService &subsystem=$subsystemAlias " }
最终 URL 长这样(生产环境):
1 2 3 https://sso.example.com ?service=http%3A%2F%2F127.0.0.1%3A54321%2Fcallback &subsystem=userservice
这个 URL 告诉 SSO 服务:用户登录成功后,把 ticket 带到 http://127.0.0.1:54321/callback 这个地址。
第 4 步:打开系统浏览器 prepareSsoLogin 返回 URL 后,ViewModel 通过 Channel 发出副作用:
1 2 _ssoEffect.send(SsoEffect.LaunchUrl(url)) _ssoLoginState.value = SsoLoginState.HandlingCallback
LoginScreen.kt 中的 LaunchedEffect 收到后:
1 2 3 is SsoEffect.LaunchUrl -> { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url))) }
此刻 App 切到后台,系统浏览器打开了 https://sso.example.com?service=...&subsystem=userservice。
💡 深入:startActivity 打开浏览器的底层机制
Intent.ACTION_VIEW 配合 https:// URL,Android 系统会查询已安装 App 中哪些声明了处理 https scheme 的 Activity(通常是 Chrome 或系统默认浏览器),然后启动它。这和你在手机上点一个网页链接完全一样 ——App 本身不渲染页面,只是把 URL 丢给系统,自己退到后台。
此时 App 进入后台,但协程仍在 Dispatchers.IO 线程上挂起等待 ServerSocket.accept(),App 进程并没有被销毁。
📦 深入:TCP 只管传输,不管消费——为什么必须主动 read()?
一个常见误解:「连接建立了,数据不就被消费了吗?」——并不是。
TCP 做的事只是:把字节从发送方的内核发送缓冲区 ,搬到接收方的内核接收缓冲区 。搬完之后 TCP 的工作就结束了。数据躺在接收缓冲区里,等应用程序来主动取 。
类比:TCP 是快递员,接收缓冲区是门口的快递柜。快递员把包裹放进柜子之后,你得自己去开柜取包裹 ——快递员不会帮你搬进房间,也不知道你有没有取。柜子满了他就没地方放新包裹,只能在外面等(TCP 流量控制)。
所以 accept() 只是「接受了连接」,数据还在缓冲区里:
这也是 headers 必须全部读完的根本原因 :如果缓冲区里还有未读的 headers,缓冲区可能满,TCP 流量控制会让浏览器的发送窗口缩为 0,发送方卡住等接收方腾空间,而接收方又在等浏览器读响应——双方互相等待,死锁。读完所有 headers 是消除这个隐患的防御性写法。
第 5 步:用户在浏览器输入账号密码 这一步完全在浏览器内发生,App 对此一无所知,也看不到任何密码。
SSO 服务验证密码成功后,做两件事:
生成一个一次性 ticket(例如 ST-12345-abcdef-sso)
把浏览器 redirect 到:
1 http://127.0.0.1:54321/callback?ticket=ST-12345-abcdef-sso
这个 redirect 实际上是浏览器发起的一次 HTTP 请求,目标是本机的 ServerSocket。
💡深入:302 重定向到本机的完整过程
这里没有任何魔法,就是最普通的 HTTP 302 重定向。
用户点击「确认登录」后,浏览器向 SSO 服务提交表单(POST 请求)。SSO 服务验证密码正确后,不返回 200,而是返回 302 :
浏览器收到 302,会自动 向 Location 里的地址发起一个新的 GET 请求:
这个请求的目标是本机 127.0.0.1,走的是回环网卡(loopback) ,不经过任何网络,直接被同一台设备上正在 accept() 的 ServerSocket 接住。浏览器完全不知道对面是一个 Android App,它只认 HTTP 协议——只要对方在那个端口上,能建立 TCP 连接、返回合法的 HTTP 响应,浏览器就会处理它。
第 6 步:AuthCallbackServer 接收 ticket 就在用户登录的同时,App 在 Dispatchers.IO 上挂起等待:
1 2 3 4 5 6 7 8 9 10 11 12 13 override suspend fun awaitSsoCallback () : Result<Unit > { acquireWakeLock() return try { runCatching { val server = callbackServer ?: error("No SSO callback server running" ) val result = server.awaitCallback() callbackServer = null exchangeTicket(result.ticket, result.callbackUrl).getOrThrow() } } finally { releaseWakeLock() } }
WakeLock 的作用 :Android 系统可能在屏幕关闭时让 CPU 进入休眠,导致 ServerSocket.accept() 永远不会被唤醒。PARTIAL_WAKE_LOCK 让 CPU 保持运行,超时设置 6 分钟(略长于服务器的 5 分钟超时)。
awaitCallback() 内部的 HTTP 解析过程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 val requestLine = reader.readLine()while (true ) { val line = reader.readLine() ?: break if (line.isEmpty()) break } val ticket = parseQueryParam(queryString, "ticket" )sendResponse(writer, SUCCESS_HTML) CallbackResult(ticket = ticket, callbackUrl = "http://127.0.0.1:54321/callback" )
💡深入:ServerSocket 为什么能控制浏览器显示什么?
浏览器向 127.0.0.1:54321 发出 GET 请求后,会等待对方返回 HTTP 响应,再把响应 body 渲染出来 。AuthCallbackServer 里的 sendResponse() 手动拼了一个合法的 HTTP 响应:
浏览器收到这个响应,看到 Content-Type: text/html,就按 HTML 把 body 渲染出来——也就是用户看到的「登录成功,正在返回 myapp…」页面。AuthCallbackServer 扮演的角色就是一个只活一次的微型 HTTP 服务器 ,接完 ticket 就关门,顺手把成功页发给浏览器。
为什么 headers 必须全部读完? TCP 是双向流。浏览器把请求发完后,要等服务端把请求全部读走 ,才会开始等响应。如果 App 不消耗完 header 行就直接写响应,可能触发 TCP 流量控制,双方互相等待(死锁)。所以代码里有那段 while 循环,必须一行一行读到空行才能写响应。
callbackUrl 为什么要带回去? UserService 的 internal_login 接口需要用它做二次校验 ——确认 ticket 确实是颁发给这个地址的,防止 ticket 被其他人截获后拿来冒用。
第 7 步:用 ticket 换 Cookie(exchangeTicket) 这是整个流程的核心兑换步骤:
1 2 3 4 5 6 7 8 POST https: Content-Type: application/json { "ticket" : "ST-12345-abcdef-sso" , "validateService" : "http://127.0.0.1:54321/callback" , "subsystemAlias" : "userservice" }
UserService 收到请求后:
拿着 ticket 去 SSO 服务验证(这是服务端对服务端的校验,客户端看不到)
验证 validateService 与当初颁发 ticket 时记录的 service 一致
查出对应的用户信息
在 HTTP 响应头 里下发 Set-Cookie,在 响应体 里返回用户数据
响应示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 HTTP/1.1 200 OK Set-Cookie: app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly Content-Type: application/json { "data": { "name": "张三", "email": "zhangsan@example.com", "userNameAlias": "zhangsan", "userId": "xxx", "access-token": "eyJhbGciOi..." } }
兼容性处理 :部分 UserService 环境只在 JSON 里返回 access-token,不下发 Set-Cookie。CookieCodec.withAccessTokenFallback 会把 JSON 里的值补合成一条标准格式的 Set-Cookie:
1 2 "app-access-token-prod=eyJhbGciOi...; Path=/; Domain=.example.com; Secure; HttpOnly"
用户信息解析 :兼容两种响应格式
第 8 步:持久化登录态 凭据换取成功后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 saveSsoSession(cookies, user) _authSnapshot.value = AuthSnapshot( stage = Stage.SSO_AUTHENTICATED, ssoSession = SsoSession(cookies, user, updatedAt = System.currentTimeMillis()), gatewayCredential = null , ) refreshGatewayCredential()
存储的 key 一览:
1 2 3 4 5 "sso_cookies_json" → JSON 序列化的 Cookie 字符串列表 "user_snapshot_json" → JSON 序列化的用户信息 "auth_env" → 当前环境(PROD/BETA/SIT) "gateway_url" → Gateway 地址 "gateway_token" → Gateway token
第 9 步:状态回到 Idle,UI 跳转主界面 1 _ssoLoginState.value = SsoLoginState.Idle
LoginScreen 收到状态变化,跳转主界面。整个 SSO 流程结束。
四、各种失败场景的处理 场景 A:用户 5 分钟内没有完成登录 ServerSocket.soTimeout = 5 * 60 * 1000,超时后 accept() 抛出 SocketTimeoutException,被 runCatching 捕获,状态切为 Error,显示”登录失败”。
场景 B:用户在 SSO 页面点了取消/关闭了 WebView cancelSsoLoginIfWaiting() 被 UI 调用:
1 2 3 4 5 6 7 8 9 fun cancelSsoLoginIfWaiting () { if (!_ssoLoginState.value.isBusy()) return try { authRepository.cancelSsoLogin() } finally { ssoLoginJob?.cancel() _ssoLoginState.value = SsoLoginState.Idle } }
场景 C:ticket 已过期或被用过(一次性) UserService 返回非 2xx,error(“internal_login failed: ${response.code}”) 被 runCatching 捕获,状态切为 Error。
场景D:协程被取消(App 进后台被系统杀死等) CancellationException 被单独捕获,状态回 Idle 并重新 throw,确保协程取消语义正确:
1 2 3 4 } catch (e: CancellationException) { _ssoLoginState.value = SsoLoginState.Idle throw e }
五、下次启动如何恢复登录态(restoreSession) App 不是每次都要走完整的 SSO 流程。下次启动时:
1 2 3 4 5 1. 从 EncryptedSharedPreferences 读出 cookies + user 2. POST /auth/user_info(带上已存的 Cookie) → UserService 验证 Cookie 是否仍有效 3. 有效 → 直接恢复 SSO_AUTHENTICATED,跳过登录页 4. 无效 → clearAuth(),显示登录页,要求重新 SSO
旧版本兼容 :如果本地只有旧二维码凭据(gateway_url 有值但 sso_cookies_json 为空),说明是从旧版本升级来的用户,直接清除并强制重新登录。
六、一句话总结每个类的职责
类/文件
职责
LoginScreen.kt
按钮点击 → 调 ViewModel;收 SsoEffect → 打开浏览器
AuthViewModel
状态机控制;协程生命周期管理;并发保护
AuthRepositoryImpl
实现所有登录逻辑;持久化;协调各 API 类
AuthCallbackServer
在 127.0.0.1 开临时 HTTP 服务器,等浏览器回调,提取 ticket
AuthApi
封装两个 UserService HTTP 接口:internal_login(换 Cookie)和 user_info(校验 Cookie)
AuthEnvConfig
管理不同环境(PROD/BETA/SIT)的服务地址和 Cookie 名
CookieCodec
Set-Cookie 字符串的编解码工具;兜底合成缺失的 access token Cookie
SsoLoginState
登录流程的状态枚举
SsoEffect
UI 副作用通道(LaunchUrl / LaunchExternalUrl)
七、IM 登录(App-to-App)完整流程 与 SSO 登录的本质区别 SSO 登录的回调靠的是 HTTP redirect → 本机 ServerSocket ,整个过程在当前 App 进程里闭环。
IM 登录完全不同:App 跳出去,IM App 完成授权,再用 deep link 把 token 直接带回来 。没有 ServerSocket,没有 HTTP 服务器,中间 App 可以被系统暂停甚至进程重启,token 依然能安全回来。
第 1 步:用户点击「IM 登录」 1 authViewModel.startImAuthorization()
同样先做并发保护检查,忙碌状态直接忽略。通过后状态切到 LaunchingImAuthorization。
第 2 步:prepareImAuthorization —— 构造授权 URL ① 生成防重放的 state
1 2 val state = UUID.randomUUID().toString().replace("-" , "" )
state 的作用是防止 CSRF 攻击——确保回跳回来的数据确实是这次授权发出去的,不是别人伪造的。
② state 双重持久化
1 2 pendingImAuthorizationState = state prefs?.edit()?.putString(KEY_PENDING_IM_STATE, state)?.apply()
为什么要存磁盘?因为跳到 IM App 后,Android 系统可能把 MyApp 进程回收(内存不足)。用户在 IM App 里完成授权、回跳时进程重新启动,内存里的 state 已经没了,必须从磁盘读回来校验。
③ 构造 imapp://corp-im/auth?… URL
ImAuthorizationUrlCodec.buildAuthorizationUrl() 把所有参数编码进 URL:
1 2 3 4 5 6 7 8 9 10 11 12 imapp://corp-im/auth ?callback=myapp%3A%2F%2Fauth-callback%3Fstate%3D...%26provider%3Dhi &state=a1b2c3d4e5f6... &subsystem=myapp &source=android &mode=local_token &cookie_name=app-access-token-prod &client_name=MyApp &client_icon_url=https://...(myapp 的图标) &client_subtitle=使用当前 IM 账号继续访问 MyApp &client_body=MyApp 仅会使用本次授权完成企业登录和工作区连接。 &consent_version=myapp_mobile_v1
参数
含义
callback
授权完成后 IM App 把 token 带到这个 URL 回跳,scheme 是 myapp://
state
防重放标识,IM App 必须原样带回
mode=local_token
告诉 Hi:不走服务端接口,直接在本地把 token 放进回调 URL
cookie_name
告诉 Hi 要回传哪个 Cookie 的 token
client_*
IM 授权页展示的 App 信息(图标、名称、说明文字)
第 3 步:跳转 IM App 1 _ssoEffect.send(SsoEffect.LaunchExternalUrl(url))
UI 层收到后:
1 2 3 4 5 6 7 is SsoEffect.LaunchExternalUrl -> { try { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(effect.url))) } catch (e: ActivityNotFoundException) { authViewModel.handleImAuthorizationLaunchFailed() } }
imapp:// 是 IM App 注册的 URL scheme,Android 系统找到 IM App 并把它拉起来。此时 MyApp 退到后台,不需要等待,没有挂起的协程,没有 ServerSocket 。
第 4 步:在 IM App 里发生了什么 这部分完全在 IM App 内部,MyApp 代码看不到,但从参数可以推断:
IM App 解析 imapp://corp-im/auth?… 里的参数
展示授权确认页(用 client_name、client_icon_url、client_subtitle、client_body 渲染)
用户点击「同意授权」
IM App 从本地取出当前登录用户的 access token(mode=local_token 决定了这一步在本地完成,不走网络)
IM App 把 token 和用户信息拼入 callback URL,用 startActivity 打开它:
1 2 3 4 5 6 7 8 9 10 myapp://auth-callback ?state=a1b2c3d4e5f6... ← 原样带回,用于校验 &provider=im &email=zhangsan@example.com &userserviceAccessToken=eyJhbGci... ← access token &name=张三 &userNameAlias=zhangsan &avatar=https://... &userId=xxx &sessionId=yyy
第 5 步:MyApp 被 deep link 唤醒 myapp:// 是 MyApp 注册的 URL scheme。Android 系统收到这个 Intent,把 MyApp 拉到前台,把整个 URL 传给 MainActivity。
App 层拿到 callbackUrl 后调用:
1 authViewModel.handleImAuthorizationCallback(callbackUrl)
状态切到 HandlingImCallback。
第 6 步:parseCallback —— 校验 + 解析 ImAuthorizationUrlCodec.parseCallback() 做了一系列防御性检查,任何一步失败就返回 Failure :
1 2 3 4 5 6 7 8 9 10 11 12 13 ① scheme 必须是 myapp,host 必须是 auth-callback ↓ 通过 ② pendingState 不能为空(内存 or 磁盘读回) ↓ 通过 ③ provider 必须是 "im" ↓ 通过 ④ 检查 error / error_description 参数(IM 授权失败时会带这两个) ↓ 无错误 ⑤ state 必须与 pendingState 完全一致(防 CSRF 核心) ↓ 一致 ⑥ email 和 access token 不能为空 ↓ 都有值 ⑦ 返回 Success,携带所有字段
token 字段的多键名兼容 :IM App 不同版本回传的字段名可能不同,代码做了多键名兼容:
1 2 private val tokenKeys = listOf("userserviceAccessToken" , "access_token" , "accessToken" , "ssoAccessToken" )private val emailKeys = listOf("email" , "userEmail" , "mail" )
URL 参数位置的双重解析 :token 既可能在 query string(?key=value),也可能在 fragment(#?key=value),代码两个位置都解析,query 优先级高于 fragment:
1 2 3 4 5 private fun parseParams (uri: URI ) : Map<String, String> { val queryParams = parseQuery(uri.rawQuery) val fragmentParams = parseFragment(uri.rawFragment) return fragmentParams + queryParams }
第 7 步:构造 Cookie(IM 登录专有) 与 SSO 登录最大的区别在这里:SSO 的 Cookie 由 UserService 服务端下发,IM 登录的 Cookie 由客户端本地构造 。
1 2 3 4 5 6 7 private fun buildImAuthorizationCookies (result: ImAuthorizationCallbackResult .Success ) : List<String> = buildList { add("${result.cookieName} =${result.accessToken} ; Path=/; Domain=.example.com; Secure; HttpOnly" ) if (result.sessionId.isNotBlank()) { add("session_id=${result.sessionId} ; Path=/; Domain=.example.com; Secure; HttpOnly" ) } }
构造完之后,持久化 + 更新状态 + 拉 Gateway 凭据,和 SSO 登录完全一样。
IM 登录 vs SSO 登录核心差异
对比维度
SSO 登录
IM 登录
用户操作
在浏览器输账号密码
在 IM App 点「同意授权」
回调机制
HTTP redirect → 本机 ServerSocket
deep link → MainActivity
token 来源
UserService 服务端 Set-Cookie 下发
IM App 本地填入回调 URL
进程存活要求
必须一直活着(WakeLock 保活)
可被杀死,state 持久化到磁盘
超时机制
5 分钟 ServerSocket 超时
无超时,依赖 Hi 响应
防重放
靠本机端口唯一性
UUID state + 磁盘持久化双重校验
八、URL Scheme 注册与系统路由机制 注册方式:AndroidManifest.xml 中的 Intent Filter App 安装时,Android 系统扫描 AndroidManifest.xml,把所有 <intent-filter> 登记进系统的包管理器数据库 (PackageManager)。MyApp 在 MainActivity 上声明了两条 deep link:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <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 ="auth-callback" /> </intent-filter > <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 ="auth" android:pathPrefix ="/callback" /> </intent-filter >
两条都用 myapp://,靠 host 区分用途:
auth-callback → IM 登录回调
auth + /callback 路径前缀 → SSO 相关回调
系统如何路由这个 URL? IM App 完成授权后,执行的代码等价于:
1 2 3 val intent = Intent(Intent.ACTION_VIEW, Uri.parse("myapp://auth-callback?state=xxx&..." ))startActivity(intent)
Android 系统收到后按以下流程处理:
1 2 3 4 5 1. 从 Intent 里提取:action=VIEW,scheme=myapp,host=auth-callback 2. 查 PackageManager:谁注册了能处理这个 Intent 的 Activity? 3. 找到 MyApp 的 MainActivity 4. 进程还活着 → 拉到前台,调 onNewIntent(intent) 进程被杀了 → 重启进程,调 onCreate(intent)
launchMode="singleTop" 的关键作用1 2 3 4 <activity android:name =".MainActivity" android:launchMode ="singleTop" ← 这里 ... >
singleTop 的意思:如果返回栈顶已经有一个 MainActivity 实例,不新建,直接复用 ,通过 onNewIntent() 把新 Intent 传进来。
如果用默认的 standard 模式,每次 deep link 回来都会创建新的 MainActivity 实例,用户返回时会出现异常的页面栈。
<queries> 声明是干什么的?1 2 3 4 5 6 <queries > <intent > <action android:name ="android.intent.action.VIEW" /> <data android:scheme ="imapp" /> </intent > </queries >
这是反向 的:MyApp 要跳出去 打开 IM App(imapp://)。Android 11+ 引入了包可见性限制——App 默认看不到其他 App 的存在。声明 <queries> 之后,MyApp 才能:
通过 packageManager.resolveActivity() 检查 IM App 是否已安装
用 startActivity(Intent(ACTION_VIEW, Uri.parse("imapp://..."))) 成功跳转
不加这个声明,startActivity 会直接抛 ActivityNotFoundException,触发代码里 IM App 未安装的兜底分支。
两个 App 的双向关系 1 2 3 4 5 6 7 8 9 10 11 12 13 MyApp IM App │ │ │ 注册 intent-filter: │ 注册 intent-filter: │ myapp://auth-callback │ imapp://corp-im/auth │ │ │ <queries> 声明能看见 imapp:// │ │ │ │──── startActivity(imapp://...) ───────>│ 被系统拉起,展示授权页 │ │ │<─── startActivity(myapp://...) ────────│ 授权完成,回跳 │ │ │ onNewIntent() 收到 deep link │ │ parseCallback() 校验 state │
系统扮演的角色是路由器 :谁注册了能处理这个 URL scheme 的 Activity,Intent 就发给谁。两个 App 通过 scheme 互相「拨号」,系统负责「接线」。