feat(ios): open notification taps on the owning hub

Push payloads do not name a hub, so a tap always opened the session
against the active hub. With several paired hubs, a session from
another hub then failed to load until the user switched hubs by hand.

When the active hub's list does not know the session, the tap now
looks it up with GET /api/sessions/:id. Hubs are tried in the same
active-first order, and 404/403 have the same meaning, as for the
notification actions. If another hub owns the session, the app
switches to it first; the new home screen then opens the chat. A miss
or an unreachable hub opens against the active hub, as before.
Single-hub installs skip the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
weishu
2026-10-28 18:00:00 +08:00
co-authored by Claude Opus 5.5
parent 5942b427a9
commit b534a96f2f
5 changed files with 151 additions and 12 deletions
+5 -4
View File
@@ -171,10 +171,11 @@ FCM delivery uses an unencrypted notification data payload; direct APNs remains
encrypted. See [Notifications](./notifications.md#native-app-notifications)
and the [push contract](../api/native-companion-contract.md) for configuration.
Push registration covers every paired hub. Notification taps currently open
the session against the active hub; if the session cannot be found, switch to
its owning hub. Background notification actions try paired hubs to resolve
the session.
Push registration covers every paired hub. On iOS, a notification tap finds
the hub that owns the session (active hub first) and switches to it before
opening the chat. Android opens the session against the active hub; if it
cannot be found, switch to its owning hub. Background notification actions on
both platforms try paired hubs to resolve the session.
## Troubleshooting
+17 -2
View File
@@ -46,7 +46,8 @@ final class AppModel {
var infoNotice: String?
/// Session targeted by a notification tap (Android
/// `pendingOpenSessionId`): the home screen consumes it into its
/// navigation path against the active hub.
/// navigation path against the active hub (switched to the owner first,
/// see ``openNotificationTarget(sessionId:hubUrl:)``).
var pendingOpenSessionId: String?
// `let` storage is inert under @Observable (no annotation needed).
@@ -78,7 +79,12 @@ final class AppModel {
credentialStore: credentialStore,
performer: performer,
openChatSessionId: { [weak self] in self?.session?.openChatSessionId },
openSession: { [weak self] sessionId in self?.pendingOpenSessionId = sessionId }
activeHubKnowsSession: { [weak self] sessionId in
self?.session?.sessionStore.sessions.contains { $0.id == sessionId } ?? false
},
openSession: { [weak self] sessionId, hubUrl in
self?.openNotificationTarget(sessionId: sessionId, hubUrl: hubUrl)
}
))
}
@@ -151,6 +157,15 @@ final class AppModel {
}
}
/// Notification tap: a located owning hub becomes active first, so the
/// new home screen consumes the target (`HomeView` is keyed by hub).
func openNotificationTarget(sessionId: String, hubUrl: String?) {
if let hubUrl, registry.hubs.contains(hubUrl), hasCredentials(for: hubUrl) {
switchHub(to: hubUrl)
}
pendingOpenSessionId = sessionId
}
// MARK: - Deep links (hapicompanion://bind)
/// Routes a parsed bind link: already-paired hubs just switch (with a
+29 -4
View File
@@ -68,9 +68,12 @@ final class PushCoordinator: NSObject {
/// Session id of the chat currently on screen, for the one
/// suppression rule (`shouldSuppressPush` on Android).
let openChatSessionId: @MainActor () -> String?
/// Tap-through navigation: stash the target session for the home
/// screen's navigation path (Android `pendingOpenSessionId`).
let openSession: @MainActor (String) -> Void
/// The active hub's session list already has this id (no lookup).
let activeHubKnowsSession: @MainActor (String) -> Bool
/// Tap-through navigation: switch to the owning hub when one was
/// located (nil = stay on the active hub), then stash the target
/// session for the home screen (Android `pendingOpenSessionId`).
let openSession: @MainActor (_ sessionId: String, _ hubUrl: String?) -> Void
}
// MARK: - Observable state (Settings row)
@@ -356,7 +359,7 @@ extension PushCoordinator: UNUserNotificationCenterDelegate {
}
switch response.actionIdentifier {
case UNNotificationDefaultActionIdentifier:
environment?.openSession(payload.sessionId)
await openSession(payload.sessionId)
case ActionID.allow:
guard payload.type == .permissionRequest, payload.supportsActions,
let requestId = payload.requestId else { return }
@@ -390,6 +393,20 @@ extension PushCoordinator: UNUserNotificationCenterDelegate {
}
}
/// Tap-through. The payload carries no hub, so with several paired hubs
/// a session the active list does not know is located first (active hub
/// first, like the actions). A miss or an unreachable owner falls back
/// to the active hub.
private func openSession(_ sessionId: String) async {
guard let environment else { return }
var owner: String?
if hubsActiveFirst().count > 1, !environment.activeHubKnowsSession(sessionId),
case .success(let hubUrl) = await actionRunner().locate(sessionId: sessionId) {
owner = hubUrl
}
environment.openSession(sessionId, owner)
}
private func actionRunner() -> PushActionRunner {
PushActionRunner(
hubs: hubsActiveFirst(),
@@ -490,6 +507,14 @@ struct PushActionRunner {
}
}
/// `GET /sessions/:id` — `.success` names the hub that owns the session
/// (notification tap-through).
func locate(sessionId: String) async -> PushActionOutcome {
await resolveAcrossHubs { api in
_ = try await api.session(id: sessionId)
}
}
private func resolveAcrossHubs(
_ call: (APIClient) async throws -> Void
) async -> PushActionOutcome {
@@ -0,0 +1,94 @@
import HapiClient
import XCTest
@testable import Hapi
@testable import HapiProtocol
/// Notification tap-through across paired hubs: the payload carries no hub,
/// so the owner is located (active hub first) before navigating. Fake HTTP
/// and in-memory credentials; no Keychain or network.
@MainActor
final class NotificationRoutingTests: XCTestCase {
private let hubA = "https://a.example.com"
private let hubB = "https://b.example.com"
/// Hub B owns `s-b`; hub A answers 404 for it. Hub C is offline.
private actor OwnerHTTP: HTTPPerforming {
private(set) var sessionLookups: [String] = []
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let url = try XCTUnwrap(request.url)
let host = url.host ?? ""
if url.path.hasPrefix("/api/sessions/") {
sessionLookups.append(host)
}
if host == "c.example.com" { throw URLError(.cannotConnectToHost) }
let owns = host == "b.example.com" && url.lastPathComponent == "s-b"
let status = owns ? 200 : 404
let body = owns
? #"{"session":{"id":"s-b","namespace":"default","seq":1,"createdAt":1,"updatedAt":1,"active":true,"activeAt":1,"metadata":null,"metadataVersion":1,"agentState":null,"agentStateVersion":1,"thinking":false,"thinkingAt":0}}"#
: #"{"error":"Session not found"}"#
let response = try XCTUnwrap(HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil))
return (Data(body.utf8), response)
}
}
/// A JWT the auth manager accepts without refreshing (expires in 2100).
private static let jwt: String = {
func encode(_ json: String) -> String {
Data(json.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
return encode(#"{"alg":"HS256","typ":"JWT"}"#) + "."
+ encode(#"{"uid":1,"ns":"default","iat":1700000000,"exp":4102444800}"#) + ".signature"
}()
private func credentials(_ hubs: [String]) throws -> InMemoryCredentialStore {
let store = InMemoryCredentialStore()
for hub in hubs {
try store.store(HubCredentials(hubUrl: hub, accessToken: "token", jwt: Self.jwt))
}
return store
}
func testLocateSkipsHubsThatDoNotKnowTheSession() async throws {
let http = OwnerHTTP()
let runner = PushActionRunner(
hubs: [hubA, "https://c.example.com", hubB],
credentialStore: try credentials([hubA, "https://c.example.com", hubB]),
performer: http
)
guard case .success(let owner) = await runner.locate(sessionId: "s-b") else {
return XCTFail("hub B owns the session")
}
XCTAssertEqual(owner, hubB)
let missing = await runner.locate(sessionId: "s-gone")
guard case .transient = missing else {
return XCTFail("an unreachable hub keeps the miss inconclusive, got \(missing)")
}
let lookups = await http.sessionLookups
XCTAssertEqual(Array(lookups.prefix(3)), ["a.example.com", "c.example.com", "b.example.com"])
}
func testTapSwitchesToTheOwningHubBeforeOpening() throws {
let defaults = try XCTUnwrap(UserDefaults(suiteName: "NotificationRoutingTests-\(UUID().uuidString)"))
let registry = HubRegistry(defaults: defaults)
registry.register(hubA)
registry.register(hubB)
registry.setActiveHub(hubA)
let model = AppModel(registry: registry, credentialStore: try credentials([hubA, hubB]), performer: OwnerHTTP())
XCTAssertEqual(model.state, .paired(activeHub: hubA))
model.openNotificationTarget(sessionId: "s-b", hubUrl: hubB)
XCTAssertEqual(model.state, .paired(activeHub: hubB))
XCTAssertEqual(model.pendingOpenSessionId, "s-b")
// No located owner (or an unpaired one): stay on the active hub.
model.openNotificationTarget(sessionId: "s-x", hubUrl: nil)
model.openNotificationTarget(sessionId: "s-y", hubUrl: "https://unpaired.example.com")
XCTAssertEqual(model.state, .paired(activeHub: hubB))
XCTAssertEqual(model.pendingOpenSessionId, "s-y")
}
}
+6 -2
View File
@@ -200,8 +200,12 @@ provider credentials as described below.
(active hub first, then the roster; 404 "Session not found" / 403 = try
the next hub) — approve/deny post `{}`, reply posts `{text, localId}`.
Failures surface as a local notice instead of vanishing. A tap deep-links
to the session chat; a push for the chat currently on screen is suppressed
(the in-app SSE stream is already showing it).
to the session chat. With several paired hubs and a session the active
list does not know, the tap first locates the owner with
`GET /api/sessions/:id` (same hub order and 404/403 rules) and switches
to it; a miss or unreachable owner opens against the active hub. A push
for the chat currently on screen is suppressed (the in-app SSE stream is
already showing it).
**Layer map**: `HapiClient/Push/` (envelope + payload + key, Linux-tested),
`Endpoints/DeviceEndpoints.swift` (register/unregister),