diff --git a/docs/guide/native-apps.md b/docs/guide/native-apps.md index ff0ec0fd..7af44276 100644 --- a/docs/guide/native-apps.md +++ b/docs/guide/native-apps.md @@ -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 diff --git a/ios/Hapi/Models/AppModel.swift b/ios/Hapi/Models/AppModel.swift index 198de178..748513ee 100644 --- a/ios/Hapi/Models/AppModel.swift +++ b/ios/Hapi/Models/AppModel.swift @@ -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 diff --git a/ios/Hapi/Models/PushCoordinator.swift b/ios/Hapi/Models/PushCoordinator.swift index 9504f819..209b4311 100644 --- a/ios/Hapi/Models/PushCoordinator.swift +++ b/ios/Hapi/Models/PushCoordinator.swift @@ -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 { diff --git a/ios/HapiTests/NotificationRoutingTests.swift b/ios/HapiTests/NotificationRoutingTests.swift new file mode 100644 index 00000000..0825a1e9 --- /dev/null +++ b/ios/HapiTests/NotificationRoutingTests.swift @@ -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") + } +} diff --git a/ios/README.md b/ios/README.md index d9fb112b..ffbcd954 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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),