diff --git a/appleApp/FTW.entitlements b/appleApp/FTW.entitlements index 1411e583..9c1fd785 100644 --- a/appleApp/FTW.entitlements +++ b/appleApp/FTW.entitlements @@ -11,5 +11,9 @@ $(FTW_APP_GROUP) + aps-environment + development + com.apple.developer.usernotifications.time-sensitive + diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 84fbd063..7df1443c 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ F7A000000000000000000065 /* NOTICE in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000061 /* NOTICE */; }; F7A000000000000000000066 /* LICENSING.md in Copy the license texts */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000062 /* LICENSING.md */; }; F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000040 /* FTWWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F7A000000000000000000070 /* FTWShared in Frameworks */ = {isa = PBXBuildFile; productRef = F7A000000000000000000071 /* FTWShared */; }; + F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7A000000000000000000074 /* FTWNotify.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -31,6 +33,13 @@ remoteGlobalIDString = F7A000000000000000000010; remoteInfo = FTW; }; + F7A000000000000000000073 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = F7A000000000000000000001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F7A000000000000000000078; + remoteInfo = FTWNotify; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -41,6 +50,7 @@ dstSubfolderSpec = 13; files = ( F7A000000000000000000034 /* FTWWidgets.appex in Embed Foundation Extensions */, + F7A000000000000000000072 /* FTWNotify.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -72,6 +82,9 @@ F7A000000000000000000062 /* LICENSING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = LICENSING.md; path = ../LICENSING.md; sourceTree = SOURCE_ROOT; }; F7A000000000000000000040 /* FTWWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7A000000000000000000100 /* FTWUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FTWUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000074 /* FTWNotify.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FTWNotify.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F7A000000000000000000075 /* FTWNotify.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FTWNotify.entitlements; sourceTree = ""; }; + F7A000000000000000000076 /* FTWNotify-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "FTWNotify-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -95,6 +108,11 @@ path = FTWCharts; sourceTree = ""; }; + F7A000000000000000000077 /* FTWNotify */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = FTWNotify; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -122,6 +140,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A00000000000000000007A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F7A000000000000000000070 /* FTWShared in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -132,11 +158,14 @@ F7A000000000000000000041 /* FTWWidgets */, F7A000000000000000000101 /* FTWUITests */, F7A000000000000000000110 /* FTWCharts */, + F7A000000000000000000077 /* FTWNotify */, F7A000000000000000000008 /* FTW.xcconfig */, F7A000000000000000000006 /* FTW.entitlements */, F7A000000000000000000009 /* FTW-Info.plist */, F7A000000000000000000007 /* FTWWidgets.entitlements */, F7A00000000000000000000A /* FTWWidgets-Info.plist */, + F7A000000000000000000075 /* FTWNotify.entitlements */, + F7A000000000000000000076 /* FTWNotify-Info.plist */, F7A000000000000000000063 /* License texts */, F7A000000000000000000004 /* Products */, ); @@ -158,6 +187,7 @@ F7A000000000000000000005 /* FTW.app */, F7A000000000000000000040 /* FTWWidgets.appex */, F7A000000000000000000100 /* FTWUITests.xctest */, + F7A000000000000000000074 /* FTWNotify.appex */, ); name = Products; sourceTree = ""; @@ -180,6 +210,7 @@ ); dependencies = ( F7A000000000000000000037 /* PBXTargetDependency */, + F7A00000000000000000007C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( F7A000000000000000000003 /* FTW */, @@ -239,6 +270,29 @@ productReference = F7A000000000000000000100 /* FTWUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + F7A000000000000000000078 /* FTWNotify */ = { + isa = PBXNativeTarget; + buildConfigurationList = F7A00000000000000000007F /* Build configuration list for PBXNativeTarget "FTWNotify" */; + buildPhases = ( + F7A000000000000000000079 /* Sources */, + F7A00000000000000000007A /* Frameworks */, + F7A00000000000000000007B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7A000000000000000000077 /* FTWNotify */, + ); + name = FTWNotify; + packageProductDependencies = ( + F7A000000000000000000071 /* FTWShared */, + ); + productName = FTWNotify; + productReference = F7A000000000000000000074 /* FTWNotify.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -259,6 +313,9 @@ CreatedOnToolsVersion = 27.0; TestTargetID = F7A000000000000000000010; }; + F7A000000000000000000078 = { + CreatedOnToolsVersion = 27.0; + }; }; }; buildConfigurationList = F7A000000000000000000020 /* Build configuration list for PBXProject "FTW" */; @@ -282,6 +339,7 @@ F7A000000000000000000010 /* FTW */, F7A000000000000000000050 /* FTWWidgets */, F7A000000000000000000102 /* FTWUITests */, + F7A000000000000000000078 /* FTWNotify */, ); }; /* End PBXProject section */ @@ -308,6 +366,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A00000000000000000007B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -356,6 +421,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F7A000000000000000000079 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -369,6 +441,11 @@ target = F7A000000000000000000010 /* FTW */; targetProxy = F7A000000000000000000106 /* PBXContainerItemProxy */; }; + F7A00000000000000000007C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F7A000000000000000000078 /* FTWNotify */; + targetProxy = F7A000000000000000000073 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -567,6 +644,52 @@ }; name = Release; }; + F7A00000000000000000007D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWNotify.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWNotify-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.notify; + PRODUCT_NAME = FTWNotify; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F7A00000000000000000007E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = FTWNotify.entitlements; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "FTWNotify-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = FTW; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = energy.ftw.app.notify; + PRODUCT_NAME = FTWNotify; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -606,6 +729,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + F7A00000000000000000007F /* Build configuration list for PBXNativeTarget "FTWNotify" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F7A00000000000000000007D /* Debug */, + F7A00000000000000000007E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ @@ -631,6 +763,11 @@ package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; productName = FTWShared; }; + F7A000000000000000000071 /* FTWShared */ = { + isa = XCSwiftPackageProductDependency; + package = F7A000000000000000000015 /* XCLocalSwiftPackageReference "FTWKit" */; + productName = FTWShared; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F7A000000000000000000001 /* Project object */; diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e599fe93..9652d847 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -9,6 +9,9 @@ import WidgetKit struct FTWApp: App { @State private var app: AppModel @Environment(\.scenePhase) private var scenePhase + /// Only for what SwiftUI has no door for: the push token, and buttons + /// on a notification. It starts after `init`, so the runner is there. + @UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate private let network: NetworkWatch init() { @@ -35,6 +38,9 @@ struct FTWApp: App { #if DEBUG // For screenshots: `-FTWDemo YES` opens the simulated home. if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } + // For end-to-end tests: `-FTWPairLink ` offers a pairing link + // as a scanned QR would, where the simulator has no camera. + if let link = UserDefaults.standard.string(forKey: "FTWPairLink") { model.offer(link) } #endif _app = State(initialValue: model) network = NetworkWatch { [weak model] in model?.networkOnline() } diff --git a/appleApp/FTW/Intents/IntentRunner.swift b/appleApp/FTW/Intents/IntentRunner.swift index 13c118bd..37e0d848 100644 --- a/appleApp/FTW/Intents/IntentRunner.swift +++ b/appleApp/FTW/Intents/IntentRunner.swift @@ -47,7 +47,13 @@ final class IntentRunner: CommandRunner { await perform(command) } - private func perform(_ command: RemoteCommand) async -> RemoteOutcome { + /// A notification's button: the same path, on the charger its event + /// named. + func run(_ command: RemoteCommand, loadpointID: String) async -> RemoteOutcome { + await perform(command, loadpointID: loadpointID) + } + + private func perform(_ command: RemoteCommand, loadpointID: String? = nil) async -> RemoteOutcome { guard let home = await begin() else { // No home on this phone: a Live Activity left from one can only // mislead, and its button has nothing to act on. @@ -57,7 +63,7 @@ final class IntentRunner: CommandRunner { } defer { end() } let started = ContinuousClock.now - let out = await home.loadpoints.perform(command) + let out = await home.loadpoints.perform(command, loadpointID: loadpointID) // The system redraws the Control when this returns: the record it // reads must already hold the box's answer, or the tile would flip // back and then forward again. @@ -99,6 +105,20 @@ final class IntentRunner: CommandRunner { app.home?.loadpoints.answer(locked: DeviceLock.isLocked) } + /// Whether `siteId` is the home this app opens. A command from a push + /// goes only there; the demo never takes one, except in a debug build + /// that seeded it as "demo" for a test. + func opens(siteId: String) -> Bool { + if app.isDemo { + #if DEBUG + return siteId == "demo" + #else + return false + #endif + } + return (app.site?.siteId ?? app.sites.current()?.siteId) == siteId + } + /// The home, with a session on its way. A launch for an intent has /// already started connecting; an app whose session was closed in the /// background opens a new one. diff --git a/appleApp/FTW/Intents/en.lproj/Localizable.strings b/appleApp/FTW/Intents/en.lproj/Localizable.strings new file mode 100644 index 00000000..0ce5681a --- /dev/null +++ b/appleApp/FTW/Intents/en.lproj/Localizable.strings @@ -0,0 +1,5 @@ +/* A push from FTW Cloud, as Apple shows it if the notification extension + does not run. The extension writes every other line itself. Elsewhere + the English is the key, so nothing else is listed here. */ +"push.title" = "Something happened at home"; +"push.generic" = "Open FTW to see what."; diff --git a/appleApp/FTW/Intents/sv.lproj/Localizable.strings b/appleApp/FTW/Intents/sv.lproj/Localizable.strings index 5f28f577..cf60fd6f 100644 --- a/appleApp/FTW/Intents/sv.lproj/Localizable.strings +++ b/appleApp/FTW/Intents/sv.lproj/Localizable.strings @@ -20,3 +20,8 @@ "On" = "På"; "Open FTW" = "Öppna FTW"; "Reason" = "Orsak"; + +/* A push from FTW Cloud, as Apple shows it if the notification extension + does not run. The extension writes every other line itself. */ +"push.title" = "Något hände hemma"; +"push.generic" = "Öppna FTW för att se vad."; diff --git a/appleApp/FTW/Push/Notifications.swift b/appleApp/FTW/Push/Notifications.swift new file mode 100644 index 00000000..34f7f9f0 --- /dev/null +++ b/appleApp/FTW/Push/Notifications.swift @@ -0,0 +1,175 @@ +import FTWKit +import os +import UIKit +import UserNotifications + +/// The one UIKit piece besides the camera: Apple hands the push token to an +/// app delegate and nowhere else. +final class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + Notifications.start() + return true + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Notifications.received(token: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) { + Notifications.log.notice("push: no token: \(error.localizedDescription, privacy: .public)") + } +} + +/// FTW's notifications on the app's side: the buttons, the token, and what a +/// button does. The words are the extension's (FTWNotify). +@MainActor +enum Notifications { + static let log = Logger(subsystem: "energy.ftw.app", category: "push") + /// The center holds its delegate weakly. + private static let delegate = NotificationButtons() + + /// A development build talks to Apple's sandbox. + static var environment: PushEnvironment { + #if DEBUG + return .sandbox + #else + return .production + #endif + } + + /// At launch, before a button pressed on the lock screen is handed over. + static func start() { + let center = UNUserNotificationCenter.current() + center.delegate = delegate + center.setNotificationCategories(categories(.preferred())) + PushTokens.setAsk { await askAndRegister() } + #if DEBUG + seedFromLaunchArguments() + if UserDefaults.standard.bool(forKey: "FTWPushRegister") { + Task { await askAndRegister() } + return + } + #endif + // Allowed before: a new launch can bring a new token. Nobody is + // asked here; FTW Cloud asks when it is turned on (PushTokens). + Task { + let status = await center.notificationSettings().authorizationStatus + if status == .authorized || status == .provisional || status == .ephemeral { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + + static func askAndRegister() async { + do { + let allowed = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + log.info("push: notifications \(allowed ? "allowed" : "not allowed", privacy: .public)") + if allowed { UIApplication.shared.registerForRemoteNotifications() } + } catch { + log.notice("push: could not ask: \(error.localizedDescription, privacy: .public)") + } + } + + static func received(token: Data) { + #if DEBUG + log.notice("push token \(token.byteArray.hex, privacy: .public) (\(environment.rawValue, privacy: .public))") + #endif + Task { await PushTokens.received(token, environment: environment) } + } + + /// The level and Charge now run behind the lock screen once the phone is + /// unlocked; Open brings the app up. + static func categories(_ language: PushText.Language) -> Set { + Set(PushActions.Category.allCases.map { category in + UNNotificationCategory( + identifier: category.rawValue, + actions: category.actions.map { action in + UNNotificationAction( + identifier: action.rawValue, + title: PushActions.title(action, language), + options: PushActions.opensApp(action) ? [.foreground] : [.authenticationRequired], + icon: UNNotificationActionIcon(systemImageName: PushActions.symbol(action)) + ) + }, + intentIdentifiers: [] + ) + }) + } + + /// A button was pressed. The payload is checked again, with the time it + /// arrived, and the button acts only for a verified event whose kind + /// offers it, on the charger the event named, in the home this app + /// opens. The box's answer is logged; nothing here claims more. + static func act(_ action: PushActions.Action, _ command: RemoteCommand, alert: PushAlert, home: CloudHome?) async { + guard PushActions.allows(action, for: alert) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") + return + } + guard let home, let loadpointID = home.loadpointID(opaque: alert.lp) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: no charger for this event on this phone") + return + } + guard let runner = IntentRunner.shared, runner.opens(siteId: home.siteId) else { + log.notice("push: \(action.rawValue, privacy: .public) not sent: not the home this app opens") + return + } + let out = await runner.run(command, loadpointID: loadpointID) + log.info("push: \(action.rawValue, privacy: .public): \(String(describing: out.kind), privacy: .public): \(out.text, privacy: .public)") + } + + #if DEBUG + /// Until the box's session info fills CloudKeys (FTW Cloud C3), a debug + /// build can hold one home for an end-to-end test: + /// + /// -FTWCloudHome -FTWCloudBoxKey + /// -FTWCloudLoadpoint =[,…] + /// -FTWCloudSource =[,…] -FTWCloudSite + /// + /// The site is the open home's by default, or "demo" with `-FTWDemo YES`. + static func seedFromLaunchArguments() { + let d = UserDefaults.standard + guard let home = d.string(forKey: "FTWCloudHome"), let text = d.string(forKey: "FTWCloudBoxKey") else { return } + func pairs(_ key: String) -> [String: String] { + var out = [String: String]() + for pair in (d.string(forKey: key) ?? "").split(separator: ",") { + let parts = pair.split(separator: "=", maxSplits: 1).map(String.init) + if parts.count == 2 { out[parts[0]] = parts[1] } + } + return out + } + let site = d.string(forKey: "FTWCloudSite") ?? (d.bool(forKey: "FTWDemo") ? "demo" : IntentRunner.shared?.app.sites.current()?.siteId ?? "") + guard let keys = CloudKeys.shared() else { + log.error("push: not seeded: no App Group") + return + } + do { + let row = CloudHome(home: home, siteId: site, boxKey: Data(try Base64url.decode(text)), loadpoints: pairs("FTWCloudLoadpoint"), sources: pairs("FTWCloudSource")) + try keys.write(row) + log.notice("push: seeded a home for \(site, privacy: .public) with \(row.loadpoints.count, privacy: .public) chargers") + } catch { + log.error("push: not seeded: \(String(describing: error), privacy: .public)") + } + } + #endif +} + +/// Where the system hands a pressed button and a notification that arrives +/// while FTW is open. +final class NotificationButtons: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { + // A tap on the notification, Open, or a dismissal asks the box + // nothing. + guard let action = PushActions.Action(rawValue: response.actionIdentifier), let command = PushActions.command(action) else { return } + let keys = CloudKeys.shared() + let alert = PushAlert.read( + userInfo: response.notification.request.content.userInfo, + boxKey: { keys?.boxKey(home: $0) }, + nowMs: Int64(response.notification.date.timeIntervalSince1970 * 1000) + ) + await Notifications.act(action, command, alert: alert, home: keys?.read(home: alert.home)) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .list, .sound] + } +} diff --git a/appleApp/FTWKit/Package.swift b/appleApp/FTWKit/Package.swift index 4ac35c77..2246ba12 100644 --- a/appleApp/FTWKit/Package.swift +++ b/appleApp/FTWKit/Package.swift @@ -4,9 +4,10 @@ // // - FTWKit: pairing, the passkey derivations, Noise IK, frames, the relay, // the session and the state each screen reads. The app alone links it. -// - FTWShared: plain `Sendable` values the app shares with its widgets and, -// later, a notification extension. The App Group cache, its records and -// the formatters a widget needs. Nothing on the main actor, no keys. +// - FTWShared: plain `Sendable` values the app shares with its widgets and +// its notification extension. The App Group cache, its records, the +// formatters a widget needs, and the check and the words of a push. +// Nothing on the main actor, no secret keys. // - FTWDemo: a simulated box in this process, for the demo, App Review and // previews. Kept out of FTWKit so the product module holds no fake box. // @@ -33,6 +34,10 @@ let package = Package( dependencies: ["FTWKit", "FTWDemo"], resources: [.copy("Resources")] ), - .testTarget(name: "FTWSharedTests", dependencies: ["FTWShared"]), + .testTarget( + name: "FTWSharedTests", + dependencies: ["FTWShared"], + resources: [.copy("Resources")] + ), ] ) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift index 94166ae2..41525435 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/LoadpointsModel.swift @@ -569,11 +569,13 @@ public final class LoadpointsModel: Activatable { /// take 20 of them. public static let remoteStreamWaitMs: Double = 10_000 - /// A command from a Control, Siri or the Live Activity. The caller has - /// opened the session; this waits for the stream, reads the chargers, - /// sends the command exactly as the car view would, and says how it - /// ended. The Live Activity moves as it does for a tap in the car view. - public func perform(_ command: RemoteCommand, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { + /// A command from a Control, Siri, the Live Activity or a notification. + /// The caller has opened the session; this waits for the stream, reads + /// the chargers, sends the command exactly as the car view would, and + /// says how it ended. The Live Activity moves as it does for a tap in + /// the car view. A notification names its charger in `loadpointID`; + /// the others let the box's account pick one. + public func perform(_ command: RemoteCommand, loadpointID: String? = nil, waitMs: Double = LoadpointsModel.remoteStreamWaitMs) async -> RemoteOutcome { // A Live Activity's button names its home. One from another home, // or from the demo, never reaches this one: its card closes instead. if case .backToPlan(let id, let home?) = command, home != activityHome { @@ -596,7 +598,9 @@ public final class LoadpointsModel: Activatable { } catch { return RemoteOutcome(.failed, self.error ?? EVText.failure(error)) } - guard let lp = Self.target(command, in: points) else { + // A charger named and not found is not swapped for another. + let named = loadpointID.flatMap { id in points.first { $0.id == id } } + guard let lp = loadpointID == nil ? Self.target(command, in: points) : named else { return RemoteOutcome(.failed, Self.say("FTW has no charger to control.")) } // Charge now with no car would hold a charger that has nothing to diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift new file mode 100644 index 00000000..4198e0df --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/PushTokens.swift @@ -0,0 +1,73 @@ +import Foundation +import Synchronization + +/// Which of Apple's push services a token belongs to. A development build +/// gets sandbox tokens. +public enum PushEnvironment: String, Sendable { + case sandbox, production +} + +/// Takes the phone's APNs token. FTW Cloud's channel is the one that will: +/// it tells the service, over the app session, where Apple can reach this +/// phone. +public protocol PushTokenSink: Sendable { + func register(apnsToken: Data, environment: PushEnvironment) async +} + +/// The token Apple gave this phone, and who takes it. +/// +/// Nothing asks for notifications until something wants them: a sink +/// arrives when FTW Cloud is on, and only then is the person asked. Once +/// they said yes, Apple hands a token at every launch. A sink installed +/// later gets the latest at once. Kept in memory: Apple gives it again at +/// the next launch. +public enum PushTokens { + public struct Token: Sendable, Equatable { + public var data: Data + public var environment: PushEnvironment + } + + private struct State { + var sink: (any PushTokenSink)? + var token: Token? + var ask: (@Sendable () async -> Void)? + } + + private static let state = Mutex(State()) + + /// The app sets this at launch: ask the person to allow notifications, + /// then register with Apple. + public static func setAsk(_ ask: @escaping @Sendable () async -> Void) { + state.withLock { $0.ask = ask } + } + + /// Something wants pushes. It gets the token now if there is one and + /// whenever it changes; the person is asked if nobody has asked yet. + public static func install(_ sink: any PushTokenSink) async { + let (token, ask) = state.withLock { s in + s.sink = sink + return (s.token, s.ask) + } + if let token { await sink.register(apnsToken: token.data, environment: token.environment) } + await ask?() + } + + /// Nothing wants pushes any more. + public static func uninstall() { + state.withLock { $0.sink = nil } + } + + /// Apple gave a token. The same token twice is handed over once. + public static func received(_ data: Data, environment: PushEnvironment) async { + let token = Token(data: data, environment: environment) + let sink = state.withLock { s -> (any PushTokenSink)? in + defer { s.token = token } + return s.token == token ? nil : s.sink + } + await sink?.register(apnsToken: data, environment: environment) + } + + public static var current: Token? { + state.withLock { $0.token } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift new file mode 100644 index 00000000..bc07dfcc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/CloudKeys.swift @@ -0,0 +1,99 @@ +import Foundation + +/// What this phone holds about one home for FTW Cloud's pushes: the key the +/// box signs events with, which paired home it is, and the names behind the +/// opaque ids an event uses. The app writes it when the box describes its +/// FTW Cloud link over Noise; the notification extension reads it. +/// +/// Nothing here is a secret. A public key checks signatures and cannot make +/// one, and the names are what the box's own page shows. +public struct CloudHome: Codable, Sendable, Equatable { + /// The home's id at FTW Cloud: 26 characters of lowercase base32. + public var home: String + /// The paired home on this phone that it belongs to. + public var siteId: String + /// The box's cloud key, a 65-byte uncompressed P-256 point. + public var boxKey: Data + /// Opaque loadpoint id → the loadpoint's id at the box. + public var loadpoints: [String: String] + /// Opaque source id → the name the box shows for the device. + public var sources: [String: String] + + public init(home: String, siteId: String, boxKey: Data, loadpoints: [String: String] = [:], sources: [String: String] = [:]) { + self.home = home + self.siteId = siteId + self.boxKey = boxKey + self.loadpoints = loadpoints + self.sources = sources + } + + /// The charger an event means. An event that names none means the only + /// one, if the home has one. An id the table does not know is not + /// guessed at: the table may predate a second charger. + public func loadpointID(opaque: String?) -> String? { + guard let opaque else { return loadpoints.count == 1 ? loadpoints.values.first : nil } + return loadpoints[opaque] + } + + public func sourceName(opaque: String?) -> String? { + opaque.flatMap { sources[$0] } + } +} + +/// The App Group files that hold each home's `CloudHome`, beside the +/// widgets' cache, so signing out clears them with it. The app writes, the +/// notification extension reads. Like the cache they carry +/// `completeUntilFirstUserAuthentication`: a push that arrives while the +/// phone is locked can be checked, one before the first unlock cannot. +public struct CloudKeys: Sendable { + public let directory: URL + + public init(directory: URL) { + self.directory = directory + } + + /// In the App Group container, when this process has the group. + public static func shared(bundle: Bundle = .main) -> CloudKeys? { + AppGroupFiles.shared(bundle: bundle).map { CloudKeys(directory: $0.directory) } + } + + public struct Refused: Error, Equatable { + public let reason: String + } + + /// A home id as FTW Cloud cuts it. Anything else never becomes a file + /// name: the id arrives in a push, and a push is not trusted. + public static func isHomeID(_ s: String) -> Bool { + s.utf8.count == 26 && s.utf8.allSatisfy { (0x61...0x7a).contains($0) || (0x32...0x37).contains($0) } + } + + func url(_ home: String) -> URL { + directory.appendingPathComponent("cloud-\(home)") + } + + public func read(home: String) -> CloudHome? { + guard Self.isHomeID(home), + let data = try? Data(contentsOf: url(home)), + let row = try? JSONDecoder().decode(CloudHome.self, from: data), row.home == home else { return nil } + return row + } + + /// The box key for a home, for `PushAlert.read`. + public func boxKey(home: String) -> Data? { + read(home: home)?.boxKey + } + + /// Only the app writes. Atomic, so the extension never reads half a file. + public func write(_ row: CloudHome) throws { + guard Self.isHomeID(row.home) else { throw Refused(reason: "not a home id") } + guard row.boxKey.count == 65, row.boxKey.first == 0x04 else { throw Refused(reason: "not an uncompressed P-256 key") } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try JSONEncoder().encode(row).write(to: url(row.home), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + /// FTW Cloud was turned off for this home, or the phone left it. + public func remove(home: String) { + guard Self.isHomeID(home) else { return } + try? FileManager.default.removeItem(at: url(home)) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift new file mode 100644 index 00000000..44dcd445 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushAlert.swift @@ -0,0 +1,240 @@ +import CryptoKit +import Foundation + +// A push from FTW Cloud, as the phone checks it. The box signs every event; +// the service wraps the box's envelope, unchanged, in an APNs alert. The +// phone's notification extension checks the signature with the box key it +// holds (CloudKeys), writes its own text from the kind (PushText) and picks +// the buttons from a verified kind alone (PushActions). The service picks +// nothing: not the words, not the buttons, not the priority. +// +// This is a port of ReadAlert in srcfl/ftw, go/protocol/cloudwire/push.go, +// and docs/protocol-cloud.md, "Push", is the normative text. The tests +// replay the Go side's push vectors case by case. + +/// The kinds in the push catalogue (`protocol/push-catalogue.yaml`). +public enum PushKind { + public static let chargingConnected = "charging.connected" + public static let chargingSessionComplete = "charging.session_complete" + public static let chargingGoalAtRisk = "charging.goal_at_risk" + public static let chargingInterrupted = "charging.interrupted" + public static let updateInstalled = "update.installed" + public static let driverOffline = "driver.offline" + public static let fuseOverLimit = "fuse.over_limit" + /// The one alert the service writes itself: a box that cannot reach the + /// service cannot sign one. + public static let boxUnreachable = "box.unreachable" + public static let cloudGranted = "cloud.granted" + public static let cloudRevoked = "cloud.revoked" + + /// In the catalogue's order. + public static let all = [ + chargingConnected, chargingSessionComplete, chargingGoalAtRisk, chargingInterrupted, updateInstalled, + driverOffline, fuseOverLimit, boxUnreachable, cloudGranted, cloudRevoked, + ] +} + +/// What a push says once checked. Only `read` makes one, so a kind that has +/// not been verified cannot reach the text or the buttons. +public struct PushAlert: Sendable, Equatable { + public enum Verdict: String, Sendable { + /// Signed by the box, anchored in time and fresh, of a kind the + /// catalogue has. The phone writes its text and offers its buttons. + case event + /// The service's box.unreachable. Its own text, no buttons. + case unreachable + /// Anything else: the generic line, no buttons. + case generic + } + + public let verdict: Verdict + /// The home the payload names. Empty when the payload could not be read. + public let home: String + // The rest is set only for `.event`. + public let kind: String + public let args: [String: String] + /// The opaque id of the device the event concerns. + public let src: String? + /// The opaque id of the loadpoint it concerns. + public let lp: String? + public let boot: Data? + public let seq: UInt64 + /// When it happened by the phone's reading of the box clock: never + /// later than it arrived. + public let atMs: Int64 + + init(_ verdict: Verdict, home: String, kind: String = "", args: [String: String] = [:], src: String? = nil, lp: String? = nil, boot: Data? = nil, seq: UInt64 = 0, atMs: Int64 = 0) { + self.verdict = verdict + self.home = home + self.kind = kind + self.args = args + self.src = src + self.lp = lp + self.boot = boot + self.seq = seq + self.atMs = atMs + } + + /// The payload key the alert sits under, beside Apple's "aps". + public static let key = "ftw" + static let version = 1 + /// How far ahead of the phone a box clock may run before the phone + /// stops trusting it. + static let clockLeadMs: Int64 = 2_000 + /// How old an event may be and still get its text and buttons. + static let maxAgeMs: Int64 = 10 * 60_000 + + /// Check a push as the notification extension receives it. + /// `boxKey` is the key the phone holds for a home, nil when it holds + /// none; `nowMs` is the phone's clock when the push arrived. Whether an + /// event was seen before is `PushSeen`'s question, not this one's. + public static func read(userInfo: [AnyHashable: Any], boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + read(ftw: userInfo[key], boxKey: boxKey, nowMs: nowMs) + } + + /// The same, from the payload's JSON as APNs sends it. + public static func read(payload: Data, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + let top = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] + return read(ftw: top?[key], boxKey: boxKey, nowMs: nowMs) + } + + /// The service's part of the payload. + private struct Wire: Decodable { + var v: Int + var home: String? + /// The box's envelope, base64 with padding. + var event: Data? + var kind: String? + } + + static func read(ftw: Any?, boxKey: (String) -> Data?, nowMs: Int64) -> PushAlert { + guard let ftw, JSONSerialization.isValidJSONObject(ftw), + let json = try? JSONSerialization.data(withJSONObject: ftw), + let a = try? JSONDecoder().decode(Wire.self, from: json), a.v == version else { + return PushAlert(.generic, home: "") + } + let home = a.home ?? "" + let generic = PushAlert(.generic, home: home) + guard let event = a.event, !event.isEmpty else { + return a.kind == PushKind.boxUnreachable ? PushAlert(.unreachable, home: home) : generic + } + guard a.kind == nil || a.kind == "", + let env = Envelope(Array(event)), env.t == "event", + let pk = boxKey(home), verify(pk, home: home, env), + let ev = Event(env.b), let wallMs = ev.wallMs, wallMs <= nowMs &+ clockLeadMs else { + return generic + } + var at = min(wallMs, nowMs) + if let ts = ev.ts { at = min(at, ts) } + let age = nowMs.subtractingReportingOverflow(at) + guard PushKind.all.contains(ev.kind), ev.kind != PushKind.boxUnreachable, !age.overflow, age.partialValue <= maxAgeMs else { + return generic + } + return PushAlert(.event, home: home, kind: ev.kind, args: ev.args, src: ev.src, lp: ev.lp, boot: ev.boot.map { Data($0) }, seq: ev.seq, atMs: at) + } + + /// ES256 over the bytes received, never over a re-encoding. The key is + /// the box's 65-byte uncompressed P-256 point. + static func verify(_ pk: Data, home: String, _ env: Envelope) -> Bool { + guard pk.count == 65, pk.first == 0x04, env.sig.count == 64, + let key = try? P256.Signing.PublicKey(x963Representation: pk), + let sig = try? P256.Signing.ECDSASignature(rawRepresentation: env.sig) else { return false } + return key.isValidSignature(sig, for: PushCBOR.signedInput(home: home, t: env.t, b: env.b)) + } + + /// One message: {t, b, sig}. `b` is the body's exact bytes, so the + /// signature covers what was sent. + struct Envelope { + var t: String + var b: [UInt8] + var sig: [UInt8] + + /// t must be text and b and sig byte strings; unknown keys are + /// ignored. + init?(_ raw: [UInt8]) { + guard let m = (try? PushCBOR.decode(raw))?.textMap, + case .text(let t)? = m["t"], !t.isEmpty, + case .bytes(let b)? = m["b"], + case .bytes(let sig)? = m["sig"] else { return nil } + self.t = t + self.b = b + self.sig = sig + } + } + + /// The event body. A field may be missing or null; one of the wrong + /// type makes the whole body unreadable, as it does in Go. + struct Event { + var seq: UInt64 = 0 + var boot: [UInt8]? + var wallMs: Int64? + var ts: Int64? + var kind = "" + var args: [String: String] = [:] + var src: String? + var lp: String? + + init?(_ b: [UInt8]) { + guard let m = (try? PushCBOR.decode(b))?.textMap else { return nil } + func field(_ key: String, _ read: (PushCBOR) -> T?) throws -> T? { + guard let v = m[key], v != .null else { return nil } + guard let out = read(v) else { throw PushCBOR.Malformed() } + return out + } + do { + seq = try field("seq", \.uint) ?? 0 + boot = try field("boot", \.byteString) + _ = try field("uptimeMs", \.int64) + wallMs = try field("wallMs", \.int64) + _ = try field("eventId", \.text) + ts = try field("ts", \.int64) + kind = try field("kind", \.text) ?? "" + _ = try field("severity", \.text) + args = try field("args", \.textOfText) ?? [:] + src = try field("src", \.text) + lp = try field("lp", \.text) + } catch { + return nil + } + } + } +} + +extension PushCBOR { + var uint: UInt64? { + if case .unsigned(let u) = self { return u } + return nil + } + + var int64: Int64? { + switch self { + case .unsigned(let u): return Int64(exactly: u) + case .negative(let n): return Int64(exactly: n).map { -1 - $0 } + default: return nil + } + } + + var text: String? { + if case .text(let s) = self { return s } + return nil + } + + var byteString: [UInt8]? { + if case .bytes(let b) = self { return b } + return nil + } + + /// A text-to-text map. A null value reads as empty text, as in Go. + var textOfText: [String: String]? { + guard let m = textMap else { return nil } + var out = [String: String]() + for (k, v) in m { + switch v { + case .text(let s): out[k] = s + case .null: out[k] = "" + default: return nil + } + } + return out + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift new file mode 100644 index 00000000..b59f8df4 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushCBOR.swift @@ -0,0 +1,159 @@ +import Foundation + +/// The CBOR a push carries: the box's envelope and the event inside it. +/// +/// The notification extension does not link FTWKit, so it reads with this +/// small reader instead. It is strict where FTWKit's is lenient: definite +/// lengths only, text must be UTF-8, a key may appear once, and nothing may +/// follow the value. The box writes core-deterministic CBOR, so a genuine +/// event never needs more; anything else is shown as the generic line. +indirect enum PushCBOR: Equatable, Sendable { + case unsigned(UInt64) + /// The value is `-1 - n`. + case negative(UInt64) + case bytes([UInt8]) + case text(String) + case array([PushCBOR]) + case map([Entry]) + case tagged(UInt64, PushCBOR) + /// Null and undefined. Go reads either as "not there". + case null + /// Booleans, floats and other simple values: skipped, never read. + case simple + + struct Entry: Equatable, Sendable { + var key: PushCBOR + var value: PushCBOR + } + + struct Malformed: Error {} + + /// Exactly one value, and nothing after it. + static func decode(_ bytes: [UInt8]) throws -> PushCBOR { + var r = Reader(bytes: bytes) + let value = try r.read(depth: 0) + guard r.at == bytes.count else { throw Malformed() } + return value + } + + /// A map whose keys are all text, as a dictionary. Nil for anything + /// else; a duplicate key never gets this far. + var textMap: [String: PushCBOR]? { + guard case .map(let entries) = self else { return nil } + var out = [String: PushCBOR]() + for e in entries { + guard case .text(let k) = e.key else { return nil } + out[k] = e.value + } + return out + } + + private struct Reader { + let bytes: [UInt8] + var at = 0 + + /// Deeper than any event, shallow enough that a hostile one cannot + /// exhaust the stack. + static let maxDepth = 16 + + mutating func byte() throws -> UInt8 { + guard at < bytes.count else { throw Malformed() } + defer { at += 1 } + return bytes[at] + } + + mutating func take(_ n: UInt64) throws -> [UInt8] { + guard n <= UInt64(bytes.count - at) else { throw Malformed() } + let end = at + Int(n) + defer { at = end } + return Array(bytes[at.. UInt64 { + switch info { + case 0..<24: return UInt64(info) + case 24: return UInt64(try byte()) + case 25: return try take(2).reduce(0) { $0 << 8 | UInt64($1) } + case 26: return try take(4).reduce(0) { $0 << 8 | UInt64($1) } + case 27: return try take(8).reduce(0) { $0 << 8 | UInt64($1) } + // 28–30 are reserved; 31 is an indefinite length. + default: throw Malformed() + } + } + + mutating func read(depth: Int) throws -> PushCBOR { + guard depth < Self.maxDepth else { throw Malformed() } + let initial = try byte() + let major = initial >> 5 + let info = initial & 0x1f + switch major { + case 0: return .unsigned(try argument(info)) + case 1: return .negative(try argument(info)) + case 2: return .bytes(try take(try argument(info))) + case 3: + guard let s = String(validating: try take(try argument(info)), as: UTF8.self) else { throw Malformed() } + return .text(s) + case 4: + let n = try argument(info) + // Each item takes at least a byte. + guard n <= UInt64(bytes.count - at) else { throw Malformed() } + return .array(try (0.. [UInt8] { + var out = Array("ftw.cloud.v1\u{0}".utf8) + out.append(0x83) + head(3, UInt64(home.utf8.count), into: &out) + out.append(contentsOf: home.utf8) + head(3, UInt64(t.utf8.count), into: &out) + out.append(contentsOf: t.utf8) + head(2, UInt64(b.count), into: &out) + out.append(contentsOf: b) + return out + } + + /// A head in its shortest form, as deterministic encoding asks. + private static func head(_ major: UInt8, _ n: UInt64, into out: inout [UInt8]) { + let m = major << 5 + switch n { + case 0..<24: out.append(m | UInt8(n)) + case 24...0xff: out += [m | 24, UInt8(n)] + case 0x100...0xffff: out += [m | 25, UInt8(n >> 8), UInt8(n & 0xff)] + case 0x1_0000...0xffff_ffff: out += [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + default: out += [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift new file mode 100644 index 00000000..8f7a0c09 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushNotice.swift @@ -0,0 +1,148 @@ +import Foundation +import UserNotifications + +/// The buttons a push may carry, and what each one asks the box. +/// +/// Only a kind the box signed picks them; the service never does, so the +/// extension always sets the category itself, empty when there are none. +/// Every command from a notification needs the phone unlocked (Fredrik, +/// 12 Oct 2026), and runs in the background over the app's own session +/// with the box, as Siri's do. +public enum PushActions { + public enum Action: String, CaseIterable, Sendable { + case level20 = "ftw.level.20" + case level50 = "ftw.level.50" + case level80 = "ftw.level.80" + case chargeNow = "ftw.chargeNow" + case open = "ftw.open" + } + + public enum Category: String, CaseIterable, Sendable { + /// The car was plugged in: tell FTW how full it is. + case level = "ftw.level" + /// The car may not be ready: start charging at once. + case chargeNow = "ftw.chargeNow" + + public var actions: [Action] { + switch self { + case .level: return [.level20, .level50, .level80, .open] + case .chargeNow: return [.chargeNow, .open] + } + } + } + + /// Few kinds get buttons, and only from a verified event. + public static func category(for alert: PushAlert) -> Category? { + guard alert.verdict == .event else { return nil } + switch alert.kind { + case PushKind.chargingConnected: return .level + case PushKind.chargingInterrupted, PushKind.chargingGoalAtRisk: return .chargeNow + default: return nil + } + } + + /// Whether a button may run for this alert: it has to be one its + /// verified kind gets. + public static func allows(_ action: Action, for alert: PushAlert) -> Bool { + category(for: alert)?.actions.contains(action) ?? false + } + + /// What the button asks the box. Open asks nothing; it opens the app. + public static func command(_ action: Action) -> RemoteCommand? { + switch action { + case .level20: return .setLevel(pct: 20) + case .level50: return .setLevel(pct: 50) + case .level80: return .setLevel(pct: 80) + case .chargeNow: return .chargeNow + case .open: return nil + } + } + + /// Whether the button needs the app on screen. The others run behind + /// the lock screen once the phone is unlocked. + public static func opensApp(_ action: Action) -> Bool { + action == .open + } + + public static func title(_ action: Action, _ language: PushText.Language) -> String { + switch action { + case .level20: return "20 %" + case .level50: return "50 %" + case .level80: return "80 %" + case .chargeNow: return language == .sv ? "Ladda nu" : "Charge now" + case .open: return language == .sv ? "Öppna" : "Open" + } + } + + public static func symbol(_ action: Action) -> String { + switch action { + case .level20: return "battery.25percent" + case .level50: return "battery.50percent" + case .level80: return "battery.75percent" + case .chargeNow: return "bolt.fill" + case .open: return "arrow.up.forward.app" + } + } +} + +/// Everything the extension sets on a notification, from one checked alert. +public struct PushNotice: Sendable, Equatable { + public var title: String + public var body: String + /// "" for no buttons. Always set, so a category the service put in the + /// payload never shows. + public var category: String + public var level: UNNotificationInterruptionLevel + /// Whether it makes a sound. + public var sound = true + /// One thread per home. + public var thread: String + /// The checked fields, for the app. The app checks the payload again + /// before it acts and never trusts these alone. + public var verified: [String: String] + + public init(_ alert: PushAlert, home: CloudHome?, language: PushText.Language, decimalSeparator: String = Locale.current.decimalSeparator ?? ".") { + let words = PushText.content(alert, sourceName: home?.sourceName(opaque: alert.src), language: language, decimalSeparator: decimalSeparator) + title = words.title + body = words.body + category = PushActions.category(for: alert)?.rawValue ?? "" + level = Self.level(alert) + thread = alert.home + verified = alert.verdict == .generic ? [:] : ["verdict": alert.verdict.rawValue, "home": alert.home] + if alert.verdict == .event { + verified["kind"] = alert.kind + verified["seq"] = String(alert.seq) + verified["atMs"] = String(alert.atMs) + if let lp = home?.loadpointID(opaque: alert.lp) { verified["loadpointID"] = lp } + } + } + + /// The generic line, for an extension that ran out of time. + public static func generic(_ language: PushText.Language) -> PushNotice { + PushNotice(PushAlert(.generic, home: ""), home: nil, language: language) + } + + /// An event shown before. iOS shows something for every push unless + /// Apple grants the filtering entitlement, and without it empty content + /// brings back the payload's own alert. So: the generic line, quiet, + /// with no buttons, in Notification Center only. + public static func repeated(_ alert: PushAlert, language: PushText.Language) -> PushNotice { + var n = PushNotice(PushAlert(.generic, home: alert.home), home: nil, language: language) + n.level = .passive + n.sound = false + return n + } + + /// The key `verified` sits under in the notification's userInfo. + public static let userInfoKey = "ftwVerified" + + /// The payload never says how urgent it is. A box out of reach and a + /// charge that stopped early break through a Focus; the rest wait. + static func level(_ alert: PushAlert) -> UNNotificationInterruptionLevel { + switch alert.verdict { + case .unreachable: return .timeSensitive + case .event where alert.kind == PushKind.chargingInterrupted: return .timeSensitive + default: return .active + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift new file mode 100644 index 00000000..af67a2b1 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushSeen.swift @@ -0,0 +1,66 @@ +import Foundation +import Synchronization + +/// Events this phone has shown, so a push that arrives twice is shown once. +/// +/// The box numbers what it sends: `seq` rises by one per message within a +/// boot. Per home and boot the extension keeps the highest `seq` it showed; +/// an event at or below it is a duplicate or older than one already shown. +/// A new boot starts its own count. The file keeps the most recent few +/// boots and is in the App Group, beside the cache. +public struct PushSeen: Sendable { + public let file: URL + /// Homes times boots worth remembering. Each box starts a new boot on + /// every restart; the oldest goes first. + public static let limit = 32 + + public init(file: URL) { + self.file = file + } + + public static func shared(bundle: Bundle = .main) -> PushSeen? { + AppGroupFiles.shared(bundle: bundle).map { PushSeen(file: $0.directory.appendingPathComponent("push-seen")) } + } + + struct Entry: Codable, Equatable { + var home: String + var boot: Data + var seq: UInt64 + /// The phone's clock when it was last moved, to drop the oldest. + var atMs: Double + } + + /// Whether to show this alert. A new event is remembered before the + /// answer, so a second copy that arrives meanwhile is not. Only events + /// carry a number; the rest are always shown. If the file cannot be + /// written the event is shown anyway: a duplicate is better than a + /// missed alert. + public func accept(_ alert: PushAlert, nowMs: Double) -> Bool { + guard alert.verdict == .event else { return true } + let boot = alert.boot ?? Data() + return Self.lock.withLock { _ in + var entries = load() + if let i = entries.firstIndex(where: { $0.home == alert.home && $0.boot == boot }) { + guard alert.seq > entries[i].seq else { return false } + entries[i].seq = alert.seq + entries[i].atMs = nowMs + } else { + entries.append(Entry(home: alert.home, boot: boot, seq: alert.seq, atMs: nowMs)) + } + entries.sort { $0.atMs > $1.atMs } + try? save(Array(entries.prefix(Self.limit))) + return true + } + } + + private static let lock = Mutex(()) + + func load() -> [Entry] { + (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode([Entry].self, from: $0) } ?? [] + } + + private func save(_ entries: [Entry]) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(entries).write(to: file, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PushText.swift b/appleApp/FTWKit/Sources/FTWShared/PushText.swift new file mode 100644 index 00000000..f9d5fd1c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PushText.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The words of a push, written on the phone from the event's kind and +/// arguments, in Swedish or English (Fredrik, 12 Oct 2026). The box sends +/// codes and the service sends none of its own, so a lock screen never +/// shows a sentence this file does not hold. +/// +/// The English is the push catalogue's (`protocol/push-catalogue.yaml`, +/// the same file as in srcfl/ftw), and a test holds it to that file. +public enum PushText { + public enum Language: String, Sendable, CaseIterable { + case sv, en + + /// The person's first language: Swedish if it is Swedish, else + /// English. + public static func preferred(_ languages: [String] = Locale.preferredLanguages) -> Language { + languages.first?.lowercased().hasPrefix("sv") == true ? .sv : .en + } + } + + public struct Content: Sendable, Equatable { + public var title: String + public var body: String + + public init(title: String, body: String) { + self.title = title + self.body = body + } + } + + /// One kind's sentence. `{name}` marks an argument; `without` is the + /// body to show when an argument is missing or will not do. + struct Sentence { + var title: String + var body: String + var without: String? + } + + /// The line for anything that did not check out: no signature, an old + /// event, a home this phone does not hold. It says nothing the push + /// claimed. + public static func generic(_ language: Language) -> Content { + switch language { + case .sv: return Content(title: "Något hände hemma", body: "Öppna FTW för att se vad.") + case .en: return Content(title: "Something happened at home", body: "Open FTW to see what.") + } + } + + /// The words for a checked alert. `sourceName` is the device's name from + /// the box, for an event about a device. + public static func content(_ alert: PushAlert, sourceName: String? = nil, language: Language, decimalSeparator: String = Locale.current.decimalSeparator ?? ".") -> Content { + let kind: String + switch alert.verdict { + case .generic: return generic(language) + case .unreachable: kind = PushKind.boxUnreachable + case .event: kind = alert.kind + } + guard let s = sentences(language)[kind] else { return generic(language) } + let values = arguments(alert.args, name: sourceName, decimalSeparator: decimalSeparator) + guard let body = fill(s.body, values) else { return Content(title: s.title, body: s.without ?? s.body) } + return Content(title: s.title, body: body) + } + + static func sentences(_ language: Language) -> [String: Sentence] { + language == .sv ? sv : en + } + + static let en: [String: Sentence] = [ + PushKind.chargingConnected: Sentence(title: "Car plugged in", body: "Open to see charging status, check the battery level and set a ready time."), + PushKind.chargingSessionComplete: Sentence(title: "Car charged", body: "{kwh} kWh delivered — ready to go.", without: "Ready to go."), + PushKind.chargingGoalAtRisk: Sentence(title: "The car may not be ready", body: "It will not reach {target} % by {by} at this pace.", without: "It will not reach its goal in time at this pace."), + PushKind.chargingInterrupted: Sentence(title: "Charging stopped early", body: "The car stopped charging before it was done."), + PushKind.updateInstalled: Sentence(title: "Your box updated itself", body: "Now running {version}. Everything came back on its own.", without: "Everything came back on its own."), + PushKind.driverOffline: Sentence(title: "A device went quiet", body: "{name} stopped answering.", without: "A device stopped answering."), + PushKind.fuseOverLimit: Sentence(title: "The house is drawing too much", body: "{phase} is over the fuse rating.", without: "One phase is over the fuse rating."), + PushKind.boxUnreachable: Sentence(title: "Your box is out of reach", body: "It stopped answering. Power cut, or the internet at home is down."), + PushKind.cloudGranted: Sentence(title: "FTW Cloud is on", body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."), + PushKind.cloudRevoked: Sentence(title: "FTW Cloud is off", body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."), + ] + + static let sv: [String: Sentence] = [ + PushKind.chargingConnected: Sentence(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid."), + PushKind.chargingSessionComplete: Sentence(title: "Bilen är laddad", body: "{kwh} kWh laddat – redo att åka.", without: "Redo att åka."), + PushKind.chargingGoalAtRisk: Sentence(title: "Bilen kanske inte blir klar", body: "Den når inte {target} % till {by} i den här takten.", without: "Den når inte målet i tid i den här takten."), + PushKind.chargingInterrupted: Sentence(title: "Laddningen avbröts", body: "Bilen slutade ladda innan den var klar."), + PushKind.updateInstalled: Sentence(title: "Din box har uppdaterat sig", body: "Nu kör den {version}. Allt kom igång igen av sig självt.", without: "Allt kom igång igen av sig självt."), + PushKind.driverOffline: Sentence(title: "En enhet har tystnat", body: "{name} har slutat svara.", without: "En enhet har slutat svara."), + PushKind.fuseOverLimit: Sentence(title: "Huset drar för mycket ström", body: "{phase} drar mer än huvudsäkringen klarar.", without: "En fas drar mer än huvudsäkringen klarar."), + PushKind.boxUnreachable: Sentence(title: "Din box går inte att nå", body: "Den har slutat svara. Strömavbrott, eller så är internet hemma nere."), + PushKind.cloudGranted: Sentence(title: "FTW Cloud är på", body: "Din box delar nu en kopia av sina mätvärden med FTW Cloud. Du kan stänga av det i appen eller på boxen."), + PushKind.cloudRevoked: Sentence(title: "FTW Cloud är av", body: "Din box har slutat dela med FTW Cloud och bett den radera sin kopia."), + ] + + /// The arguments that will do, ready to show. The box signed them, and + /// they still pass a check: a lock screen is no place for a stray line + /// break or a paragraph. + static func arguments(_ args: [String: String], name: String?, decimalSeparator: String) -> [String: String] { + var out = [String: String]() + // Energy is kWh with the reader's decimal mark, never a percentage. + if let kwh = args["kwh"], kwh.wholeMatch(of: /[0-9]{1,5}(\.[0-9]{1,3})?/) != nil { + out["kwh"] = kwh.replacingOccurrences(of: ".", with: decimalSeparator) + } + if let target = args["target"], target.wholeMatch(of: /[0-9]{1,3}/) != nil, let pct = Int(target), pct <= 100 { + out["target"] = String(pct) + } + if let by = args["by"], let m = by.wholeMatch(of: /([0-9]{2}):([0-9]{2})/), let h = Int(m.1), let min = Int(m.2), h < 24, min < 60 { + out["by"] = by + } + for key in ["version", "phase"] { + if let v = args[key].flatMap(plain) { out[key] = v } + } + if let name = name.flatMap(plain) { out["name"] = name } + return out + } + + /// A short line of text, or nil. + static func plain(_ s: String) -> String? { + let t = s.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...40).contains(t.count), t.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) && !CharacterSet.newlines.contains($0) }) else { return nil } + return t + } + + /// The template with every `{name}` filled, or nil if one has no value. + static func fill(_ template: String, _ values: [String: String]) -> String? { + var out = "" + var rest = template[...] + while let open = rest.firstIndex(of: "{") { + guard let close = rest[open...].firstIndex(of: "}"), let value = values[String(rest[rest.index(after: open)..([]) + + func register(apnsToken: Data, environment: PushEnvironment) async { + got.withLock { $0.append(PushTokens.Token(data: apnsToken, environment: environment)) } + } + } + + @Test func theSinkGetsEachTokenOnce() async { + let asked = Mutex(0) + PushTokens.setAsk { asked.withLock { $0 += 1 } } + defer { PushTokens.uninstall() } + let a = Data([1, 2, 3]) + let b = Data([4, 5, 6]) + + // A token from an earlier yes, before anything wants it. + await PushTokens.received(a, environment: .sandbox) + #expect(asked.withLock { $0 } == 0) + #expect(PushTokens.current == PushTokens.Token(data: a, environment: .sandbox)) + + let sink = Sink() + await PushTokens.install(sink) + #expect(asked.withLock { $0 } == 1) + // Apple hands the same token again after the ask: not repeated. + await PushTokens.received(a, environment: .sandbox) + await PushTokens.received(b, environment: .sandbox) + #expect(sink.got.withLock { $0 } == [.init(data: a, environment: .sandbox), .init(data: b, environment: .sandbox)]) + + PushTokens.uninstall() + await PushTokens.received(a, environment: .production) + #expect(sink.got.withLock { $0 }.count == 2) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift index 6c2be65c..7b2c5247 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/RemoteTests.swift @@ -51,8 +51,8 @@ import FTWShared /// Run a command the way an intent does, from a cold app: nothing /// read yet, the stream not yet up. - func perform(_ command: RemoteCommand, _ loadpoints: LoadpointsModel, for ms: Double = 5_000) async -> RemoteOutcome { - let task = Task { await loadpoints.perform(command) } + func perform(_ command: RemoteCommand, _ loadpoints: LoadpointsModel, loadpointID: String? = nil, for ms: Double = 5_000) async -> RemoteOutcome { + let task = Task { await loadpoints.perform(command, loadpointID: loadpointID) } await run(ms) return await task.value } @@ -201,6 +201,28 @@ import FTWShared } } + /// A notification's 50 %: any whole level, on the charger its event + /// named. A charger the box does not have is never swapped for one it + /// does. + @Test func aNotificationNamesItsCharger() async throws { + try await AppWords.$language.withValue(.en) { + let rig = Rig() + let loadpoints = try rig.start() + let out = await rig.perform(.setLevel(pct: 50), loadpoints, loadpointID: "garage") + #expect(out.kind == .done) + let cmd = try #require(rig.wire?.commands.last) + #expect(cmd.op == Contract.opLoadpointSocSet) + #expect(cmd.args["soc"]?.double == 0.5) + #expect(cmd.args["id"]?.string == "garage") + + let sent = rig.wire?.commands.count + let elsewhere = await rig.perform(.chargeNow, loadpoints, loadpointID: "street") + #expect(elsewhere.kind == .failed) + #expect(elsewhere.text == "FTW has no charger to control.") + #expect(rig.wire?.commands.count == sent) + } + } + /// The box refuses a level for a car that is not plugged in; Siri says /// what to do. @Test func aRefusalIsAFailureThatSaysWhatToDo() async throws { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift new file mode 100644 index 00000000..148a28bb --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift @@ -0,0 +1,304 @@ +import CryptoKit +import Foundation +import Testing +@testable import FTWShared + +/// The push check against srcfl/ftw's own vectors: every case in +/// push-vectors.json, as the Go reference reads it, and every message in +/// cloud-vectors.json for the envelope and the bytes a signature covers. +/// Then the edges the vectors do not reach, signed here with the vector box +/// key. +@Suite struct PushAlertTests { + struct PushFile: Decodable { + struct Key: Decodable { var home: String; var pk: String } + struct Want: Decodable { + var verdict: String + var home: String + var kind: String? + var args: [String: String]? + var src: String? + var lp: String? + var boot: String? + var seq: UInt64? + var atMs: Int64? + } + struct Case: Decodable { + var name: String + var payload: String + var nowMs: Int64 + var want: Want + } + var keys: [Key] + var cases: [Case] + } + + struct CloudFile: Decodable { + struct Key: Decodable { var d: String?; var pk: String; var home: String? } + struct Message: Decodable { + var name: String + var signer: String + var t: String + var body: String + var signedInputSha256: String + var sig: String + var envelope: String + } + var boxKey: Key + var serviceKey: Key + var messages: [Message] + } + + static func load(_ name: String, as: T.Type) throws -> T { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Resources")) + return try JSONDecoder().decode(T.self, from: Data(contentsOf: url)) + } + + // MARK: The Go side's vectors + + @Test func everyPushVectorReadsAsTheGoReferenceReadsIt() throws { + let file = try Self.load("push-vectors", as: PushFile.self) + #expect(file.cases.count == 14) + let keys = Dictionary(uniqueKeysWithValues: file.keys.map { ($0.home, Data(hex: $0.pk)) }) + for c in file.cases { + let payload = Data(c.payload.utf8) + let fromJSON = PushAlert.read(payload: payload, boxKey: { keys[$0] }, nowMs: c.nowMs) + // The extension gets userInfo, not the bytes: both roads agree. + let userInfo = try #require(JSONSerialization.jsonObject(with: payload) as? [AnyHashable: Any]) + let fromUserInfo = PushAlert.read(userInfo: userInfo, boxKey: { keys[$0] }, nowMs: c.nowMs) + #expect(fromJSON == fromUserInfo, "\(c.name)") + + let a = fromJSON + let w = c.want + #expect(a.verdict.rawValue == w.verdict, "\(c.name)") + #expect(a.home == w.home, "\(c.name)") + #expect(a.kind == (w.kind ?? ""), "\(c.name)") + #expect(a.args == (w.args ?? [:]), "\(c.name)") + #expect((a.src ?? "") == (w.src ?? ""), "\(c.name)") + #expect((a.lp ?? "") == (w.lp ?? ""), "\(c.name)") + #expect((a.boot?.hex ?? "") == (w.boot ?? ""), "\(c.name)") + #expect(a.seq == (w.seq ?? 0), "\(c.name)") + #expect(a.atMs == (w.atMs ?? 0), "\(c.name)") + } + } + + /// Every envelope parses, its body is the bytes carried, the signed + /// input hashes as Go's does, and each signature verifies with its + /// signer's key and with no other. + @Test func everyCloudMessageVerifiesOverTheBytesItCarries() throws { + let file = try Self.load("cloud-vectors", as: CloudFile.self) + let home = try #require(file.boxKey.home) + let keys = ["box": Data(hex: file.boxKey.pk), "service": Data(hex: file.serviceKey.pk)] + #expect(file.messages.count == 16) + for m in file.messages { + let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope))), "\(m.name)") + #expect(env.t == m.t, "\(m.name)") + #expect(Data(env.b) == Data(hex: m.body), "\(m.name)") + #expect(Data(env.sig) == Data(hex: m.sig), "\(m.name)") + let input = PushCBOR.signedInput(home: home, t: env.t, b: env.b) + #expect(Data(SHA256.hash(data: input)) == Data(hex: m.signedInputSha256), "\(m.name)") + for (signer, pk) in keys { + #expect(PushAlert.verify(pk, home: home, env) == (signer == m.signer), "\(m.name) with the \(signer) key") + } + // Signed for another home, never valid. + if let pk = keys[m.signer] { + #expect(!PushAlert.verify(pk, home: "aaaaaaaaaaaaaaaaaaaaaaaaaa", env), "\(m.name)") + } + // The strict reader takes every body the box sends. + #expect((try? PushCBOR.decode(env.b)) != nil, "\(m.name)") + } + } + + /// The three events of the shared vectors, delivered fresh, read as + /// events, and session complete carries its energy. + @Test func theSharedEventsReadAsEvents() throws { + let file = try Self.load("cloud-vectors", as: CloudFile.self) + let home = try #require(file.boxKey.home) + let pk = Data(hex: file.boxKey.pk) + for m in file.messages where m.t == "event" { + let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope)))) + let ev = try #require(PushAlert.Event(env.b)) + let now = try #require(ev.wallMs) + 1_200 + let a = PushAlert.read(payload: Self.payload(home: home, event: Data(hex: m.envelope)), boxKey: { $0 == home ? pk : nil }, nowMs: now) + #expect(a.verdict == .event, "\(m.name)") + #expect(a.kind == ev.kind) + #expect(a.lp == "j3HZORNCWHs") + #expect(a.src == "DUYWWQmn-Xg") + #expect(a.boot == Data(repeating: 0xb0, count: 16)) + if ev.kind == PushKind.chargingSessionComplete { + #expect(a.args == ["kwh": "18.4"]) + #expect(PushText.content(a, language: .en, decimalSeparator: ".").body == "18.4 kWh delivered — ready to go.") + #expect(PushText.content(a, language: .sv, decimalSeparator: ",").body == "18,4 kWh laddat – redo att åka.") + } + } + } + + // MARK: Edges, signed here with the vector box + + static let boxKey: P256.Signing.PrivateKey = { + let file = try! load("cloud-vectors", as: CloudFile.self) + return try! P256.Signing.PrivateKey(rawRepresentation: Data(hex: file.boxKey.d!)) + }() + static var home: String { "fjzyr67buzpyncqmjliwgezvde" } + static var pk: Data { boxKey.publicKey.x963Representation } + static let wall: Int64 = 1_791_820_800_000 + + static func body(_ extra: [(String, TestCBOR)] = [], drop: Set = [], kind: String = PushKind.chargingConnected) -> [UInt8] { + var fields: [(String, TestCBOR)] = [ + ("seq", .u(9)), ("boot", .b(Array(repeating: 0xb0, count: 16))), ("uptimeMs", .u(71_000)), + ("wallMs", .u(UInt64(wall))), ("eventId", .t("Q2ZrMjR0dzVh")), ("kind", .t(kind)), + ("severity", .t("info")), ("args", .m([])), ("lp", .t("j3HZORNCWHs")), + ] + fields.removeAll { drop.contains($0.0) || extra.map(\.0).contains($0.0) } + return TestCBOR.m(fields + extra).encoded + } + + static func envelope(_ body: [UInt8], t: String = "event", home: String = home) -> [UInt8] { + let sig = try! boxKey.signature(for: PushCBOR.signedInput(home: home, t: t, b: body)).rawRepresentation + return TestCBOR.m([("b", .b(body)), ("t", .t(t)), ("sig", .b(Array(sig)))]).encoded + } + + static func payload(home: String = home, event: Data? = nil, kind: String? = nil) -> Data { + var ftw: [String: Any] = ["v": 1, "home": home] + if let event { ftw["event"] = event.base64EncodedString() } + if let kind { ftw["kind"] = kind } + let aps: [String: Any] = ["alert": ["title-loc-key": "push.title", "loc-key": "push.generic"], "mutable-content": 1, "sound": "default"] + return try! JSONSerialization.data(withJSONObject: ["aps": aps, "ftw": ftw]) + } + + static func read(_ env: [UInt8], nowMs: Int64 = wall + 1_200, kind: String? = nil) -> PushAlert { + PushAlert.read(payload: payload(event: Data(env), kind: kind), boxKey: { $0 == home ? pk : nil }, nowMs: nowMs) + } + + @Test func aFreshSignedEventReads() { + let a = Self.read(Self.envelope(Self.body())) + #expect(a.verdict == .event) + #expect(a.atMs == Self.wall) + #expect(a.seq == 9) + #expect(a.src == nil) + } + + /// Unknown keys are a newer box, not a fault. + @Test func unknownKeysAreIgnored() { + #expect(Self.read(Self.envelope(Self.body([("later", .m([("x", .u(1))]))]))).verdict == .event) + } + + /// A field of the wrong type is not the event that was meant. + @Test func aFieldOfTheWrongTypeIsGeneric() { + for extra: (String, TestCBOR) in [("seq", .t("9")), ("wallMs", .t("now")), ("kind", .b([1])), ("args", .m([("kwh", .u(18))])), ("lp", .u(1)), ("ts", .neg(UInt64.max))] { + #expect(Self.read(Self.envelope(Self.body([extra]))).verdict == .generic, "\(extra.0)") + } + } + + /// The box clock anchors the event; the phone's bounds it. + @Test func theClockRules() { + let b = Self.body() + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).verdict == .event) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_001).verdict == .generic) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall - 2_000).atMs == Self.wall - 2_000) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_000).verdict == .event) + #expect(Self.read(Self.envelope(b), nowMs: Self.wall + 600_001).verdict == .generic) + // The event's own time, when earlier, is when it happened. + let held = Self.body([("ts", .u(UInt64(Self.wall - 30_000)))]) + #expect(Self.read(Self.envelope(held)).atMs == Self.wall - 30_000) + #expect(Self.read(Self.envelope(held), nowMs: Self.wall + 570_001).verdict == .generic) + // No wall clock, no anchor. + #expect(Self.read(Self.envelope(Self.body(drop: ["wallMs"]))).verdict == .generic) + } + + /// The service's kind beside an event, or the box signing the one kind + /// only the service may send, is generic. + @Test func onlyTheServiceSaysUnreachableAndOnlyAlone() { + #expect(Self.read(Self.envelope(Self.body()), kind: PushKind.boxUnreachable).verdict == .generic) + #expect(Self.read(Self.envelope(Self.body(kind: PushKind.boxUnreachable))).verdict == .generic) + let alone = PushAlert.read(payload: Self.payload(kind: PushKind.boxUnreachable), boxKey: { _ in nil }, nowMs: Self.wall) + #expect(alone.verdict == .unreachable) + #expect(alone.kind.isEmpty && alone.args.isEmpty) + } + + /// Another type of message, signed by the same box, is not an event. + @Test func onlyAnEventIsAnEvent() { + #expect(Self.read(Self.envelope(Self.body(), t: "hb")).verdict == .generic) + } + + @Test func aPayloadThatIsNotOursIsGenericWithoutAHome() { + for json in [#"{"aps":{}}"#, #"{"ftw":{"v":2,"home":"x"}}"#, #"{"ftw":{"v":true,"home":"x"}}"#, #"{"ftw":{"v":1,"home":7}}"#, #"{"ftw":{"v":1,"home":"x","event":"not base64!"}}"#, #"{"ftw":null}"#, "[]", "nonsense"] { + let a = PushAlert.read(payload: Data(json.utf8), boxKey: { _ in Self.pk }, nowMs: Self.wall) + #expect(a == PushAlert(.generic, home: ""), "\(json)") + } + } + + /// The envelope's shape is checked byte by byte. + @Test func theEnvelopeIsStrict() throws { + let b = Self.body() + let sig = Array(try Self.boxKey.signature(for: PushCBOR.signedInput(home: Self.home, t: "event", b: b)).rawRepresentation) + let good = TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("sig", .b(sig))]).encoded + #expect(PushAlert.Envelope(good) != nil) + // t as bytes, b as text, no sig, an empty t. + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .b(Array("event".utf8))), ("sig", .b(sig))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .t("x")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event"))]).encoded) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("")), ("sig", .b(sig))]).encoded) == nil) + // A byte after the value, a key twice, an indefinite length. + #expect(PushAlert.Envelope(good + [0x00]) == nil) + #expect(PushAlert.Envelope(TestCBOR.m([("b", .b(b)), ("t", .t("event")), ("t", .t("event")), ("sig", .b(sig))]).encoded) == nil) + var indefinite = good + indefinite[0] = 0xbf + #expect(PushAlert.Envelope(indefinite + [0xff]) == nil) + // Truncated anywhere. + for n in 0.. [UInt8] { + let m = major << 5 + switch n { + case 0..<24: return [m | UInt8(n)] + case 24...0xff: return [m | 24, UInt8(n)] + case 0x100...0xffff: return [m | 25, UInt8(n >> 8), UInt8(n & 0xff)] + case 0x1_0000...0xffff_ffff: return [m | 26] + (0..<4).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + default: return [m | 27] + (0..<8).reversed().map { UInt8(truncatingIfNeeded: n >> ($0 * 8)) } + } + } + switch self { + case .u(let n): return head(0, n) + case .neg(let n): return head(1, n) + case .b(let b): return head(2, UInt64(b.count)) + b + case .t(let s): return head(3, UInt64(s.utf8.count)) + Array(s.utf8) + case .m(let pairs): return head(5, UInt64(pairs.count)) + pairs.flatMap { TestCBOR.t($0.0).encoded + $0.1.encoded } + } + } +} + +extension Data { + init(hex: String) { + var out = Data() + var it = hex.makeIterator() + while let a = it.next(), let b = it.next() { + out.append(UInt8(String([a, b]), radix: 16)!) + } + self = out + } + + var hex: String { map { String(format: "%02x", $0) }.joined() } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift new file mode 100644 index 00000000..af563aa4 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushStoreTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +@testable import FTWShared + +/// What the app leaves for the notification extension, and what the +/// extension remembers between pushes. +@Suite struct PushStoreTests { + static let home = "fjzyr67buzpyncqmjliwgezvde" + static let key = Data([0x04] + Array(repeating: 0x11, count: 64)) + + static func temp() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("ftw-push-\(UUID().uuidString)", isDirectory: true) + } + + static func event(home: String = home, boot: UInt8 = 0xb0, seq: UInt64) -> PushAlert { + PushAlert(.event, home: home, kind: PushKind.chargingConnected, boot: Data(repeating: boot, count: 16), seq: seq, atMs: 1) + } + + // MARK: CloudKeys + + @Test func aHomeReadsBackAsWritten() throws { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let keys = CloudKeys(directory: dir) + let row = CloudHome(home: Self.home, siteId: "site", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"], sources: ["DUYWWQmn-Xg": "Easee"]) + try keys.write(row) + #expect(keys.read(home: Self.home) == row) + #expect(keys.boxKey(home: Self.home) == Self.key) + #expect(keys.read(home: "t3w5bfotxeygcjxjdhjaeo6pvq") == nil) + keys.remove(home: Self.home) + #expect(keys.read(home: Self.home) == nil) + } + + /// The home id comes from a push. It never names a file outside the + /// store, and a key that is not a point is refused at the door. + @Test func onlyAHomeIDAndAPointAreKept() throws { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let keys = CloudKeys(directory: dir) + for bad in ["../../etc/passwd", "FJZYR67BUZPYNCQMJLIWGEZVDE", "fjzyr67buzpyncqmjliwgezvd", "fjzyr67buzpyncqmjliwgezvd1", ""] { + #expect(!CloudKeys.isHomeID(bad), "\(bad)") + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: bad, siteId: "s", boxKey: Self.key)) } + #expect(keys.read(home: bad) == nil) + } + #expect(CloudKeys.isHomeID(Self.home)) + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data(Self.key.dropFirst()))) } + #expect(throws: CloudKeys.Refused.self) { try keys.write(CloudHome(home: Self.home, siteId: "s", boxKey: Data([0x02]) + Self.key.dropFirst())) } + } + + /// The event's charger, through the box's table. A home with one + /// charger needs no name for it; an unknown name is not guessed. + @Test func theChargerAnEventMeans() { + let one = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["j3HZORNCWHs": "garage"]) + #expect(one.loadpointID(opaque: "j3HZORNCWHs") == "garage") + #expect(one.loadpointID(opaque: nil) == "garage") + #expect(one.loadpointID(opaque: "unknown") == nil) + let two = CloudHome(home: Self.home, siteId: "s", boxKey: Self.key, loadpoints: ["a": "garage", "b": "street"]) + #expect(two.loadpointID(opaque: nil) == nil) + #expect(two.loadpointID(opaque: "b") == "street") + #expect(CloudHome(home: Self.home, siteId: "s", boxKey: Self.key).loadpointID(opaque: nil) == nil) + } + + // MARK: PushSeen + + @Test func anEventIsShownOnce() { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let seen = PushSeen(file: dir.appendingPathComponent("push-seen")) + #expect(seen.accept(Self.event(seq: 9), nowMs: 1)) + #expect(!seen.accept(Self.event(seq: 9), nowMs: 2)) + // Older than one shown. + #expect(!seen.accept(Self.event(seq: 8), nowMs: 3)) + #expect(seen.accept(Self.event(seq: 10), nowMs: 4)) + // A restart counts again; another home counts on its own. + #expect(seen.accept(Self.event(boot: 0xc0, seq: 1), nowMs: 5)) + #expect(seen.accept(Self.event(home: "t3w5bfotxeygcjxjdhjaeo6pvq", seq: 9), nowMs: 6)) + // Remembered across processes: a fresh reader of the same file. + #expect(!PushSeen(file: seen.file).accept(Self.event(seq: 10), nowMs: 7)) + // Only events carry a number. + #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 8)) + #expect(seen.accept(PushAlert(.unreachable, home: Self.home), nowMs: 9)) + #expect(seen.accept(PushAlert(.generic, home: Self.home), nowMs: 10)) + } + + @Test func theFileStaysSmall() { + let dir = Self.temp() + defer { try? FileManager.default.removeItem(at: dir) } + let seen = PushSeen(file: dir.appendingPathComponent("push-seen")) + for boot in 0.. [Entry] { + let yaml = try String(contentsOf: repo.appendingPathComponent("protocol/push-catalogue.yaml"), encoding: .utf8) + var out = [Entry]() + for line in yaml.split(separator: "\n") { + let t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("- kind: ") { + out.append(Entry(kind: String(t.dropFirst(8)), title: "", body: "")) + } else if t.hasPrefix("title: "), !out.isEmpty { + out[out.count - 1].title = String(t.dropFirst(7)) + } else if t.hasPrefix("body: "), !out.isEmpty { + var body = String(t.dropFirst(6)) + if body.hasPrefix("\""), body.hasSuffix("\"") { body = String(body.dropFirst().dropLast()) } + out[out.count - 1].body = body + } + } + return out + } + + static func event(_ kind: String, args: [String: String] = [:], src: String? = nil, lp: String? = nil) -> PushAlert { + PushAlert(.event, home: "fjzyr67buzpyncqmjliwgezvde", kind: kind, args: args, src: src, lp: lp, boot: Data(repeating: 0xb0, count: 16), seq: 9, atMs: 1) + } + + // MARK: The catalogue + + @Test func theCatalogueIsTheOneTheBoxHas() throws { + let entries = try Self.catalogue() + #expect(entries.map(\.kind) == PushKind.all) + #expect(entries.allSatisfy { !$0.title.isEmpty && !$0.body.isEmpty }) + } + + /// Every kind has both languages, and the English is the catalogue's, + /// placeholders and all. + @Test func everyKindHasSwedishAndTheCataloguesEnglish() throws { + for e in try Self.catalogue() { + let en = try #require(PushText.en[e.kind], "\(e.kind)") + let sv = try #require(PushText.sv[e.kind], "\(e.kind)") + #expect(en.title == e.title, "\(e.kind)") + #expect(en.body == e.body, "\(e.kind)") + // Swedish asks for the same arguments, and has the same way out. + #expect(Self.placeholders(sv.body) == Self.placeholders(en.body), "\(e.kind)") + #expect((sv.without == nil) == (en.without == nil), "\(e.kind)") + #expect(Self.placeholders(en.body).isEmpty == (en.without == nil), "\(e.kind)") + #expect(sv.title != en.title, "\(e.kind)") + } + #expect(Set(PushText.en.keys) == Set(PushKind.all)) + #expect(Set(PushText.sv.keys) == Set(PushKind.all)) + } + + static func placeholders(_ s: String) -> [String] { + s.matches(of: /\{([a-z]+)\}/).map { String($0.1) }.sorted() + } + + /// Filled, the English reads exactly as the catalogue with its + /// placeholders filled. + @Test func filledEnglishIsTheCataloguesSentence() throws { + let args = ["kwh": "18.4", "target": "80", "by": "07:00", "version": "0.142.0", "phase": "L2"] + for e in try Self.catalogue() where e.kind != PushKind.boxUnreachable { + var want = e.body + for (k, v) in args { want = want.replacingOccurrences(of: "{\(k)}", with: v) } + want = want.replacingOccurrences(of: "{name}", with: "Easee") + let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "s", boxKey: Data(), sources: ["DUYWWQmn-Xg": "Easee"]) + let got = PushText.content(Self.event(e.kind, args: args, src: "DUYWWQmn-Xg"), sourceName: home.sourceName(opaque: "DUYWWQmn-Xg"), language: .en, decimalSeparator: ".") + #expect(got == PushText.Content(title: e.title, body: want), "\(e.kind)") + } + } + + @Test func theSwedishReadsNaturally() { + let sv = { (kind: String, args: [String: String], name: String?) in + PushText.content(Self.event(kind, args: args), sourceName: name, language: .sv, decimalSeparator: ",") + } + #expect(sv(PushKind.chargingConnected, [:], nil) == .init(title: "Bilen är inkopplad", body: "Öppna för att se laddningen, kolla bilens nivå och välja klartid.")) + #expect(sv(PushKind.chargingSessionComplete, ["kwh": "18.4"], nil) == .init(title: "Bilen är laddad", body: "18,4 kWh laddat – redo att åka.")) + #expect(sv(PushKind.chargingGoalAtRisk, ["target": "80", "by": "07:00"], nil).body == "Den når inte 80 % till 07:00 i den här takten.") + #expect(sv(PushKind.updateInstalled, ["version": "0.142.0"], nil).body == "Nu kör den 0.142.0. Allt kom igång igen av sig självt.") + #expect(sv(PushKind.driverOffline, [:], "Easee").body == "Easee har slutat svara.") + #expect(sv(PushKind.fuseOverLimit, ["phase": "L2"], nil).body == "L2 drar mer än huvudsäkringen klarar.") + } + + /// Energy is kWh with the reader's decimal mark. Never a percentage. + @Test func energyIsKilowattHours() { + for (sep, want) in [(".", "18.4 kWh delivered — ready to go."), (",", "18,4 kWh delivered — ready to go.")] { + #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "18.4"]), language: .en, decimalSeparator: sep).body == want) + } + #expect(PushText.content(Self.event(PushKind.chargingSessionComplete, args: ["kwh": "7"]), language: .sv, decimalSeparator: ",").body == "7 kWh laddat – redo att åka.") + for language in PushText.Language.allCases { + #expect(!PushText.sentences(language)[PushKind.chargingSessionComplete]!.body.contains("%")) + } + } + + /// An argument that is missing or will not do gives the sentence + /// without it, never a half-filled one. + @Test func aBadArgumentGivesTheSentenceWithoutIt() { + let cases: [(String, [String: String], String)] = [ + (PushKind.chargingSessionComplete, [:], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "18.4%"], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "1e3"], "Ready to go."), + (PushKind.chargingSessionComplete, ["kwh": "١٨"], "Ready to go."), + (PushKind.chargingGoalAtRisk, ["target": "180", "by": "07:00"], "It will not reach its goal in time at this pace."), + (PushKind.chargingGoalAtRisk, ["target": "80", "by": "7:00"], "It will not reach its goal in time at this pace."), + (PushKind.chargingGoalAtRisk, ["target": "80", "by": "25:00"], "It will not reach its goal in time at this pace."), + (PushKind.updateInstalled, ["version": "1.0\nCall this number"], "Everything came back on its own."), + (PushKind.updateInstalled, ["version": String(repeating: "x", count: 41)], "Everything came back on its own."), + (PushKind.fuseOverLimit, ["phase": ""], "One phase is over the fuse rating."), + (PushKind.driverOffline, [:], "A device stopped answering."), + ] + for (kind, args, body) in cases { + #expect(PushText.content(Self.event(kind, args: args), language: .en, decimalSeparator: ".").body == body, "\(kind) \(args)") + } + // A name comes only from the box's table, never from the event. + #expect(PushText.content(Self.event(PushKind.driverOffline, args: ["name": "Mallory"]), language: .en).body == "A device stopped answering.") + } + + @Test func theGenericLineSaysNothingThePushClaimed() { + #expect(PushText.generic(.sv) == .init(title: "Något hände hemma", body: "Öppna FTW för att se vad.")) + #expect(PushText.generic(.en) == .init(title: "Something happened at home", body: "Open FTW to see what.")) + #expect(PushText.content(PushAlert(.generic, home: "x"), language: .sv) == PushText.generic(.sv)) + #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .en).title == "Your box is out of reach") + #expect(PushText.content(PushAlert(.unreachable, home: "x"), language: .sv).title == "Din box går inte att nå") + } + + /// What Apple shows if the extension never runs: the app's own strings + /// for the payload's keys, which are the generic line. + @Test func theAppsFallbackStringsAreTheGenericLine() throws { + for language in PushText.Language.allCases { + let file = Self.repo.appendingPathComponent("appleApp/FTW/Intents/\(language.rawValue).lproj/Localizable.strings") + let strings = try #require(NSDictionary(contentsOf: file) as? [String: String], "\(language)") + #expect(strings["push.title"] == PushText.generic(language).title, "\(language)") + #expect(strings["push.generic"] == PushText.generic(language).body, "\(language)") + } + } + + @Test func theLanguageIsThePersonsFirst() { + #expect(PushText.Language.preferred(["sv-SE", "en-US"]) == .sv) + #expect(PushText.Language.preferred(["SV"]) == .sv) + #expect(PushText.Language.preferred(["en-SE", "sv-SE"]) == .en) + #expect(PushText.Language.preferred(["de-DE"]) == .en) + #expect(PushText.Language.preferred([]) == .en) + } + + // MARK: Buttons + + @Test func onlyAVerifiedKindPicksButtons() { + #expect(PushActions.category(for: Self.event(PushKind.chargingConnected)) == .level) + #expect(PushActions.category(for: Self.event(PushKind.chargingInterrupted)) == .chargeNow) + #expect(PushActions.category(for: Self.event(PushKind.chargingGoalAtRisk)) == .chargeNow) + for kind in PushKind.all where ![PushKind.chargingConnected, PushKind.chargingInterrupted, PushKind.chargingGoalAtRisk].contains(kind) { + #expect(PushActions.category(for: Self.event(kind)) == nil, "\(kind)") + } + #expect(PushActions.category(for: PushAlert(.unreachable, home: "x")) == nil) + // A generic alert carries no kind it could be tricked by. + #expect(PushActions.category(for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected)) == nil) + #expect(PushActions.Category.level.actions == [.level20, .level50, .level80, .open]) + #expect(PushActions.Category.chargeNow.actions == [.chargeNow, .open]) + } + + @Test func aButtonRunsOnlyForTheKindThatOffersIt() { + let connected = Self.event(PushKind.chargingConnected) + let atRisk = Self.event(PushKind.chargingGoalAtRisk) + #expect(PushActions.allows(.level50, for: connected)) + #expect(!PushActions.allows(.chargeNow, for: connected)) + #expect(PushActions.allows(.chargeNow, for: atRisk)) + #expect(!PushActions.allows(.level80, for: atRisk)) + for action in PushActions.Action.allCases { + #expect(!PushActions.allows(action, for: Self.event(PushKind.cloudGranted))) + #expect(!PushActions.allows(action, for: PushAlert(.generic, home: "x", kind: PushKind.chargingConnected))) + } + } + + /// The levels are the car's level, any whole percent; Charge now is the + /// Control's command. Open asks the box nothing. + @Test func whatEachButtonAsks() { + #expect(PushActions.command(.level20) == .setLevel(pct: 20)) + #expect(PushActions.command(.level50) == .setLevel(pct: 50)) + #expect(PushActions.command(.level80) == .setLevel(pct: 80)) + #expect(PushActions.command(.chargeNow) == .chargeNow) + #expect(PushActions.command(.open) == nil) + #expect(PushActions.Action.allCases.filter(PushActions.opensApp) == [.open]) + #expect(PushActions.title(.chargeNow, .sv) == "Ladda nu") + #expect(PushActions.title(.open, .sv) == "Öppna") + #expect(PushActions.title(.open, .en) == "Open") + #expect(PushActions.title(.level50, .sv) == "50 %") + } + + // MARK: The notice + + @Test func theNoticeSetsEverythingItself() { + let home = CloudHome(home: "fjzyr67buzpyncqmjliwgezvde", siteId: "site", boxKey: Data(), loadpoints: ["j3HZORNCWHs": "garage"]) + let connected = PushNotice(Self.event(PushKind.chargingConnected, lp: "j3HZORNCWHs"), home: home, language: .en) + #expect(connected.category == "ftw.level") + #expect(connected.level == .active) + #expect(connected.thread == "fjzyr67buzpyncqmjliwgezvde") + #expect(connected.verified == ["verdict": "event", "home": "fjzyr67buzpyncqmjliwgezvde", "kind": PushKind.chargingConnected, "seq": "9", "atMs": "1", "loadpointID": "garage"]) + + let interrupted = PushNotice(Self.event(PushKind.chargingInterrupted), home: home, language: .sv) + #expect(interrupted.level == .timeSensitive) + #expect(interrupted.category == "ftw.chargeNow") + #expect(interrupted.title == "Laddningen avbröts") + + let unreachable = PushNotice(PushAlert(.unreachable, home: "fjzyr67buzpyncqmjliwgezvde"), home: nil, language: .en) + #expect(unreachable.level == .timeSensitive) + #expect(unreachable.category == "") + #expect(unreachable.verified == ["verdict": "unreachable", "home": "fjzyr67buzpyncqmjliwgezvde"]) + + let generic = PushNotice(PushAlert(.generic, home: "fjzyr67buzpyncqmjliwgezvde"), home: home, language: .sv) + #expect(generic.category == "") + #expect(generic.level == .active) + #expect(generic.verified.isEmpty) + #expect(generic.title == "Något hände hemma") + #expect(PushNotice.generic(.en).category == "") + + // Shown before: the generic line, quiet, no buttons, same thread. + let again = PushNotice.repeated(Self.event(PushKind.chargingConnected), language: .sv) + #expect(again.title == "Något hände hemma") + #expect(again.category == "") + #expect(again.level == .passive) + #expect(!again.sound) + #expect(again.thread == "fjzyr67buzpyncqmjliwgezvde") + #expect(connected.sound && unreachable.sound) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json new file mode 100644 index 00000000..9ad22446 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json @@ -0,0 +1,196 @@ +{ + "boxKey": { + "d": "6c50811aa47e688ecbb01b2c1e91aca4a2ad7c879526d1c9679ef3fd25560074", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8", + "home": "fjzyr67buzpyncqmjliwgezvde" + }, + "serviceKey": { + "d": "f8d258ed839f4602dcfc2c139237d740e960310da68140e23bf88bf4c3c6ffa5", + "pk": "043cb883f267f64e085082b9025d318d83a1b36bc9fa474091074498c6417100da767b737fc59344ddb1b3ae5ddd4b6446f330315d79a0fb7dc88e96a5ec827c9e" + }, + "grantKey": "7777777777777777777777777777777777777777777777777777777777777777", + "ids": [ + { + "kind": "src", + "name": "p1meter", + "id": "ACfHt4DWL7g" + }, + { + "kind": "src", + "name": "sungrow", + "id": "f-oHMDqV-mA" + }, + { + "kind": "src", + "name": "easee", + "id": "DUYWWQmn-Xg" + }, + { + "kind": "lp", + "name": "garage", + "id": "j3HZORNCWHs" + } + ], + "messages": [ + { + "name": "challenge", + "signer": "", + "t": "challenge", + "body": "a2617601656e6f6e636558201111111111111111111111111111111111111111111111111111111111111111", + "signedInputSha256": "b8baed8e00b3e02409d01f18a83294c626a1ee5d43a3e38efd071fb2a89e92f0", + "sig": "", + "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740", + "frame": "010100000042a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740" + }, + { + "name": "hello", + "signer": "box", + "t": "hello", + "body": "ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f7374657253657104", + "signedInputSha256": "85ba027484b1e3ca0d10cca2602d07413d2dac2850e410e468274ee17ac43068", + "sig": "32b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "frame": "0101000001a9a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d" + }, + { + "name": "hello_ok", + "signer": "service", + "t": "hello_ok", + "body": "a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f7374657253657103", + "signedInputSha256": "499bf81555f32a94ce6e39a3e1eb43880773b33005ae5efe459a8227aee4d855", + "sig": "230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "frame": "0101000000b9a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d" + }, + { + "name": "roster", + "signer": "box", + "t": "roster", + "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf", + "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", + "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124" + }, + { + "name": "live full", + "signer": "box", + "t": "live", + "body": "aa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b6564427980", + "signedInputSha256": "e45fdca79363137b374011804fb5905c1452b533cb275c89de0aa61562886393", + "sig": "02a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "frame": "010100000250a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95" + }, + { + "name": "live delta", + "signer": "box", + "t": "live", + "body": "a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b6564427980", + "signedInputSha256": "6881cdc040c9d5e58e85e7bfb9e3b15ae13e6a9c235a06070c43d870df0976fd", + "sig": "0dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "frame": "0101000000f3a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6" + }, + { + "name": "lp", + "signer": "box", + "t": "lp", + "body": "a7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e", + "signedInputSha256": "4774dda3175a23d752f2b1aea4e1959d50edec99f4882caf007779daa829eb8f", + "sig": "b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79" + }, + { + "name": "prices", + "signer": "box", + "t": "prices", + "body": "aa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847", + "signedInputSha256": "022b3d2ae277cd3caee80775e0a2fe1c8ecebcce3263f586c0ee5e4af96a00e7", + "sig": "e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "frame": "0101000000e0a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3" + }, + { + "name": "hb", + "signer": "box", + "t": "hb", + "body": "a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c", + "signedInputSha256": "6961171385681257aeb2a889a6c33b5854b95e85cef4d9a8fd3b6dfb7d709541", + "sig": "e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8" + }, + { + "name": "watch", + "signer": "", + "t": "watch", + "body": "a162687a01", + "signedInputSha256": "64808965667acfc5db8cd769b2c18654ff94b593dd47363d30b0835f438bde81", + "sig": "", + "envelope": "a3616245a162687a0161746577617463686373696740", + "frame": "010100000016a3616245a162687a0161746577617463686373696740" + }, + { + "name": "resync", + "signer": "", + "t": "resync", + "body": "a16477686174816466756c6c", + "signedInputSha256": "778a30a84492300eb2b2b14555a35e0ac73d2f145d5ef5c0b9aa45e0be3f093f", + "sig": "", + "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740", + "frame": "01010000001ea361624ca16477686174816466756c6c617466726573796e636373696740" + }, + { + "name": "bye", + "signer": "box", + "t": "bye", + "body": "a6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a00011170", + "signedInputSha256": "0499c5251783c9826fdc39a6a09e8f0f35f20244dd100b07e9978dced9475389", + "sig": "19b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "frame": "01010000009ea36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04" + }, + { + "name": "purged", + "signer": "service", + "t": "purged", + "body": "a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444", + "signedInputSha256": "8789b7655a3f732582f55de4e6e1dbb9d9a60dadc501b535560566e8c879c9b6", + "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" + } + ] +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json new file mode 100644 index 00000000..218d502f --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/push-vectors.json @@ -0,0 +1,181 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} diff --git a/appleApp/FTWNotify-Info.plist b/appleApp/FTWNotify-Info.plist new file mode 100644 index 00000000..041c793d --- /dev/null +++ b/appleApp/FTWNotify-Info.plist @@ -0,0 +1,20 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + FTWAppGroup + $(FTW_APP_GROUP) + CFBundleLocalizations + + en + sv + + + diff --git a/appleApp/FTWNotify.entitlements b/appleApp/FTWNotify.entitlements new file mode 100644 index 00000000..04c6e8a3 --- /dev/null +++ b/appleApp/FTWNotify.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(FTW_APP_GROUP) + + + diff --git a/appleApp/FTWNotify/NotificationService.swift b/appleApp/FTWNotify/NotificationService.swift new file mode 100644 index 00000000..03c0366a --- /dev/null +++ b/appleApp/FTWNotify/NotificationService.swift @@ -0,0 +1,61 @@ +import Foundation +import FTWShared +import os +import UserNotifications + +/// Writes every FTW notification on the phone. +/// +/// FTW Cloud forwards the box's signed event and nothing of its own. This +/// checks the signature with the box key the app left in the App Group, +/// writes the words from the kind in the person's language, and picks the +/// buttons from a verified kind alone. Anything that does not check out +/// shows the generic line with no buttons. It links FTWShared only: no +/// session, no secret key, nothing on the main actor. +final class NotificationService: UNNotificationServiceExtension { + private let log = Logger(subsystem: "energy.ftw.app", category: "push") + private var deliver: ((UNNotificationContent) -> Void)? + private var userInfo: [AnyHashable: Any] = [:] + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + deliver = contentHandler + userInfo = request.content.userInfo + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let keys = CloudKeys.shared() + let alert = PushAlert.read(userInfo: userInfo, boxKey: { keys?.boxKey(home: $0) }, nowMs: Int64(nowMs)) + log.info("push: \(alert.verdict.rawValue, privacy: .public) \(alert.kind, privacy: .public)") + if let seen = PushSeen.shared(), !seen.accept(alert, nowMs: nowMs) { + log.info("push: seen before, seq \(alert.seq, privacy: .public)") + finish(content(.repeated(alert, language: .preferred()))) + return + } + finish(content(PushNotice(alert, home: keys?.read(home: alert.home), language: .preferred()))) + } + + /// Out of time: the generic line, which claims nothing. + override func serviceExtensionTimeWillExpire() { + finish(content(.generic(.preferred()))) + } + + private func finish(_ content: UNNotificationContent) { + guard let deliver else { return } + self.deliver = nil + deliver(content) + } + + /// New content, so nothing the service put in "aps" survives: not a + /// title, not a category, not a priority. The payload stays in + /// userInfo, because the app checks it again before a button acts. + private func content(_ notice: PushNotice) -> UNNotificationContent { + let c = UNMutableNotificationContent() + c.title = notice.title + c.body = notice.body + c.sound = notice.sound ? .default : nil + c.categoryIdentifier = notice.category + c.threadIdentifier = notice.thread + c.interruptionLevel = notice.level + var info = userInfo + info[PushNotice.userInfoKey] = notice.verified + c.userInfo = info + return c + } +} diff --git a/appleApp/FTWNotify/PrivacyInfo.xcprivacy b/appleApp/FTWNotify/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..64e785c8 --- /dev/null +++ b/appleApp/FTWNotify/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/protocol/push-catalogue.yaml b/protocol/push-catalogue.yaml new file mode 100644 index 00000000..8473df44 --- /dev/null +++ b/protocol/push-catalogue.yaml @@ -0,0 +1,62 @@ +# The push notification catalogue — every sentence a phone's lock screen +# may carry, and the one place they are written. +# +# The app owns all prose; the box sends codes. Push is the sole exception, +# stated in CLAUDE.md since the beginning: a push must render text when the +# app is not running, so the box renders it — FROM THIS FILE, never from +# words of its own. The box's copy is byte-identical and CI on both sides +# compares them, exactly as contract/registry.yaml is paired. +# +# `{arg}` placeholders are filled by the box from the event's own facts. +# Every event here must be sparse by design: a notification is an +# interruption, and a household that mutes the app because it nagged has +# lost every notification that mattered. +version: 1 +events: + - kind: charging.connected + title: Car plugged in + body: "Open to see charging status, check the battery level and set a ready time." + - kind: charging.session_complete + title: Car charged + body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." + - kind: charging.interrupted + # Only after the hysteresis window the box applies: a charge that had + # been running steadily and then stopped without finishing. A flapping + # cable must not become a night of lock-screen noise. + title: Charging stopped early + body: "The car stopped charging before it was done." + - kind: update.installed + title: Your box updated itself + body: "Now running {version}. Everything came back on its own." + - kind: driver.offline + # After the box's own threshold: ten minutes of silence from a driver + # that had been reporting. A blip must not become a lock-screen. + title: A device went quiet + body: "{name} stopped answering." + - kind: fuse.over_limit + # After the box's own threshold: thirty seconds over the rating. A + # kettle must not page anyone. + title: The house is drawing too much + body: "{phase} is over the fuse rating." + - kind: box.unreachable + # The one sentence the box cannot send about itself. The relay holds + # this pre-encrypted and posts it only when the box has missed its + # deadline — the dead man's switch, and the only stored row the relay + # has. The box encrypts it before it ever leaves home. + title: Your box is out of reach + body: "It stopped answering. Power cut, or the internet at home is down." + - kind: cloud.granted + # To every owner, when an owner turns FTW Cloud on. Sharing a readable + # copy of the house is a choice every owner should hear about. + title: FTW Cloud is on + body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box." + - kind: cloud.revoked + # To every owner, when FTW Cloud is turned off at either door. + title: FTW Cloud is off + body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."