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."