Release recipient inbox SDKs 0.1.0 preview

This commit is contained in:
WRNexus SDK Publisher committed 2026-10-09 14:27:00 +05:30
commit d0781a9f3b
23 files changed
+1846

No files matched your search

+21
View File
@@ -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.
+3
View File
@@ -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"])])
+23
View File
@@ -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`.
+127
View File
@@ -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<T:Decodable>(_ 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<InboxSnapshot,Error> {
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
}
}
+73
View File
@@ -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<String> = []
@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
+48
View File
@@ -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 }
}
+26
View File
@@ -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")}
}
}
@@ -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)
}
}
+21
View File
@@ -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.
+21
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+287
View File
@@ -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<String> Function() token;
final Duration timeout;
final http.Client _http;
final http.Client Function() _streamFactory;
final Set<void Function()> _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<Map<String, String>> _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<Json> _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<InboxSnapshot> 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<void> markRead(String id) async {
await _call('POST', 'messages', {'id': id, 'action': 'read'});
}
Future<void> archive(String id) async {
await _call('POST', 'messages', {'id': id, 'action': 'archive'});
}
Future<InboxPreferences> preferences() async =>
InboxPreferences.fromJson(await _call('GET', 'preferences'));
Future<void> savePreferences(PreferenceInput input) async {
await _call('POST', 'preferences', input.toJson());
}
Future<InboxDevices> devices() async =>
InboxDevices.fromJson(await _call('GET', 'devices'));
/// Use the same installationId when the provider token rotates.
Future<DeviceResult> registerMobilePush(MobileRegistration input) async =>
DeviceResult.fromJson(await _call('POST', 'devices', input.toJson()));
Future<DeviceResult> unregisterMobilePush(String id) async =>
DeviceResult.fromJson(
await _call('POST', 'devices', {
'action': 'unregister-mobile',
'deviceId': id,
}),
);
Future<DeviceResult> 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<InboxSnapshot> updates() {
late StreamController<InboxSnapshot> controller;
http.Client? active;
bool stopped = false;
final done = Completer<void>();
void stop() {
if (stopped) return;
stopped = true;
active?.close();
if (!done.isCompleted) done.complete();
_stoppers.remove(stop);
}
Future<void> 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<void>.delayed(const Duration(seconds: 2)),
done.future,
]);
}
}
} catch (error, stack) {
if (!stopped && !_closed) controller.addError(error, stack);
} finally {
stop();
unawaited(controller.close());
}
}
controller = StreamController<InboxSnapshot>(
onListen: () {
unawaited(run());
},
onCancel: stop,
);
return controller.stream;
}
}
class InboxEventParser {
String _event = '';
final List<String> _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;
}
}
+323
View File
@@ -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<WRNexusInboxView> createState() => _InboxViewState();
}
class _InboxViewState extends State<WRNexusInboxView>
with WidgetsBindingObserver {
InboxSnapshot? _snapshot;
InboxBranding? _branding;
String? _error;
final Set<String> _busy = {};
StreamSubscription<InboxSnapshot>? _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<void> _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<void> _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),
),
],
),
],
),
);
},
),
),
),
],
),
);
}
}
+261
View File
@@ -0,0 +1,261 @@
typedef Json = Map<String, dynamic>;
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<InboxAction> 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<InboxMessage> 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<int> 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<int>();
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<CategoryPreference> preferences;
final List<ChannelPreference> 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<String, bool>? 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<MobileDevice> mobileDevices;
final List<WebDevice> devices;
final List<MobileConnection> mobileConnections;
final List<WebConnection> 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;
}
+5
View File
@@ -0,0 +1,5 @@
library;
export 'src/models.dart';
export 'src/client.dart';
export 'src/inbox_view.dart';
+16
View File
@@ -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
+113
View File
@@ -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 = <http.Request>[];
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<InboxException>().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<InboxException>()
.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<InboxException>()),
);
}
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);
});
}
Binary file not shown.
+93
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+206
View File
@@ -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<List<int>>();
@override
Future<http.StreamedResponse> 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 = <Map<String, dynamic>>[];
final streams = <IdleStreamClient>[];
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<String, dynamic>;
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 = <String>[];
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<http.Response>();
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();
},
);
}
+124
View File
@@ -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<void> main() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
var tokens = 0, streams = 0;
final calls = <String>[];
final bodies = <Json>[];
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 = <int>[];
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);
}
}
+5
View File
@@ -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