feat(flow): on-demand host handoff with reliable return and a back-tap tip

Keyboard
- Only jump to the app when the voice surface is shown (tab tap / opened
  there); typing tabs no longer leave the host on keyboard open.
- Wait up to 0.4 s for the arbiter to report the host app before leaving,
  and late-bind an id that arrives within 6 s of the jump.
- Empty arbiter callbacks no longer wipe an already captured host id;
  install the capture handler in every build configuration.

App
- Return to the previous app with up to four retries using iOS's real
  open result, and honor the skip-app-switch preference.
- Keep the pending host id across cold-start zombie reconciliation, and
  expire it after two minutes (legacy values without a timestamp stay valid).
- Show a light tip pointing at the system back control when we cannot
  return automatically ("点击返回,立即使用").

Tests: TTL behaviour and return retry policy.
This commit is contained in:
Rocky
2026-10-29 16:32:52 +08:00
parent 4dce67df84
commit e09ad34437
17 changed files with 454 additions and 63 deletions
@@ -52,4 +52,36 @@ final class FlowSessionPolicyTests: XCTestCase {
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
}
func testPendingHostBundleIdExpiresAfterTTL() {
let defaults = makeDefaults()
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
let justInside = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL - 5)
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: justInside),
"com.tencent.xin"
)
let pastTTL = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL + 5)
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: pastTTL))
}
func testPendingHostBundleIdWithoutTimestampStaysValid() {
// Written by an older build: no timestamp key. Must keep working.
let defaults = makeDefaults()
defaults.set("com.tencent.xin", forKey: FlowSessionKeys.pendingHostBundleId)
let farFuture = Date().addingTimeInterval(FlowSessionKeys.pendingHostBundleIdTTL * 10)
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults, now: farFuture),
"com.tencent.xin"
)
}
func testClearingPendingHostBundleIdAlsoClearsTimestamp() {
let defaults = makeDefaults()
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
XCTAssertNil(defaults.object(forKey: FlowSessionKeys.pendingHostBundleIdAt))
}
}