commit d0781a9f3b9b901dda501f2c6c2ef4e16be3e637 Author: WRNexus SDK Publisher Date: Fri Oct 9 14:27:00 2026 +0530 Release recipient inbox SDKs 0.1.0 preview diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..332e050 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WRNexus contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ce0de40 --- /dev/null +++ b/Package.swift @@ -0,0 +1,3 @@ +// swift-tools-version: 5.9 +import PackageDescription +let package = Package(name: "WRNexusInbox", platforms: [.iOS(.v15), .macOS(.v12)], products: [.library(name: "WRNexusInbox", targets: ["WRNexusInbox"])], targets: [.target(name: "WRNexusInbox"), .testTarget(name: "WRNexusInboxTests", dependencies: ["WRNexusInbox"])]) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2d520e --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# WRNexusInbox 0.1.0 preview + +SwiftPM recipient client and SwiftUI `InboxView`, supporting iOS 15+ and macOS 12+. Distribution uses a versioned Git package; the release report records the published repository when available. + +```swift +import WRNexusInbox +let inbox = try InboxClient(token: { try await yourBackend.fetchRecipientToken() }) +InboxView(client: inbox) { message, action in + // Handle custom action.id. HTTPS links are handled by SwiftUI Link. +} +``` + +Your server issues a recipient token through the Notifications business API. Never embed a business API key. The token provider runs before each request/reconnect, allowing refresh. Configure a verified custom domain through `endpoint:`. API errors expose `InboxError.code`, `status` and `message`. + +Async methods: `messages(limit:)`, `markRead(_:)`, `archive(_:)`, `preferences()`, `savePreferences(_:)`, `devices()`, `registerMobilePush(_:)`, `unregisterMobilePush(_:)`, `unregisterWebPush(_:)`, and `updates()`. Cancel the task consuming `updates()` on pause/disappear; the component manages its own subscription. Null quiet-hours/marketing-limit settings explicitly clear saved delivery rules. Omitted preference fields preserve existing settings. Writes are not retried. + +Configure an active APNs or FCM connection in Notifications. The host requests `UNUserNotificationCenter` permission and registers with APNs or initializes Firebase. Convert the APNs `Data` device token to lowercase hexadecimal using `deviceToken.map { String(format: "%02x", $0) }.joined()`. Persist a random installation ID in the app's private storage and reuse it when the provider rotates the token. Pass `MobileRegistration(connectionId:installationId:name:token:marketingConsent:consentEvidence:)`; the platform defaults to `ios`. Retain the returned device ID per recipient. Marketing consent must reflect the recipient's real choice. + +On logout, unregister the device ID for that recipient, remove their view, cancel subscription tasks and call `close()`. If unregister fails, have your backend deactivate the device. Create a new client for each new recipient. The host owns provider configuration, consent, permissions and account storage. + +`swift test` verifies Foundation models, requests, errors, SSE parsing and logout behavior. Linux tests do not compile SwiftUI or prove iOS simulator/device behavior. Validate `InboxView` with an Apple SDK before a stable release; real APNs/FCM delivery remains a device acceptance check. + +SwiftPM install: `.package(url: "https://git.workroot.in/Clintchiz/WRNexus-Mobile-SDKs.git", exact: "0.1.0")`. Flutter: Git dependency at the same URL, ref `0.1.0`, path `flutter`. diff --git a/Sources/WRNexusInbox/InboxClient.swift b/Sources/WRNexusInbox/InboxClient.swift new file mode 100644 index 0000000..a064d11 --- /dev/null +++ b/Sources/WRNexusInbox/InboxClient.swift @@ -0,0 +1,127 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +private final class InboxRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + static let shared=InboxRedirectPolicy() + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {completionHandler(nil)} +} + +/// Recipient-only client. Fetch recipient tokens from your own backend; never embed business API keys. +public final class InboxClient: @unchecked Sendable { + public let endpoint: URL + private let token: @Sendable () async throws -> String + private let session: URLSession + private let timeout: TimeInterval + private let lock=NSLock() + private var closed=false + private var cancellers=[UUID: @Sendable () -> Void]() + public init(endpoint: URL = URL(string:"https://notifications.wrnexus.com")!, timeout: TimeInterval = 15, session: URLSession = .shared, token: @escaping @Sendable () async throws -> String) throws { + guard Self.validEndpoint(endpoint), timeout > 0 else { throw InboxError(code:"invalid-endpoint",message:"Use an HTTPS origin or localhost for development, with a positive timeout.") } + self.endpoint=endpoint; self.timeout=timeout; self.session=session; self.token=token + } + private static func validEndpoint(_ u: URL) -> Bool { + guard u.host != nil, u.user == nil, u.password == nil, u.query == nil, u.fragment == nil, ["","/"].contains(u.path) else { return false } + return u.scheme == "https" || (u.scheme == "http" && ["localhost","127.0.0.1","[::1]","::1"].contains(u.host!)) + } + public func close() { lock.lock(); closed=true; let callbacks=Array(cancellers.values); cancellers.removeAll(); lock.unlock(); for cancel in callbacks {cancel()} } + private func track(_ id: UUID, _ cancel: @escaping @Sendable () -> Void) {lock.lock(); let stop=closed; if !stop {cancellers[id]=cancel}; lock.unlock(); if stop {cancel()} } + private func untrack(_ id: UUID) {lock.lock();cancellers.removeValue(forKey:id);lock.unlock()} + private func isClosed() -> Bool { lock.lock(); defer {lock.unlock()}; return closed } + private func request(_ path: String, method: String = "GET", body: Data? = nil, stream: Bool = false) async throws -> URLRequest { + guard !isClosed() else { throw InboxError(code:"closed",message:"The recipient session is closed.") } + let value=try await token() + guard !value.isEmpty, !value.contains("\r"), !value.contains("\n") else { throw InboxError(code:"recipient-token-required",message:"Pass a recipient token.") } + guard !isClosed() else { throw CancellationError() } + var r=URLRequest(url:URL(string:"/api/v1/inbox/"+path,relativeTo:endpoint)!,timeoutInterval:stream ? 75 : timeout) + r.httpMethod=method; r.httpBody=body; r.setValue("Bearer "+value,forHTTPHeaderField:"Authorization") + r.setValue(stream ? "text/event-stream" : "application/json",forHTTPHeaderField:"Accept") + if body != nil { r.setValue("application/json",forHTTPHeaderField:"Content-Type") }; return r + } + static func decoder() -> JSONDecoder { let d=JSONDecoder(); d.keyDecodingStrategy = .convertFromSnakeCase; return d } + private func check(_ response: URLResponse, data: Data) throws { + guard let r=response as? HTTPURLResponse else { throw InboxError(code:"network-error",message:"Invalid HTTP response.") } + guard (200..<300).contains(r.statusCode) else { + let json=(try? JSONSerialization.jsonObject(with:data)) as? [String:Any]; let error=json?["error"] as? [String:Any] + throw InboxError(code:error?["code"] as? String ?? "request-failed",status:r.statusCode,message:error?["message"] as? String ?? "Request failed with HTTP \(r.statusCode).") + } + } + private func call(_ path: String, method: String = "GET", body: Data? = nil) async throws -> T { + let r=try await request(path,method:method,body:body) + let id=UUID(); let task=Task { try await session.data(for:r,delegate:InboxRedirectPolicy.shared) };track(id,{task.cancel()});defer {untrack(id)} + do { + let (data,response)=try await withTaskCancellationHandler(operation:{try await task.value},onCancel:{task.cancel()}); try Task.checkCancellation() + guard !isClosed() else { throw CancellationError() }; try check(response,data:data) + do { return try Self.decoder().decode(T.self,from:data) } catch {throw InboxError(code:"invalid-response",message:"Invalid inbox response.")} + } catch let error as URLError { + if error.code == .cancelled || isClosed() || Task.isCancelled {throw CancellationError()} + throw InboxError(code:error.code == .timedOut ? "timeout" : "network-error",message:error.code == .timedOut ? "The request timed out." : "Unable to connect to the inbox.") + } + } + private func json(_ value: [String:Any]) throws -> Data { try JSONSerialization.data(withJSONObject:value) } + public func messages(limit: Int? = nil) async throws -> InboxSnapshot { + if let limit, !(1...100).contains(limit) { throw InboxError(code:"invalid-limit",message:"Limit must be between 1 and 100.") } + return try await call("messages"+(limit.map {"?limit=\($0)"} ?? "")) + } + public func markRead(_ id: String) async throws -> MessageResult { try await call("messages",method:"POST",body:json(["id":id,"action":"read"])) } + public func archive(_ id: String) async throws -> MessageResult { try await call("messages",method:"POST",body:json(["id":id,"action":"archive"])) } + public func preferences() async throws -> InboxPreferences { try await call("preferences") } + public func savePreferences(_ input: PreferenceInput) async throws { let _:EmptyResult=try await call("preferences",method:"POST",body:JSONEncoder().encode(input)) } + public func devices() async throws -> InboxDevices { try await call("devices") } + /// Re-register the same installation ID when FCM/APNs rotates its token. + public func registerMobilePush(_ input: MobileRegistration) async throws -> DeviceResult { + var value=try JSONSerialization.jsonObject(with:JSONEncoder().encode(input)) as! [String:Any]; value["action"]="register-mobile" + return try await call("devices",method:"POST",body:json(value)) + } + public func unregisterMobilePush(_ id: String) async throws -> DeviceResult { try await call("devices",method:"POST",body:json(["action":"unregister-mobile","deviceId":id])) } + public func unregisterWebPush(_ id: String) async throws -> DeviceResult { try await call("devices",method:"POST",body:json(["action":"unregister","deviceId":id])) } + /// Cancelling the consuming task stops streaming. Close on logout and create a new client for a new recipient. + public func updates() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let id=UUID() + let task=Task { + defer {self.untrack(id)} + do { + while !Task.isCancelled && !self.isClosed() { + do { + let r=try await self.request("stream",stream:true) + #if canImport(Darwin) + let (bytes,response)=try await self.session.bytes(for:r,delegate:InboxRedirectPolicy.shared); try self.check(response,data:Data()) + var parser=InboxEventParser() + for try await line in bytes.lines { + try Task.checkCancellation(); if self.isClosed() { break } + if let snapshot=try parser.line(line) { continuation.yield(snapshot) } + } + #else + let (data,response)=try await self.session.data(for:r,delegate:InboxRedirectPolicy.shared); try self.check(response,data:data) + var parser=InboxEventParser() + for line in String(decoding:data,as:UTF8.self).components(separatedBy:"\n") { if let snapshot=try parser.line(line) { continuation.yield(snapshot) } } + #endif + } catch let error as InboxError { throw error } + catch is CancellationError { throw CancellationError() } + catch let error as DecodingError { throw error } + catch { /* Transient connection failures reconnect; writes are never retried. */ } + try await Task.sleep(nanoseconds:2_000_000_000) + } + continuation.finish() + } catch { if Task.isCancelled || self.isClosed() { continuation.finish() } else { continuation.finish(throwing:error) } } + } + self.track(id,{task.cancel()}) + continuation.onTermination={ @Sendable _ in task.cancel() } + } + } + public func logoURL(_ branding: InboxBranding) -> URL? { guard let path=branding.logoUrl, path.range(of:"^/api/v1/inbox/branding-logo/[a-f0-9]{64}$",options:.regularExpression) != nil else {return nil}; return URL(string:path,relativeTo:endpoint)?.absoluteURL } + public static func safeActionURL(_ text: String?) -> URL? { guard let text, let u=URL(string:text),u.scheme=="https",u.host != nil,u.user==nil,u.password==nil else {return nil}; return u } +} + +struct InboxEventParser { + private var event=""; private var data=[String](); private var size=0 + mutating func line(_ raw: String) throws -> InboxSnapshot? { + let line=raw.hasSuffix("\r") ? String(raw.dropLast()) : raw + if line.isEmpty { defer {event="";data=[];size=0}; guard event=="inbox", !data.isEmpty else {return nil}; return try InboxClient.decoder().decode(InboxSnapshot.self,from:Data(data.joined(separator:"\n").utf8)) } + size += line.utf8.count; guard size < 2_097_152 else { throw InboxError(code:"stream-too-large",message:"Inbox frame exceeded the size limit.") } + if line.hasPrefix("event:") {event=String(line.dropFirst(6)).trimmingCharacters(in:.whitespaces)} + if line.hasPrefix("data:") { var value=String(line.dropFirst(5)); if value.hasPrefix(" ") {value.removeFirst()}; data.append(value) }; return nil + } +} diff --git a/Sources/WRNexusInbox/InboxView.swift b/Sources/WRNexusInbox/InboxView.swift new file mode 100644 index 0000000..fcb0a88 --- /dev/null +++ b/Sources/WRNexusInbox/InboxView.swift @@ -0,0 +1,73 @@ +#if canImport(SwiftUI) +import SwiftUI + +/// A complete recipient inbox, using organization branding returned by the recipient API. +@available(iOS 15.0, macOS 12.0, *) +public struct InboxView: View { + private let client: InboxClient + private let onAction: (InboxMessage, InboxAction?) -> Void + @State private var snapshot: InboxSnapshot? + @State private var branding: InboxBranding? + @State private var error: String? + @State private var busy: Set = [] + @State private var generation=0 + @State private var recipientClient: ObjectIdentifier? + @Environment(\.scenePhase) private var scenePhase + public init(client: InboxClient, onAction: @escaping (InboxMessage, InboxAction?) -> Void = {_,_ in}) { self.client=client; self.onAction=onAction } + private var visibleBranding: InboxBranding? { recipientClient == ObjectIdentifier(client) ? branding : nil } + private var brand: Color { color(visibleBranding?.brandColor) ?? Color.accentColor } + public var body: some View { + let currentSnapshot = recipientClient == ObjectIdentifier(client) ? snapshot : nil + VStack(alignment:.leading,spacing:0) { + HStack(spacing:12) { + if let branding=visibleBranding, let url=client.logoURL(branding) { AsyncImage(url:url) { image in image.resizable().scaledToFit() } placeholder: { Color.clear }.frame(width:36,height:36).accessibilityHidden(true) } + VStack(alignment:.leading) { Text(visibleBranding?.displayName ?? "Notifications").font(.title2.bold()); if let snapshot=currentSnapshot { Text("\(snapshot.unreadCount) unread").font(.subheadline).foregroundStyle(.secondary) } } + Spacer() + Button { generation += 1 } label: { Image(systemName:"arrow.clockwise") }.accessibilityLabel("Refresh notifications") + }.padding().overlay(alignment:.bottom) { Rectangle().fill(brand).frame(height:2) } + if let error,recipientClient == ObjectIdentifier(client) { HStack { Text(error).font(.callout); Spacer(); Button("Retry") {generation += 1} }.padding().accessibilityElement(children:.contain) } + if let snapshot=currentSnapshot { + if snapshot.messages.isEmpty { VStack(spacing:8) { Image(systemName:"bell").font(.largeTitle).foregroundStyle(brand); Text("No notifications yet").font(.headline); Text("Your notifications will appear here.").foregroundStyle(.secondary) }.frame(maxWidth:.infinity,maxHeight:.infinity).padding() } + else { List(snapshot.messages) { message in + VStack(alignment:.leading,spacing:8) { + HStack { if message.readAt == nil { Circle().fill(brand).frame(width:8,height:8).accessibilityLabel("Unread") }; Text(message.title).font(.headline); Spacer() } + Text(message.body).font(.body) + Text(message.createdAt).font(.caption).foregroundStyle(.secondary) + HStack { + if message.readAt == nil { Button("Mark read") { mutate(message,archive:false) } } + Button("Archive") { mutate(message,archive:true) } + if let url=InboxClient.safeActionURL(message.url) { Link("Open",destination:url).simultaneousGesture(TapGesture().onEnded { onAction(message,nil) }) } + }.buttonStyle(.borderless).disabled(busy.contains(message.id)) + ForEach(message.actions ?? [],id:\.id) { action in + if let url=InboxClient.safeActionURL(action.url) { Link(action.label,destination:url).simultaneousGesture(TapGesture().onEnded {onAction(message,action)}) } + else if action.url == nil { Button(action.label) {onAction(message,action)}.buttonStyle(.borderless) } + } + }.padding(.vertical,8) + }.listStyle(.plain) } + } else if error == nil || recipientClient != ObjectIdentifier(client) { ProgressView("Loading notifications").frame(maxWidth:.infinity,maxHeight:.infinity) } + }.tint(.primary).task(id:"\(ObjectIdentifier(client)):\(generation)") { await load() } + .onChange(of:scenePhase) { _ in generation += 1 } + } + @MainActor private func load() async { + guard scenePhase == .active else {return} + let identity=ObjectIdentifier(client) + if recipientClient != identity {snapshot=nil;branding=nil;busy=[];recipientClient=identity} + error=nil + do { let settings=try await client.preferences();let first=try await client.messages();try Task.checkCancellation();guard recipientClient==identity else{return};branding=settings.branding;snapshot=first; for try await value in client.updates() { try Task.checkCancellation();guard recipientClient==identity else{return};snapshot=value } } + catch { if !Task.isCancelled && recipientClient==identity { self.error=error.localizedDescription } } + } + private func mutate(_ message: InboxMessage, archive: Bool) { + let identity=ObjectIdentifier(client) + busy.insert(message.id) + Task { @MainActor in + defer {if recipientClient==identity {busy.remove(message.id)}} + do { if archive { _=try await client.archive(message.id) } else { _=try await client.markRead(message.id) }; let value=try await client.messages();if recipientClient==identity {snapshot=value} } + catch {if recipientClient==identity {self.error=error.localizedDescription}} + } + } + private func color(_ text: String?) -> Color? { + guard let text,text.range(of:"^#[a-fA-F0-9]{6}$",options:.regularExpression) != nil, let hex=UInt32(text.dropFirst(),radix:16) else {return nil} + return Color(red:Double((hex>>16)&255)/255,green:Double((hex>>8)&255)/255,blue:Double(hex&255)/255) + } +} +#endif diff --git a/Sources/WRNexusInbox/Models.swift b/Sources/WRNexusInbox/Models.swift new file mode 100644 index 0000000..f6a489f --- /dev/null +++ b/Sources/WRNexusInbox/Models.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct InboxAction: Codable, Sendable { public let id: String; public let label: String; public let url: String? } +public struct InboxMessage: Codable, Identifiable, Sendable { + public let id: String; public let recipientId: String; public let title: String; public let body: String + public let purpose: String; public let category: String; public let url: String?; public let actions: [InboxAction]? + public let createdAt: String; public let readAt: String?; public let archivedAt: String? +} +public struct InboxSnapshot: Codable, Sendable { public let messages: [InboxMessage]; public let unreadCount: Int } +public struct InboxBranding: Codable, Sendable { public let displayName: String?; public let brandColor: String?; public let brandTextColor: String?; public let logoUrl: String? } +public struct QuietHours: Codable, Sendable { + public let start: String; public let end: String; public let days: [Int] + public init(start: String, end: String, days: [Int]) { self.start=start; self.end=end; self.days=days } +} +public struct DeliveryRules: Codable, Sendable { + public let timezone: String; public let quietHours: QuietHours?; public let marketingDailyLimit: Int? + public init(timezone: String, quietHours: QuietHours? = nil, marketingDailyLimit: Int? = nil) { self.timezone=timezone; self.quietHours=quietHours; self.marketingDailyLimit=marketingDailyLimit } + // Null explicitly clears a saved rule; omission would preserve it. + public func encode(to encoder: Encoder) throws { + var c=encoder.container(keyedBy: CodingKeys.self); try c.encode(timezone, forKey:.timezone) + try c.encode(quietHours, forKey:.quietHours); try c.encode(marketingDailyLimit, forKey:.marketingDailyLimit) + } +} +public struct CategoryPreference: Codable, Sendable { public let category: String; public let inAppEnabled: Int; public let marketingEnabled: Int } +public struct ChannelPreference: Codable, Sendable { public let channel: String; public let category: String; public let enabled: Int } +public struct InboxPreferences: Codable, Sendable { public let branding: InboxBranding?; public let deliveryRules: DeliveryRules; public let preferences: [CategoryPreference]; public let channelPreferences: [ChannelPreference] } +public struct PreferenceInput: Encodable, Sendable { + public let category: String; public let inAppEnabled: Bool; public let marketingEnabled: Bool; public let externalChannels: [String:Bool]?; public let deliveryRules: DeliveryRules? + public init(category: String, inAppEnabled: Bool, marketingEnabled: Bool, externalChannels: [String:Bool]? = nil, deliveryRules: DeliveryRules? = nil) { self.category=category; self.inAppEnabled=inAppEnabled; self.marketingEnabled=marketingEnabled; self.externalChannels=externalChannels; self.deliveryRules=deliveryRules } +} +public enum MobilePlatform: String, Codable, Sendable { case android, ios } +public struct MobileRegistration: Encodable, Sendable { + public let connectionId: String; public let installationId: String; public let name: String; public let platform: MobilePlatform; public let token: String; public let marketingConsent: Bool; public let consentEvidence: String + public init(connectionId: String, installationId: String, name: String, platform: MobilePlatform = .ios, token: String, marketingConsent: Bool = false, consentEvidence: String = "") { self.connectionId=connectionId; self.installationId=installationId; self.name=name; self.platform=platform; self.token=token; self.marketingConsent=marketingConsent; self.consentEvidence=consentEvidence } +} +public struct MobileDevice: Decodable, Sendable { public let id: String; public let connectionId: String; public let installationId: String; public let name: String; public let platform: String; public let active: Int; public let revision: Int; public let createdAt: String; public let updatedAt: String } +public struct WebDevice: Decodable, Sendable { public let id: String; public let connectionId: String; public let name: String; public let active: Int; public let createdAt: String; public let updatedAt: String } +public struct MobileConnection: Decodable, Sendable { public let id: String; public let provider: String; public let applicationId: String; public let isDefault: Bool } +public struct WebConnection: Decodable, Sendable { public let id: String; public let publicKey: String; public let isDefault: Bool } +public struct InboxDevices: Decodable, Sendable { public let devices: [WebDevice]; public let mobileDevices: [MobileDevice]; public let connections: [WebConnection]; public let mobileConnections: [MobileConnection] } +public struct DeviceResult: Decodable, Sendable { public let id: String; public let revision: Int?; public let replayed: Bool } +public struct MessageResult: Decodable, Sendable { public let id: String } +struct EmptyResult: Decodable { } +public struct InboxError: Error, LocalizedError, Sendable { + public let code: String; public let status: Int; public let message: String + public var errorDescription: String? { message } + public init(code: String, status: Int = 0, message: String) { self.code=code; self.status=status; self.message=message } +} diff --git a/Tests/WRNexusInboxTests/InboxTests.swift b/Tests/WRNexusInboxTests/InboxTests.swift new file mode 100644 index 0000000..feae259 --- /dev/null +++ b/Tests/WRNexusInboxTests/InboxTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import WRNexusInbox +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +final class InboxTests: XCTestCase { + func testEndpointAndActionValidation() throws { + for text in ["http://example.com", "https://u:p@example.com", "https://example.com/a", "https://example.com?q=x"] { XCTAssertThrowsError(try InboxClient(endpoint:URL(string:text)!,token:{"recipient"})) } + XCTAssertNoThrow(try InboxClient(endpoint:URL(string:"http://127.0.0.1:1234")!,token:{"recipient"})) + XCTAssertNil(InboxClient.safeActionURL("javascript:alert(1)")); XCTAssertNil(InboxClient.safeActionURL("https://u:p@example.com")); XCTAssertNotNil(InboxClient.safeActionURL("https://example.com/path")) + } + func testSnakeCaseAndNulls() throws { + let text=#"{"messages":[{"id":"m","recipient_id":"r","title":"Title","body":"Body","purpose":"transactional","category":"orders","url":null,"actions":[],"created_at":"2026-10-09","read_at":null,"archived_at":null}],"unreadCount":1}"# + let value=try InboxClient.decoder().decode(InboxSnapshot.self,from:Data(text.utf8)); XCTAssertEqual(value.messages[0].recipientId,"r"); XCTAssertNil(value.messages[0].readAt) + let data=try JSONEncoder().encode(DeliveryRules(timezone:"UTC")); let rules=try JSONSerialization.jsonObject(with:data) as! [String:Any]; XCTAssertTrue(rules["quietHours"] is NSNull); XCTAssertTrue(rules["marketingDailyLimit"] is NSNull) + } + func testSSEFrames() throws { + var p=InboxEventParser(); XCTAssertNil(try p.line(": heartbeat")); XCTAssertNil(try p.line("")); XCTAssertNil(try p.line("event: inbox\r")); XCTAssertNil(try p.line(#"data: {"messages":[],"unreadCount":4}"#)); XCTAssertEqual(try p.line("")?.unreadCount,4) + _=try p.line("event: inbox"); _=try p.line("data: broken"); XCTAssertThrowsError(try p.line("")) + } + func testClosedClientCannotRequest() async throws { + let client=try InboxClient(token:{"recipient"}); client.close() + do {_=try await client.messages(); XCTFail("closed client requested")} catch let error as InboxError {XCTAssertEqual(error.code,"closed")} + } +} diff --git a/Tests/WRNexusInboxTests/TransportTests.swift b/Tests/WRNexusInboxTests/TransportTests.swift new file mode 100644 index 0000000..efcf064 --- /dev/null +++ b/Tests/WRNexusInboxTests/TransportTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import WRNexusInbox +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +private final class StubProtocol: URLProtocol { + static var handler: ((URLRequest) -> (Int,String))! + override class func canInit(with request:URLRequest)->Bool {true} + override class func canonicalRequest(for request:URLRequest)->URLRequest {request} + override func startLoading() { + let (status,text)=Self.handler(request) + client?.urlProtocol(self,didReceive:HTTPURLResponse(url:request.url!,statusCode:status,httpVersion:nil,headerFields:["Content-Type":"application/json"])!,cacheStoragePolicy:.notAllowed) + client?.urlProtocol(self,didLoad:Data(text.utf8));client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} +private final class Counter: @unchecked Sendable {let lock=NSLock();var count=0;func next()->String{lock.lock();defer{lock.unlock()};count+=1;return "recipient-\(count)"}} +final class TransportTests:XCTestCase { + func testAllRecipientOperationsAndTokenRefresh() async throws { + let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[StubProtocol.self];let session=URLSession(configuration:config);defer{session.invalidateAndCancel()} + var requests=[URLRequest]();let counter=Counter() + StubProtocol.handler={request in + requests.append(request) + if request.httpMethod=="GET",request.url!.path.hasSuffix("messages") {return (200,#"{"messages":[],"unreadCount":2}"#)} + if request.httpMethod=="GET",request.url!.path.hasSuffix("preferences") {return (200,##"{"branding":{"displayName":"Acme","brandColor":"#123456","brandTextColor":"#ffffff","logoUrl":null},"deliveryRules":{"timezone":"UTC","quietHours":null,"marketingDailyLimit":null},"preferences":[{"category":"*","in_app_enabled":1,"marketing_enabled":0}],"channelPreferences":[]}"##)} + if request.httpMethod=="GET",request.url!.path.hasSuffix("devices") {return (200,#"{"devices":[],"mobileDevices":[],"connections":[],"mobileConnections":[]}"#)} + return (200,#"{"ok":true,"id":"d","revision":2,"replayed":false}"#) + } + let c=try InboxClient(endpoint:URL(string:"http://localhost")!,session:session,token:{counter.next()}) + let snapshot=try await c.messages(limit:20);XCTAssertEqual(snapshot.unreadCount,2) + _=try await c.markRead("m");_=try await c.archive("m") + let preferences=try await c.preferences();XCTAssertEqual(preferences.preferences[0].inAppEnabled,1);XCTAssertEqual(preferences.branding?.displayName,"Acme") + try await c.savePreferences(PreferenceInput(category:"*",inAppEnabled:true,marketingEnabled:false,deliveryRules:DeliveryRules(timezone:"UTC"))) + _=try await c.devices();let device=try await c.registerMobilePush(MobileRegistration(connectionId:"c",installationId:"i",name:"Phone",token:"rotated"));XCTAssertEqual(device.revision,2) + _=try await c.unregisterMobilePush("d");_=try await c.unregisterWebPush("w") + XCTAssertEqual(requests.count,9);XCTAssertEqual(requests[0].url?.query,"limit=20") + for (i,r) in requests.enumerated(){XCTAssertEqual(r.value(forHTTPHeaderField:"Authorization"),"Bearer recipient-\(i+1)")} + let encoded=try JSONSerialization.jsonObject(with:requests[4].httpBody!) as! [String:Any];let rules=encoded["deliveryRules"] as! [String:Any];XCTAssertTrue(rules["quietHours"] is NSNull);XCTAssertNil(encoded["externalChannels"]) + let registration=try JSONSerialization.jsonObject(with:requests[6].httpBody!) as! [String:Any];XCTAssertEqual(registration["action"] as? String,"register-mobile");XCTAssertEqual(registration["installationId"] as? String,"i") + c.close() + } + func testErrorHasCodeAndNoWriteRetry() async throws { + var requests=0;StubProtocol.handler={_ in requests+=1;return (401,#"{"error":{"code":"recipient-token-invalid","message":"Expired"}}"#)} + let config=URLSessionConfiguration.ephemeral;config.protocolClasses=[StubProtocol.self];let session=URLSession(configuration:config);defer{session.invalidateAndCancel()} + let c=try InboxClient(session:session,token:{"expired"});defer{c.close()} + do {_=try await c.archive("m");XCTFail()} catch let e as InboxError {XCTAssertEqual(e.status,401);XCTAssertEqual(e.code,"recipient-token-invalid")};XCTAssertEqual(requests,1) + } +} diff --git a/flutter/LICENSE b/flutter/LICENSE new file mode 100644 index 0000000..332e050 --- /dev/null +++ b/flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 WRNexus contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/flutter/README.md b/flutter/README.md new file mode 100644 index 0000000..9604c57 --- /dev/null +++ b/flutter/README.md @@ -0,0 +1,21 @@ +# WRNexus Inbox 0.1.0 preview + +Flutter 3.35+/Dart 3.9+ recipient inbox client and `WRNexusInboxView`. Distribution uses a versioned Git package; the release report records the published repository when available. Use `sdk/flutter` locally while publication is pending. + +```dart +import 'package:wrnexus_inbox/wrnexus_inbox.dart'; +final inbox = InboxClient(token: () => yourBackend.fetchRecipientToken()); +WRNexusInboxView(client: inbox, onAction: (message, action, url) { + // Route the HTTPS URL in your app, or handle action?.id. +}); +``` + +The view needs bounded height, for example a `Scaffold.body` or `Expanded`. It loads organization branding, shows unread count and messages, and supports read/archive, pull-to-refresh and custom actions. Text renders as text. Unsafe action URLs are refused. It pauses streaming with app lifecycle and cancels on dispose. Replacing `client` clears the previous recipient's state before loading another. + +Methods: `messages(limit:)`, `markRead(id)`, `archive(id)`, `preferences()`, `savePreferences(input)`, `devices()`, `registerMobilePush(input)`, `unregisterMobilePush(id)`, `unregisterWebPush(id)` and `updates()`. Errors expose `InboxException.code`, `status` and `message`. Each request/reconnect fetches a fresh recipient token. Delivery-rule null values clear saved rules; omitted fields preserve existing values. Writes never retry automatically. + +Your backend issues recipient tokens; never include a business API key. For push, configure an active Notifications FCM/APNs connection. Initialize the host's provider SDK, request notification permission, persist a random installation ID, and register the token using `MobileRegistration` with `MobilePlatform.android` or `.ios`. Subscribe to the provider's token-refresh callback and register the replacement using the same installation ID. Retain the returned device ID per signed-in recipient and record actual marketing consent/evidence. + +Before logout unregister that recipient's stored device ID, dispose the view/subscription and call `inbox.close()`. If unregister fails, have your backend deactivate the device. A new recipient gets a new client; do not reuse inbox state across accounts. Provider setup and permission handling remain with the host app. + +Validation: `flutter analyze` and `flutter test`. Tests exercise transport, errors, actions, read/archive, disposal and a branded widget golden. The golden uses explicitly labeled test fixture data. Real Android/iOS push delivery remains a separate device check. diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml new file mode 100644 index 0000000..f9b3034 --- /dev/null +++ b/flutter/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/flutter/lib/src/client.dart b/flutter/lib/src/client.dart new file mode 100644 index 0000000..8876536 --- /dev/null +++ b/flutter/lib/src/client.dart @@ -0,0 +1,287 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'models.dart'; + +/// Never pass a business API key. Tokens must be issued by your application backend. +class InboxClient { + final Uri endpoint; + final FutureOr Function() token; + final Duration timeout; + final http.Client _http; + final http.Client Function() _streamFactory; + final Set _stoppers = {}; + bool _closed = false; + InboxClient({ + required this.token, + Uri? endpoint, + this.timeout = const Duration(seconds: 15), + http.Client? transport, + http.Client Function()? streamTransport, + }) : endpoint = endpoint ?? Uri.parse('https://notifications.wrnexus.com'), + _http = transport ?? http.Client(), + _streamFactory = streamTransport ?? http.Client.new { + final u = this.endpoint; + if (u.host.isEmpty || + u.userInfo.isNotEmpty || + u.hasQuery || + u.hasFragment || + !['', '/'].contains(u.path) || + (u.scheme != 'https' && + !(u.scheme == 'http' && + ['localhost', '127.0.0.1', '::1'].contains(u.host))) || + timeout <= Duration.zero) { + throw const InboxException( + 'invalid-endpoint', + 'Use an HTTPS origin or localhost with a positive timeout.', + ); + } + } + void close() { + if (_closed) return; + _closed = true; + for (final stop in _stoppers.toList()) { + stop(); + } + _http.close(); + } + + void _checkOpen() { + if (_closed) { + throw const InboxException('closed', 'The recipient session is closed.'); + } + } + + Future> _headers({bool stream = false}) async { + _checkOpen(); + final value = await token(); + _checkOpen(); + if (value.isEmpty || value.contains('\r') || value.contains('\n')) { + throw const InboxException( + 'recipient-token-required', + 'Pass a recipient token.', + ); + } + return { + 'authorization': 'Bearer $value', + 'accept': stream ? 'text/event-stream' : 'application/json', + 'content-type': 'application/json', + }; + } + + Uri _url(String path) => endpoint.resolve('/api/v1/inbox/$path'); + InboxException _failure(int status, String body) { + try { + final j = jsonDecode(body) as Json; + final e = j['error'] as Json?; + return InboxException( + e?['code'] as String? ?? 'request-failed', + e?['message'] as String? ?? 'Request failed with HTTP $status.', + status, + ); + } catch (_) { + return InboxException( + 'request-failed', + 'Request failed with HTTP $status.', + status, + ); + } + } + + Future _call(String method, String path, [Json? body]) async { + _checkOpen(); + try { + final headers = await _headers(); + final response = + await (method == 'GET' + ? _http.get(_url(path), headers: headers) + : _http.post( + _url(path), + headers: headers, + body: jsonEncode(body), + )) + .timeout(timeout); + _checkOpen(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _failure(response.statusCode, response.body); + } + return jsonDecode(response.body) as Json; + } on TimeoutException { + throw const InboxException('timeout', 'The request timed out.'); + } on http.ClientException { + throw const InboxException( + 'network-error', + 'Unable to connect to the inbox.', + ); + } + } + + Future messages({int? limit}) async { + if (limit != null && (limit < 1 || limit > 100)) { + throw const InboxException( + 'invalid-limit', + 'Limit must be between 1 and 100.', + ); + } + return InboxSnapshot.fromJson( + await _call('GET', 'messages${limit == null ? '' : '?limit=$limit'}'), + ); + } + + Future markRead(String id) async { + await _call('POST', 'messages', {'id': id, 'action': 'read'}); + } + + Future archive(String id) async { + await _call('POST', 'messages', {'id': id, 'action': 'archive'}); + } + + Future preferences() async => + InboxPreferences.fromJson(await _call('GET', 'preferences')); + Future savePreferences(PreferenceInput input) async { + await _call('POST', 'preferences', input.toJson()); + } + + Future devices() async => + InboxDevices.fromJson(await _call('GET', 'devices')); + + /// Use the same installationId when the provider token rotates. + Future registerMobilePush(MobileRegistration input) async => + DeviceResult.fromJson(await _call('POST', 'devices', input.toJson())); + Future unregisterMobilePush(String id) async => + DeviceResult.fromJson( + await _call('POST', 'devices', { + 'action': 'unregister-mobile', + 'deviceId': id, + }), + ); + Future unregisterWebPush(String id) async => + DeviceResult.fromJson( + await _call('POST', 'devices', { + 'action': 'unregister', + 'deviceId': id, + }), + ); + Uri? logoUri(InboxBranding branding) { + final path = branding.logoUrl; + return path != null && + RegExp(r'^/api/v1/inbox/branding-logo/[a-f0-9]{64}$').hasMatch(path) + ? endpoint.resolve(path) + : null; + } + + static Uri? safeActionUri(String? text) { + final u = text == null ? null : Uri.tryParse(text); + return u != null && + u.scheme == 'https' && + u.host.isNotEmpty && + u.userInfo.isEmpty + ? u + : null; + } + + /// Cancel the subscription on pause/dispose. It cancels its HTTP connection immediately. + Stream updates() { + late StreamController controller; + http.Client? active; + bool stopped = false; + final done = Completer(); + void stop() { + if (stopped) return; + stopped = true; + active?.close(); + if (!done.isCompleted) done.complete(); + _stoppers.remove(stop); + } + + Future run() async { + _stoppers.add(stop); + try { + while (!stopped && !_closed) { + active = _streamFactory(); + try { + final request = http.Request('GET', _url('stream')); + request.headers.addAll(await _headers(stream: true)); + if (stopped) break; + final response = await active!.send(request).timeout(timeout); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _failure( + response.statusCode, + await response.stream.bytesToString(), + ); + } + final parser = InboxEventParser(); + await for (final line + in response.stream + .transform(utf8.decoder) + .transform(const LineSplitter())) { + if (stopped || _closed) break; + final snapshot = parser.line(line); + if (snapshot != null) controller.add(snapshot); + } + } on InboxException { + rethrow; + } on FormatException { + rethrow; + } catch (_) { + if (stopped || _closed) break; + } finally { + active?.close(); + active = null; + } + if (!stopped && !_closed) { + await Future.any([ + Future.delayed(const Duration(seconds: 2)), + done.future, + ]); + } + } + } catch (error, stack) { + if (!stopped && !_closed) controller.addError(error, stack); + } finally { + stop(); + unawaited(controller.close()); + } + } + + controller = StreamController( + onListen: () { + unawaited(run()); + }, + onCancel: stop, + ); + return controller.stream; + } +} + +class InboxEventParser { + String _event = ''; + final List _data = []; + int _size = 0; + InboxSnapshot? line(String line) { + if (line.isEmpty) { + final event = _event; + final data = _data.join('\n'); + _event = ''; + _data.clear(); + _size = 0; + return event == 'inbox' && data.isNotEmpty + ? InboxSnapshot.fromJson(jsonDecode(data) as Json) + : null; + } + _size += utf8.encode(line).length; + if (_size > 2097152) { + throw const InboxException( + 'stream-too-large', + 'Inbox frame exceeded the size limit.', + ); + } + if (line.startsWith('event:')) _event = line.substring(6).trim(); + if (line.startsWith('data:')) { + var value = line.substring(5); + if (value.startsWith(' ')) value = value.substring(1); + _data.add(value); + } + return null; + } +} diff --git a/flutter/lib/src/inbox_view.dart b/flutter/lib/src/inbox_view.dart new file mode 100644 index 0000000..0ca5509 --- /dev/null +++ b/flutter/lib/src/inbox_view.dart @@ -0,0 +1,323 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'client.dart'; +import 'models.dart'; + +/// Real recipient content only. The host owns navigation and custom-action handling. +class WRNexusInboxView extends StatefulWidget { + final InboxClient client; + final void Function(InboxMessage message, InboxAction? action, Uri? url)? + onAction; + const WRNexusInboxView({super.key, required this.client, this.onAction}); + @override + State createState() => _InboxViewState(); +} + +class _InboxViewState extends State + with WidgetsBindingObserver { + InboxSnapshot? _snapshot; + InboxBranding? _branding; + String? _error; + final Set _busy = {}; + StreamSubscription? _subscription; + int _generation = 0; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + unawaited(_load()); + } + + @override + void didUpdateWidget(covariant WRNexusInboxView old) { + super.didUpdateWidget(old); + if (old.client != widget.client) { + _generation++; + _snapshot = null; + _branding = null; + _error = null; + _busy.clear(); + unawaited(_load()); + } + } + + @override + void dispose() { + _generation++; + _subscription?.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_load()); + } else { + _generation++; + _subscription?.cancel(); + _subscription = null; + } + } + + bool _current(int generation) => mounted && generation == _generation; + Future _load() async { + final generation = ++_generation; + final previous = _subscription; + _subscription = null; + if (previous != null) { + unawaited(previous.cancel()); + } + if (!_current(generation)) return; + setState(() => _error = null); + try { + final preferences = await widget.client.preferences(); + final snapshot = await widget.client.messages(); + if (!_current(generation)) return; + setState(() { + _branding = preferences.branding; + _snapshot = snapshot; + }); + _subscription = widget.client.updates().listen( + (value) { + if (_current(generation)) setState(() => _snapshot = value); + }, + onError: (Object error) { + if (_current(generation)) setState(() => _error = error.toString()); + }, + ); + } catch (error) { + if (_current(generation)) setState(() => _error = error.toString()); + } + } + + Future _mutate(InboxMessage message, bool archive) async { + final generation = _generation; + final client = widget.client; + setState(() => _busy.add(message.id)); + try { + if (archive) { + await client.archive(message.id); + } else { + await client.markRead(message.id); + } + final snapshot = await client.messages(); + if (_current(generation)) setState(() => _snapshot = snapshot); + } catch (error) { + if (_current(generation)) setState(() => _error = error.toString()); + } finally { + if (_current(generation)) setState(() => _busy.remove(message.id)); + } + } + + Color? _color(String? text) => + text != null && RegExp(r'^#[a-fA-F0-9]{6}$').hasMatch(text) + ? Color(0xff000000 | int.parse(text.substring(1), radix: 16)) + : null; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final brand = _color(_branding?.brandColor) ?? theme.colorScheme.primary; + final logo = _branding == null ? null : widget.client.logoUri(_branding!); + return Theme( + data: theme.copyWith( + colorScheme: theme.colorScheme.copyWith( + primary: brand.computeLuminance() > 0.18 + ? theme.colorScheme.onSurface + : brand, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: brand, width: 2)), + ), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (logo != null) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Image.network( + logo.toString(), + width: 36, + height: 36, + fit: BoxFit.contain, + excludeFromSemantics: true, + errorBuilder: (_, _, _) => + const SizedBox(width: 36, height: 36), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _branding?.displayName ?? 'Notifications', + style: theme.textTheme.titleLarge, + ), + if (_snapshot != null) + Text( + '${_snapshot!.unreadCount} unread', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + tooltip: 'Refresh notifications', + onPressed: () => unawaited(_load()), + icon: const Icon(Icons.refresh), + ), + ], + ), + ), + if (_error != null) + MaterialBanner( + content: Text(_error!), + actions: [ + TextButton( + onPressed: () => unawaited(_load()), + child: const Text('Retry'), + ), + ], + ), + Expanded( + child: _snapshot == null + ? (_error == null + ? const Center( + child: CircularProgressIndicator( + semanticsLabel: 'Loading notifications', + ), + ) + : const SizedBox()) + : _snapshot!.messages.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.notifications_none, + color: brand, + size: 40, + ), + const SizedBox(height: 12), + Text( + 'No notifications yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + const Text('Your notifications will appear here.'), + ], + ), + ), + ) + : RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: _snapshot!.messages.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final message = _snapshot!.messages[index]; + final busy = _busy.contains(message.id); + final url = InboxClient.safeActionUri(message.url); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.readAt == null) + Padding( + padding: const EdgeInsets.only( + top: 7, + right: 8, + ), + child: Semantics( + label: 'Unread', + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: brand, + shape: BoxShape.circle, + ), + ), + ), + ), + Expanded( + child: Text( + message.title, + style: theme.textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + Text(message.body), + const SizedBox(height: 8), + Text( + message.createdAt, + style: theme.textTheme.bodySmall, + ), + Wrap( + spacing: 8, + children: [ + if (message.readAt == null) + TextButton( + onPressed: busy + ? null + : () => unawaited( + _mutate(message, false), + ), + child: const Text('Mark read'), + ), + TextButton( + onPressed: busy + ? null + : () => + unawaited(_mutate(message, true)), + child: const Text('Archive'), + ), + if (url != null && widget.onAction != null) + TextButton( + onPressed: () => + widget.onAction!(message, null, url), + child: const Text('Open'), + ), + for (final action in message.actions) + if (widget.onAction != null && + (action.url == null || + InboxClient.safeActionUri( + action.url, + ) != + null)) + TextButton( + onPressed: () => widget.onAction!( + message, + action, + InboxClient.safeActionUri(action.url), + ), + child: Text(action.label), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/src/models.dart b/flutter/lib/src/models.dart new file mode 100644 index 0000000..50a4b5f --- /dev/null +++ b/flutter/lib/src/models.dart @@ -0,0 +1,261 @@ +typedef Json = Map; + +class InboxAction { + final String id, label; + final String? url; + InboxAction.fromJson(Json j) + : id = j['id'] as String, + label = j['label'] as String, + url = j['url'] as String?; +} + +class InboxMessage { + final String id, recipientId, title, body, purpose, category, createdAt; + final String? url, readAt, archivedAt; + final List actions; + InboxMessage.fromJson(Json j) + : id = j['id'] as String, + recipientId = j['recipient_id'] as String, + title = j['title'] as String, + body = j['body'] as String, + purpose = j['purpose'] as String, + category = j['category'] as String, + createdAt = j['created_at'] as String, + url = j['url'] as String?, + readAt = j['read_at'] as String?, + archivedAt = j['archived_at'] as String?, + actions = (j['actions'] as List? ?? []) + .map((v) => InboxAction.fromJson(v as Json)) + .toList(growable: false); +} + +class InboxSnapshot { + final List messages; + final int unreadCount; + InboxSnapshot.fromJson(Json j) + : messages = (j['messages'] as List) + .map((v) => InboxMessage.fromJson(v as Json)) + .toList(growable: false), + unreadCount = j['unreadCount'] as int; +} + +class InboxBranding { + final String? displayName, brandColor, brandTextColor, logoUrl; + InboxBranding.fromJson(Json j) + : displayName = j['displayName'] as String?, + brandColor = j['brandColor'] as String?, + brandTextColor = j['brandTextColor'] as String?, + logoUrl = j['logoUrl'] as String?; +} + +class QuietHours { + final String start, end; + final List days; + const QuietHours({ + required this.start, + required this.end, + required this.days, + }); + QuietHours.fromJson(Json j) + : start = j['start'] as String, + end = j['end'] as String, + days = (j['days'] as List).cast(); + Json toJson() => {'start': start, 'end': end, 'days': days}; +} + +class DeliveryRules { + final String timezone; + final QuietHours? quietHours; + final int? marketingDailyLimit; + const DeliveryRules({ + required this.timezone, + this.quietHours, + this.marketingDailyLimit, + }); + DeliveryRules.fromJson(Json j) + : timezone = j['timezone'] as String, + quietHours = j['quietHours'] == null + ? null + : QuietHours.fromJson(j['quietHours'] as Json), + marketingDailyLimit = j['marketingDailyLimit'] as int?; + Json toJson() => { + 'timezone': timezone, + 'quietHours': quietHours?.toJson(), + 'marketingDailyLimit': marketingDailyLimit, + }; +} + +class CategoryPreference { + final String category; + final bool inAppEnabled, marketingEnabled; + CategoryPreference.fromJson(Json j) + : category = j['category'] as String, + inAppEnabled = j['in_app_enabled'] == 1, + marketingEnabled = j['marketing_enabled'] == 1; +} + +class ChannelPreference { + final String channel, category; + final bool enabled; + ChannelPreference.fromJson(Json j) + : channel = j['channel'] as String, + category = j['category'] as String, + enabled = j['enabled'] == 1; +} + +class InboxPreferences { + final InboxBranding? branding; + final DeliveryRules deliveryRules; + final List preferences; + final List channelPreferences; + InboxPreferences.fromJson(Json j) + : branding = j['branding'] == null + ? null + : InboxBranding.fromJson(j['branding'] as Json), + deliveryRules = DeliveryRules.fromJson(j['deliveryRules'] as Json), + preferences = (j['preferences'] as List) + .map((v) => CategoryPreference.fromJson(v as Json)) + .toList(growable: false), + channelPreferences = (j['channelPreferences'] as List) + .map((v) => ChannelPreference.fromJson(v as Json)) + .toList(growable: false); +} + +class PreferenceInput { + final String category; + final bool inAppEnabled, marketingEnabled; + final Map? externalChannels; + final DeliveryRules? deliveryRules; + const PreferenceInput({ + required this.category, + required this.inAppEnabled, + required this.marketingEnabled, + this.externalChannels, + this.deliveryRules, + }); + Json toJson() => { + 'category': category, + 'inAppEnabled': inAppEnabled, + 'marketingEnabled': marketingEnabled, + if (externalChannels != null) 'externalChannels': externalChannels, + if (deliveryRules != null) 'deliveryRules': deliveryRules!.toJson(), + }; +} + +enum MobilePlatform { android, ios } + +class MobileRegistration { + final String connectionId, installationId, name, token, consentEvidence; + final MobilePlatform platform; + final bool marketingConsent; + const MobileRegistration({ + required this.connectionId, + required this.installationId, + required this.name, + required this.platform, + required this.token, + this.marketingConsent = false, + this.consentEvidence = '', + }); + Json toJson() => { + 'action': 'register-mobile', + 'connectionId': connectionId, + 'installationId': installationId, + 'name': name, + 'platform': platform.name, + 'token': token, + 'marketingConsent': marketingConsent, + 'consentEvidence': consentEvidence, + }; +} + +class MobileDevice { + final String id, + connectionId, + installationId, + name, + platform, + createdAt, + updatedAt; + final bool active; + final int revision; + MobileDevice.fromJson(Json j) + : id = j['id'] as String, + connectionId = j['connection_id'] as String, + installationId = j['installation_id'] as String, + name = j['name'] as String, + platform = j['platform'] as String, + createdAt = j['created_at'] as String, + updatedAt = j['updated_at'] as String, + active = j['active'] == 1, + revision = j['revision'] as int; +} + +class WebDevice { + final String id, connectionId, name, createdAt, updatedAt; + final bool active; + WebDevice.fromJson(Json j) + : id = j['id'] as String, + connectionId = j['connection_id'] as String, + name = j['name'] as String, + createdAt = j['created_at'] as String, + updatedAt = j['updated_at'] as String, + active = j['active'] == 1; +} + +class MobileConnection { + final String id, provider, applicationId; + final bool isDefault; + MobileConnection.fromJson(Json j) + : id = j['id'] as String, + provider = j['provider'] as String, + applicationId = j['applicationId'] as String, + isDefault = j['isDefault'] as bool; +} + +class WebConnection { + final String id, publicKey; + final bool isDefault; + WebConnection.fromJson(Json j) + : id = j['id'] as String, + publicKey = j['publicKey'] as String, + isDefault = j['isDefault'] as bool; +} + +class InboxDevices { + final List mobileDevices; + final List devices; + final List mobileConnections; + final List connections; + InboxDevices.fromJson(Json j) + : mobileDevices = (j['mobileDevices'] as List) + .map((v) => MobileDevice.fromJson(v as Json)) + .toList(growable: false), + devices = (j['devices'] as List) + .map((v) => WebDevice.fromJson(v as Json)) + .toList(growable: false), + mobileConnections = (j['mobileConnections'] as List) + .map((v) => MobileConnection.fromJson(v as Json)) + .toList(growable: false), + connections = (j['connections'] as List) + .map((v) => WebConnection.fromJson(v as Json)) + .toList(growable: false); +} + +class DeviceResult { + final String id; + final int? revision; + final bool replayed; + DeviceResult.fromJson(Json j) + : id = j['id'] as String, + revision = j['revision'] as int?, + replayed = j['replayed'] as bool; +} + +class InboxException implements Exception { + final String code, message; + final int status; + const InboxException(this.code, this.message, [this.status = 0]); + @override + String toString() => message; +} diff --git a/flutter/lib/wrnexus_inbox.dart b/flutter/lib/wrnexus_inbox.dart new file mode 100644 index 0000000..3050294 --- /dev/null +++ b/flutter/lib/wrnexus_inbox.dart @@ -0,0 +1,5 @@ +library; + +export 'src/models.dart'; +export 'src/client.dart'; +export 'src/inbox_view.dart'; diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml new file mode 100644 index 0000000..fbebac9 --- /dev/null +++ b/flutter/pubspec.yaml @@ -0,0 +1,16 @@ +name: wrnexus_inbox +description: Recipient-scoped WRNexus inbox, mobile push registration and branded Flutter inbox widget. +version: 0.1.0 +homepage: https://docs.wrnexus.com/sdk +publish_to: none +environment: + sdk: '>=3.9.0 <4.0.0' + flutter: '>=3.35.0' +dependencies: + flutter: + sdk: flutter + http: ^1.5.0 +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/flutter/test/client_test.dart b/flutter/test/client_test.dart new file mode 100644 index 0000000..b3d2b4d --- /dev/null +++ b/flutter/test/client_test.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:wrnexus_inbox/wrnexus_inbox.dart'; + +void main() { + test( + 'recipient transport, token refresh, writes and nullable delivery rules', + () async { + final calls = []; + var tokens = 0; + final client = InboxClient( + token: () => 'recipient-${++tokens}', + transport: MockClient((r) async { + calls.add(r); + if (r.method == 'GET') { + return http.Response('{"messages":[],"unreadCount":3}', 200); + } + return http.Response('{"id":"d","revision":2,"replayed":false}', 200); + }), + ); + expect((await client.messages(limit: 20)).unreadCount, 3); + await client.markRead('m'); + await client.archive('m'); + await client.savePreferences( + const PreferenceInput( + category: '*', + inAppEnabled: true, + marketingEnabled: false, + deliveryRules: DeliveryRules(timezone: 'UTC'), + ), + ); + await client.registerMobilePush( + const MobileRegistration( + connectionId: 'c', + installationId: 'i', + name: 'Phone', + platform: MobilePlatform.android, + token: 'fcm', + marketingConsent: false, + ), + ); + await client.unregisterMobilePush('d'); + await client.unregisterWebPush('w'); + expect( + calls.map((r) => r.headers['authorization']), + List.generate(7, (i) => 'Bearer recipient-${i + 1}'), + ); + expect(calls.first.url.path, '/api/v1/inbox/messages'); + expect(calls.first.url.query, 'limit=20'); + expect(jsonDecode(calls[1].body), {'id': 'm', 'action': 'read'}); + expect((jsonDecode(calls[3].body) as Map)['deliveryRules'], { + 'timezone': 'UTC', + 'quietHours': null, + 'marketingDailyLimit': null, + }); + expect((jsonDecode(calls[4].body) as Map)['action'], 'register-mobile'); + client.close(); + await expectLater( + client.messages(), + throwsA(isA().having((e) => e.code, 'code', 'closed')), + ); + }, + ); + test('API errors stop without retry', () async { + var calls = 0; + final client = InboxClient( + token: () => 'expired', + transport: MockClient((_) async { + calls++; + return http.Response( + '{"error":{"code":"expired-token","message":"Expired"}}', + 401, + ); + }), + ); + await expectLater( + client.markRead('m'), + throwsA( + isA() + .having((e) => e.status, 'status', 401) + .having((e) => e.code, 'code', 'expired-token'), + ), + ); + expect(calls, 1); + client.close(); + }); + test('origin, actions and frame parsing', () { + for (final text in [ + 'http://example.com', + 'https://u:p@example.com', + 'https://example.com/a', + 'https://example.com?q=x', + ]) { + expect( + () => InboxClient(endpoint: Uri.parse(text), token: () => 'r'), + throwsA(isA()), + ); + } + expect(InboxClient.safeActionUri('javascript:alert(1)'), isNull); + expect(InboxClient.safeActionUri('https://u:p@example.com'), isNull); + final parser = InboxEventParser(); + expect(parser.line(': heartbeat'), isNull); + expect(parser.line(''), isNull); + parser.line('event: inbox'); + parser.line('data: {"messages":[],"unreadCount":2}'); + expect(parser.line('')!.unreadCount, 2); + parser.line('event: inbox'); + parser.line('data: broken'); + expect(() => parser.line(''), throwsFormatException); + }); +} diff --git a/flutter/test/fonts/Inter.ttf b/flutter/test/fonts/Inter.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/flutter/test/fonts/Inter.ttf differ diff --git a/flutter/test/fonts/OFL.txt b/flutter/test/fonts/OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/flutter/test/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/flutter/test/goldens/branded-inbox.png b/flutter/test/goldens/branded-inbox.png new file mode 100644 index 0000000..12ccc32 Binary files /dev/null and b/flutter/test/goldens/branded-inbox.png differ diff --git a/flutter/test/inbox_view_test.dart b/flutter/test/inbox_view_test.dart new file mode 100644 index 0000000..d38ff27 --- /dev/null +++ b/flutter/test/inbox_view_test.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:wrnexus_inbox/wrnexus_inbox.dart'; + +class IdleStreamClient extends http.BaseClient { + final controller = StreamController>(); + @override + Future send(http.BaseRequest request) async => + http.StreamedResponse(controller.stream, 200); + @override + void close() { + unawaited(controller.close()); + } +} + +void main() { + setUpAll(() async { + final loader = FontLoader('Inter'); + loader.addFont( + Future.value( + ByteData.sublistView(await File('test/fonts/Inter.ttf').readAsBytes()), + ), + ); + await loader.load(); + final iconPath = + '${Platform.environment['FLUTTER_ROOT'] ?? '../../.wrnexus/tools/flutter'}/bin/cache/artifacts/material_fonts/MaterialIcons-Regular.otf'; + final icons = FontLoader('MaterialIcons'); + icons.addFont( + Future.value(ByteData.sublistView(await File(iconPath).readAsBytes())), + ); + await icons.load(); + }); + testWidgets('branding, actions, read/archive and recipient change', ( + tester, + ) async { + var read = false, archived = false; + final writes = >[]; + final streams = []; + final client = InboxClient( + token: () => 'recipient', + streamTransport: () { + final c = IdleStreamClient(); + streams.add(c); + return c; + }, + transport: MockClient((r) async { + if (r.method == 'POST') { + final body = jsonDecode(r.body) as Map; + writes.add(body); + read = body['action'] == 'read' || read; + archived = body['action'] == 'archive' || archived; + return http.Response('{"id":"m"}', 200); + } + if (r.url.path.endsWith('preferences')) { + return http.Response( + jsonEncode({ + 'branding': {'displayName': 'Acme', 'brandColor': '#123456'}, + 'deliveryRules': { + 'timezone': 'UTC', + 'quietHours': null, + 'marketingDailyLimit': null, + }, + 'preferences': [], + 'channelPreferences': [], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'messages': archived + ? [] + : [ + { + 'id': 'm', + 'recipient_id': 'r', + 'title': 'Order ready', + 'body': 'Your order is ready for collection.', + 'purpose': 'transactional', + 'category': 'orders', + 'created_at': '2026-10-09T00:00:00Z', + 'read_at': read ? '2026-10-09' : null, + 'archived_at': null, + 'url': 'javascript:alert(1)', + 'actions': [ + {'id': 'track', 'label': 'Track order'}, + ], + }, + ], + 'unreadCount': read || archived ? 0 : 1, + }), + 200, + ); + }), + ); + final actions = []; + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + fontFamily: 'Inter', + scaffoldBackgroundColor: Colors.white, + ), + home: Scaffold( + body: RepaintBoundary( + key: const Key('inbox'), + child: ColoredBox( + color: Colors.white, + child: WRNexusInboxView( + client: client, + onAction: (m, a, u) => actions.add(a!.id), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Acme'), findsOneWidget); + expect(find.text('Order ready'), findsOneWidget); + expect(find.text('Open'), findsNothing); + await tester.tap(find.text('Track order')); + expect(actions, ['track']); + await expectLater( + find.byKey(const Key('inbox')), + matchesGoldenFile('goldens/branded-inbox.png'), + ); + await tester.tap(find.text('Mark read')); + await tester.pumpAndSettle(); + expect(writes.last, {'id': 'm', 'action': 'read'}); + expect(find.text('Mark read'), findsNothing); + await tester.tap(find.text('Archive')); + await tester.pumpAndSettle(); + expect(find.text('No notifications yet'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + client.close(); + expect(streams.every((s) => s.controller.isClosed), isTrue); + }); + testWidgets( + 'replacing the client clears the previous recipient before the new request resolves', + (tester) async { + final pending = Completer(); + String settings(String name) => jsonEncode({ + 'branding': {'displayName': name}, + 'deliveryRules': {'timezone': 'UTC'}, + 'preferences': [], + 'channelPreferences': [], + }); + InboxClient make(String name, {bool wait = false}) => InboxClient( + token: () => name, + streamTransport: IdleStreamClient.new, + transport: MockClient((request) async { + if (request.url.path.endsWith('preferences')) { + return wait ? pending.future : http.Response(settings(name), 200); + } + return http.Response( + jsonEncode({ + 'messages': [ + { + 'id': name, + 'recipient_id': name, + 'title': '$name message', + 'body': 'Private content', + 'purpose': 'transactional', + 'category': 'orders', + 'created_at': '2026-10-09', + }, + ], + 'unreadCount': 1, + }), + 200, + ); + }), + ); + final first = make('First'); + final second = make('Second', wait: true); + Widget app(InboxClient client) => MaterialApp( + home: Scaffold( + body: WRNexusInboxView( + key: const ValueKey('same-view'), + client: client, + ), + ), + ); + await tester.pumpWidget(app(first)); + await tester.pumpAndSettle(); + expect(find.text('First message'), findsOneWidget); + await tester.pumpWidget(app(second)); + await tester.pump(); + expect(find.text('First message'), findsNothing); + expect(find.text('First'), findsNothing); + pending.complete(http.Response(settings('Second'), 200)); + await tester.pumpAndSettle(); + expect(find.text('Second message'), findsOneWidget); + expect(find.text('First message'), findsNothing); + await tester.pumpWidget(const SizedBox()); + first.close(); + second.close(); + }, + ); +} diff --git a/flutter/tool/http_check.dart b/flutter/tool/http_check.dart new file mode 100644 index 0000000..8b10818 --- /dev/null +++ b/flutter/tool/http_check.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:wrnexus_inbox/src/client.dart'; +import 'package:wrnexus_inbox/src/models.dart'; + +Future main() async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + var tokens = 0, streams = 0; + final calls = []; + final bodies = []; + final serving = server.listen((request) async { + calls.add('${request.method} ${request.uri}'); + if (request.headers.value('authorization') != + 'Bearer recipient-${calls.length}') { + throw StateError('Recipient authorization/refresh mismatch'); + } + if (request.uri.path.endsWith('stream')) { + streams++; + if (streams == 3) { + request.response.statusCode = 401; + request.response.write( + '{"error":{"code":"recipient-token-invalid","message":"Expired"}}', + ); + } else { + request.response.headers.contentType = ContentType( + 'text', + 'event-stream', + ); + request.response.write( + 'event: inbox\r\ndata: {"messages":[],"unreadCount":$streams}\r\n\r\n', + ); + } + } else if (request.method == 'POST') { + final body = jsonDecode(await utf8.decoder.bind(request).join()) as Json; + bodies.add(body); + request.response.write( + '{"ok":true,"id":"d","revision":2,"replayed":false}', + ); + } else if (request.uri.path.endsWith('preferences')) { + request.response.write( + '{"branding":{"displayName":"HTTP fixture"},"deliveryRules":{"timezone":"UTC","quietHours":null,"marketingDailyLimit":null},"preferences":[],"channelPreferences":[]}', + ); + } else if (request.uri.path.endsWith('devices')) { + request.response.write( + '{"devices":[],"mobileDevices":[],"connections":[],"mobileConnections":[]}', + ); + } else { + request.response.write('{"messages":[],"unreadCount":4}'); + } + await request.response.close(); + }); + final client = InboxClient( + endpoint: Uri.parse('http://127.0.0.1:${server.port}'), + token: () => 'recipient-${++tokens}', + ); + void require(bool value, String message) { + if (!value) throw StateError(message); + } + + try { + require((await client.messages(limit: 20)).unreadCount == 4, 'Snapshot'); + await client.markRead('m'); + await client.archive('m'); + await client.preferences(); + await client.devices(); + await client.savePreferences( + const PreferenceInput( + category: '*', + inAppEnabled: true, + marketingEnabled: false, + deliveryRules: DeliveryRules(timezone: 'UTC'), + ), + ); + await client.registerMobilePush( + const MobileRegistration( + connectionId: 'c', + installationId: 'i', + name: 'Phone', + platform: MobilePlatform.ios, + token: 'rotated', + marketingConsent: false, + ), + ); + await client.unregisterMobilePush('d'); + await client.unregisterWebPush('w'); + final received = []; + try { + await for (final snapshot in client.updates()) { + received.add(snapshot.unreadCount); + } + } on InboxException catch (error) { + require(error.status == 401, 'Expected terminal auth error'); + } + require(received.join(',') == '1,2', 'CRLF frames/reconnect'); + require(streams == 3, 'Terminal authentication must stop retries'); + require(calls.first == 'GET /api/v1/inbox/messages?limit=20', 'Query'); + require( + bodies[0]['action'] == 'read' && bodies[1]['action'] == 'archive', + 'Writes', + ); + require( + (bodies[2]['deliveryRules'] as Json).containsKey('quietHours'), + 'Explicit nullable setting', + ); + require( + bodies[3]['installationId'] == 'i' && bodies[3]['token'] == 'rotated', + 'Rotation', + ); + stdout.writeln( + jsonEncode({ + 'localHttpChecks': 'passed', + 'requests': calls.length, + 'streamSnapshots': received, + 'authorizationRefresh': true, + 'providerSendsSubmitted': false, + }), + ); + } finally { + client.close(); + await serving.cancel(); + await server.close(force: true); + } +} diff --git a/scripts/verify-apple.sh b/scripts/verify-apple.sh new file mode 100644 index 0000000..8d2a0ca --- /dev/null +++ b/scripts/verify-apple.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +swift test +xcodebuild -scheme WRNexusInbox -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO build