mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
co-authored by
Claude Opus 5.5
parent
5942b427a9
commit
b534a96f2f
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user