~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1

Push registers with registerPush, falling back on servers without it

Merged chadtmiller.com asked to merge into main from grain.social/grain-ios:push/register-push
Grain/API/PushManager.swift+64−11
@@ -5,7 +5,7 @@ import UserNotifications
private let logger = Logger(subsystem: "social.grain.grain", category: "Push")
/// Manages push notification registration and token delivery to the hatk server.
/// Manages push notification registration and token delivery to the server.
@Observable
@MainActor
final class PushManager: NSObject {
@@ -67,11 +67,19 @@ final class PushManager: NSObject {
currentToken = nil
let client = XRPCClient(baseURL: AuthManager.serverURL)
do {
try await client.procedure(
"dev.hatk.push.unregisterToken",
input: UnregisterTokenInput(token: token),
auth: auth
)
do {
try await client.procedure(
"social.grain.notification.unregisterPush",
input: PushRegistrationInput(token: token),
auth: auth
)
} catch let error where Self.isMethodMissing(error) {
try await client.procedure(
"dev.hatk.push.unregisterToken",
input: UnregisterTokenInput(token: token),
auth: auth
)
}
logger.info("Push token unregistered from server")
} catch {
logger.error("Failed to unregister push token: \(error)")
@@ -95,16 +103,61 @@ final class PushManager: NSObject {
let client = authManager.makeClient()
do {
try await client.procedure(
"dev.hatk.push.registerToken",
input: RegisterTokenInput(token: token, platform: "apns"),
auth: auth
)
do {
try await client.procedure(
"social.grain.notification.registerPush",
input: PushRegistrationInput(token: token),
auth: auth
)
} catch let error where Self.isMethodMissing(error) {
try await client.procedure(
"dev.hatk.push.registerToken",
input: RegisterTokenInput(token: token, platform: "apns"),
auth: auth
)
}
logger.info("Push token registered with server")
} catch {
logger.error("Failed to register push token: \(error)")
}
}
/// Whether the server answered that it does not serve the method at all,
/// as a server without `social.grain.notification.registerPush` does. Only
/// then is the older `registerToken` call tried; any other failure, such as
/// a 401 or a rejected input, is the answer.
nonisolated static func isMethodMissing(_ error: Error) -> Bool {
guard case let XRPCError.httpError(statusCode, body) = error else { return false }
if statusCode == 404 || statusCode == 501 {
return true
}
guard let body,
let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any],
let name = object["error"] as? String
else { return false }
return name == "MethodNotImplemented" || name == "MethodNotFound"
}
}
/// The input of `social.grain.notification.registerPush` and `unregisterPush`,
/// shaped as Bluesky's `app.bsky.notification.registerPush`.
struct PushRegistrationInput: Encodable, Equatable {
/// The service that sends the pushes.
static let grainServiceDid = "did:web:grain.social"
/// The bundle id APNs pushes are addressed to.
static let grainAppId = "social.grain.grain"
let serviceDid: String
let token: String
let platform: String
let appId: String
init(token: String) {
serviceDid = Self.grainServiceDid
self.token = token
platform = "ios"
appId = Self.grainAppId
}
}
private struct RegisterTokenInput: Encodable {
GrainTests/NetworkSideEffectTests.swift+162−18
@@ -31,6 +31,16 @@ private final class SharedSessionLog: @unchecked Sendable {
var lastBody: [String: Any]? {
guard let request = last else { return nil }
return Self.body(of: request)
}
/// The JSON body of the latest request to `path`.
func body(at path: String) -> [String: Any]? {
guard let request = lock.withLock({ requests.last { $0.url?.path == path } }) else { return nil }
return Self.body(of: request)
}
private static func body(of request: URLRequest) -> [String: Any]? {
var data = request.httpBody
if data == nil, let stream = request.httpBodyStream {
stream.open()
@@ -84,6 +94,18 @@ final class NetworkSideEffectTests: GrainTestCase {
}
}
/// Answers requests to the paths given as given, and anything else with
/// an empty 200.
private func respond(_ answers: [String: (status: Int, body: String)]) {
let log = log!
MockURLProtocol.handler = { request in
log.record(request)
let answer = answers[request.url?.path ?? ""] ?? (200, "{}")
let response = HTTPURLResponse(url: request.url!, statusCode: answer.status, httpVersion: nil, headerFields: nil)!
return (Data(answer.body.utf8), response)
}
}
// MARK: - Reverse geocoding
private static let nominatimPlace = """
@@ -211,8 +233,31 @@ final class NetworkSideEffectTests: GrainTestCase {
// MARK: - Push token registration
/// APNs hands back a token as raw bytes; the server wants it hex-encoded.
func testTheAPNsTokenIsSentToTheServerAsHex() async {
private static let registerPush = "/xrpc/social.grain.notification.registerPush"
private static let unregisterPush = "/xrpc/social.grain.notification.unregisterPush"
private static let registerToken = "/xrpc/dev.hatk.push.registerToken"
private static let unregisterToken = "/xrpc/dev.hatk.push.unregisterToken"
/// The answers a server gives for a method it does not serve, each of
/// which sends the app to the older call.
private static let methodMissingAnswers: [(status: Int, body: String)] = [
(404, "Not Found"),
(501, #"{"error":"MethodNotImplemented"}"#),
(400, #"{"error":"MethodNotImplemented","message":"Method Not Implemented"}"#),
(400, #"{"error":"MethodNotFound"}"#),
]
/// Answers that are about the request rather than the method, after
/// which the older call is not tried.
private static let otherFailures: [(status: Int, body: String)] = [
(401, #"{"error":"AuthenticationRequired"}"#),
(400, #"{"error":"InvalidRequest","message":"Input/token must be a string"}"#),
(500, "nope"),
]
/// APNs hands back a token as raw bytes; the server wants it hex-encoded,
/// in `registerPush` shaped as Bluesky's.
func testTheAPNsTokenIsRegisteredWithRegisterPush() async {
respond("{}")
let manager = PushManager()
// Held in a local: `PushManager.authManager` is weak, so an inline
@@ -223,14 +268,62 @@ final class NetworkSideEffectTests: GrainTestCase {
manager.didRegisterForRemoteNotifications(deviceToken: Data([0xDE, 0xAD, 0xBE, 0xEF]))
await waitForRequest()
XCTAssertEqual(log.paths.last, "/xrpc/dev.hatk.push.registerToken")
XCTAssertEqual(log.lastBody?["token"] as? String, "deadbeef")
XCTAssertEqual(log.lastBody?["platform"] as? String, "apns")
XCTAssertEqual(log.paths, [Self.registerPush])
XCTAssertEqual(log.last?.httpMethod, "POST")
XCTAssertEqual(log.last?.value(forHTTPHeaderField: "Content-Type"), "application/json")
let body = log.lastBody
XCTAssertEqual(body?.count, 4)
XCTAssertEqual(body?["serviceDid"] as? String, "did:web:grain.social")
XCTAssertEqual(body?["token"] as? String, "deadbeef")
XCTAssertEqual(body?["platform"] as? String, "ios")
XCTAssertEqual(body?["appId"] as? String, "social.grain.grain")
}
/// A server without `registerPush` gets the older call, exactly as before.
func testRegistrationFallsBackWhenTheServerLacksRegisterPush() async {
for answer in Self.methodMissingAnswers {
log = SharedSessionLog()
respond([Self.registerPush: answer])
let manager = PushManager()
let auth = AuthManager()
manager.configure(authManager: auth)
manager.didRegisterForRemoteNotifications(deviceToken: Data([0xDE, 0xAD]))
await waitForRequest(expecting: 2)
let label = "\(answer.status) \(answer.body)"
XCTAssertEqual(log.paths, [Self.registerPush, Self.registerToken], label)
let body = log.body(at: Self.registerToken)
XCTAssertEqual(body?.count, 2, label)
XCTAssertEqual(body?["token"] as? String, "dead", label)
XCTAssertEqual(body?["platform"] as? String, "apns", label)
}
}
/// Any other failure is the answer: the older call is not tried, and the
/// failure is swallowed, since there is nothing the user can do and it
/// must not take the launch down with it.
func testAFailedRegistrationIsSwallowedWithoutFallingBack() async {
for answer in Self.otherFailures {
log = SharedSessionLog()
respond([Self.registerPush: answer])
let manager = PushManager()
let auth = AuthManager()
manager.configure(authManager: auth)
manager.didRegisterForRemoteNotifications(deviceToken: Data([0x01]))
await waitForRequest()
try? await Task.sleep(for: .milliseconds(300))
let label = "\(answer.status) \(answer.body)"
XCTAssertEqual(log.paths.first, Self.registerPush, label)
XCTAssertFalse(log.paths.contains(Self.registerToken), label)
}
}
/// Signing out has to withdraw the token, or the device keeps getting the
/// previous account's notifications.
func testSigningOutUnregistersTheToken() async {
func testSigningOutUnregistersTheTokenWithUnregisterPush() async {
respond("{}")
let manager = PushManager()
let auth = AuthManager()
@@ -245,22 +338,73 @@ final class NetworkSideEffectTests: GrainTestCase {
}
await manager.unregisterToken(auth: context)
XCTAssertEqual(log.paths.last, "/xrpc/dev.hatk.push.unregisterToken")
XCTAssertEqual(log.lastBody?["token"] as? String, "0102")
XCTAssertEqual(log.paths.last, Self.unregisterPush)
let body = log.lastBody
XCTAssertEqual(body?.count, 4)
XCTAssertEqual(body?["serviceDid"] as? String, "did:web:grain.social")
XCTAssertEqual(body?["token"] as? String, "0102")
XCTAssertEqual(body?["platform"] as? String, "ios")
XCTAssertEqual(body?["appId"] as? String, "social.grain.grain")
}
/// A registration the server rejects is swallowed — there is nothing the
/// user can do, and it must not take the launch down with it.
func testAFailedRegistrationIsSwallowed() async {
respond("nope", status: 500)
let manager = PushManager()
let auth = AuthManager()
manager.configure(authManager: auth)
func testUnregisteringFallsBackWhenTheServerLacksUnregisterPush() async {
for answer in Self.methodMissingAnswers {
log = SharedSessionLog()
respond([Self.registerPush: answer, Self.unregisterPush: answer])
let manager = PushManager()
let auth = AuthManager()
manager.configure(authManager: auth)
manager.didRegisterForRemoteNotifications(deviceToken: Data([0x01, 0x02]))
await waitForRequest(expecting: 2)
guard let context = await auth.authContext() else {
return XCTFail("The signed-in account should have an auth context")
}
manager.didRegisterForRemoteNotifications(deviceToken: Data([0x01]))
await waitForRequest()
await manager.unregisterToken(auth: context)
let label = "\(answer.status) \(answer.body)"
XCTAssertEqual(Array(log.paths.suffix(2)), [Self.unregisterPush, Self.unregisterToken], label)
let body = log.body(at: Self.unregisterToken)
XCTAssertEqual(body?.count, 1, label)
XCTAssertEqual(body?["token"] as? String, "0102", label)
}
}
func testAFailedUnregistrationDoesNotFallBack() async {
for answer in Self.otherFailures {
log = SharedSessionLog()
respond([Self.unregisterPush: answer])
let manager = PushManager()
let auth = AuthManager()
manager.configure(authManager: auth)
manager.didRegisterForRemoteNotifications(deviceToken: Data([0x01, 0x02]))
await waitForRequest()
guard let context = await auth.authContext() else {
return XCTFail("The signed-in account should have an auth context")
}
await manager.unregisterToken(auth: context)
XCTAssertEqual(log.paths.last, "/xrpc/dev.hatk.push.registerToken")
let label = "\(answer.status) \(answer.body)"
XCTAssertEqual(log.paths.last, Self.unregisterPush, label)
XCTAssertFalse(log.paths.contains(Self.unregisterToken), label)
}
}
func testOnlyAMissingMethodCountsAsMissing() {
func error(_ status: Int, _ body: String?) -> Error {
XRPCError.httpError(statusCode: status, body: body.map { Data($0.utf8) })
}
XCTAssertTrue(PushManager.isMethodMissing(error(404, nil)))
XCTAssertTrue(PushManager.isMethodMissing(error(501, nil)))
XCTAssertTrue(PushManager.isMethodMissing(error(400, #"{"error":"MethodNotImplemented"}"#)))
XCTAssertTrue(PushManager.isMethodMissing(error(400, #"{"error":"MethodNotFound"}"#)))
XCTAssertFalse(PushManager.isMethodMissing(error(400, #"{"error":"InvalidRequest"}"#)))
XCTAssertFalse(PushManager.isMethodMissing(error(400, "garbage")))
XCTAssertFalse(PushManager.isMethodMissing(error(400, nil)))
XCTAssertFalse(PushManager.isMethodMissing(error(500, nil)))
XCTAssertFalse(PushManager.isMethodMissing(XRPCError.unauthorized))
XCTAssertFalse(PushManager.isMethodMissing(URLError(.notConnectedToInternet)))
}
/// The manager only holds its `AuthManager` weakly, so a token arriving