From d8b6ecc7343c35c18b485f67f4bc6bd934db121f Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:30:55 +0200 Subject: [PATCH 01/22] feat(ios): expose native host telemetry to products --- .../Products/SPA/Rust/SPARustRuntime.swift | 6 +- .../TrUAPI/RustRuntimeScriptsFactory.swift | 71 ++++++++++++++++++- .../TrUAPI/SPARustRuntimeTests.swift | 61 ++++++++++++---- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift index 722f205ad3..62995dcf0c 100644 --- a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift +++ b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift @@ -21,6 +21,7 @@ actor SPARustRuntime { private let productResolver: ProductResolving private let schemeHandlerProxy: SchemeHandlerProxy private let logger: LoggerProtocol + private let hostSessionId = UUID() private var engine: JSEngineProtocol? private var engineMonitor: JSEngineMonitor? @@ -74,7 +75,10 @@ extension SPARustRuntime: SPARuntimeProtocol { try checkNotDisposed() let bootstrapScript = try executionModel.startBridge() - let scriptsFactory = RustRuntimeScriptsFactory(bootstrapScript: bootstrapScript) + let scriptsFactory = RustRuntimeScriptsFactory( + bootstrapScript: bootstrapScript, + hostTelemetry: .current(hostSessionId: hostSessionId) + ) await engine.registerJSDeviceCapabilityHandler( executionModel.osPermissionAsker.makeDeviceCapabilityHandler( diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 8a873bef7e..12fe127018 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -1,22 +1,87 @@ +import Foundation import Products import TrUAPIHost +struct NativeHostTelemetry: Encodable { + let hostSessionId: String + let appVersion: String? + let appBuild: String? + let appVariant: String + let platform: String + let osVersion: String + let truapiHostVersion: String + let schemaVersion: String + + static func current(hostSessionId: UUID) -> NativeHostTelemetry { + let hostVersion = truapiHostVersion() + + return NativeHostTelemetry( + hostSessionId: hostSessionId.uuidString.lowercased(), + appVersion: Bundle.main.appVersion, + appBuild: Bundle.main.appBuild, + appVariant: currentAppVariant, + platform: "ios", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + truapiHostVersion: hostVersion, + schemaVersion: "1" + ) + } + + private static var currentAppVariant: String { + #if NIGHTLY + "nightly" + #elseif SAFETYNET + "safetynet" + #elseif UNSTABLE + "unstable" + #elseif DEBUG + "debug" + #else + "release" + #endif + } +} + /// Bootstrap must publish the bridge endpoint before the container runs. final class RustRuntimeScriptsFactory { private let bootstrapScript: String + private let hostTelemetry: NativeHostTelemetry? - init(bootstrapScript: String) { + init(bootstrapScript: String, hostTelemetry: NativeHostTelemetry? = nil) { self.bootstrapScript = bootstrapScript + self.hostTelemetry = hostTelemetry } func makeScripts() throws -> [JSEngineScript] { - try [ + var scripts: [JSEngineScript] = [] + + if let hostTelemetry { + scripts.append(try makeHostTelemetryScript(hostTelemetry)) + } + + scripts.append(contentsOf: try [ JSEngineScript(content: bootstrapScript, insertionPoint: .atDocStart), JSEngineScript( content: ContainerScriptBundle.load(), insertionPoint: .atDocStart, frameScope: .allFrames ) - ] + ]) + + return scripts + } + + private func makeHostTelemetryScript(_ telemetry: NativeHostTelemetry) throws -> JSEngineScript { + let data = try JSONEncoder().encode(telemetry) + let json = String(decoding: data, as: UTF8.self) + let script = """ + Object.defineProperty(globalThis, "__trinityHostTelemetry", { + value: Object.freeze(\(json)), + writable: false, + configurable: false + }); + """ + + return JSEngineScript(content: script, insertionPoint: .atDocStart) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index 9a4afc4ca9..ed9b06923c 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -73,17 +73,23 @@ struct SPARustRuntimeTests { #expect(execution.permissionRequests.isEmpty) #expect(engine.mediaHandlerWasInstalledAtInitialization) - // Bootstrap → container → zoom disable, doc-start ordering intact. - #expect(engine.initializedScripts.count == 3) - #expect(engine.initializedScripts[0] - .content == #"window.__truapi_localhost = { url: "ws://127.0.0.1:0/?t=test" };"#) + // Host telemetry → bootstrap → container → zoom disable, doc-start ordering intact. + #expect(engine.initializedScripts.count == 4) + #expect(engine.initializedScripts[0].content.contains("__trinityHostTelemetry")) + #expect(engine.initializedScripts[0].content.contains("Object.freeze")) + #expect(engine.initializedScripts[0].content.contains("writable: false")) + #expect(engine.initializedScripts[0].content.contains("configurable: false")) #expect(engine.initializedScripts[0].insertionPoint == .atDocStart) - #expect(!engine.initializedScripts[0].content.contains("__truapi_policy__")) - #expect(engine.initializedScripts[1].content.contains("freezeAndDelete")) + #expect(engine.initializedScripts[0].frameScope == .mainFrameOnly) + #expect(engine.initializedScripts[1] + .content == #"window.__truapi_localhost = { url: "ws://127.0.0.1:0/?t=test" };"#) #expect(engine.initializedScripts[1].insertionPoint == .atDocStart) - #expect(engine.initializedScripts[2].content.contains("viewport")) - #expect(engine.initializedScripts[2].insertionPoint == .atDocEnd) - #expect(engine.initializedScripts[2].frameScope == .mainFrameOnly) + #expect(!engine.initializedScripts[1].content.contains("__truapi_policy__")) + #expect(engine.initializedScripts[2].content.contains("freezeAndDelete")) + #expect(engine.initializedScripts[2].insertionPoint == .atDocStart) + #expect(engine.initializedScripts[3].content.contains("viewport")) + #expect(engine.initializedScripts[3].insertionPoint == .atDocEnd) + #expect(engine.initializedScripts[3].frameScope == .mainFrameOnly) await runtime.dispose() } @@ -130,7 +136,7 @@ struct SPARustRuntimeTests { #expect(url.scheme == ProductScriptSchemeHandler.scheme) #expect(url.host == "test.dot") #expect(url.path == "/") - #expect(engine.initializedScripts[0] + #expect(engine.initializedScripts[1] .content == #"window.__truapi_localhost = { url: "ws://127.0.0.1:0/?t=test" };"#) await runtime.dispose() @@ -192,17 +198,42 @@ struct SPARustRuntimeTests { } @Test func rustScriptsFactoryOrdersBootstrapBeforeContainer() throws { - let factory = RustRuntimeScriptsFactory(bootstrapScript: "/*bootstrap*/") + let telemetry = NativeHostTelemetry( + hostSessionId: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3", + appVersion: "1.2.3", + appBuild: "456", + appVariant: "nightly", + platform: "ios", + osVersion: "18.1", + truapiHostVersion: "0.9.0", + schemaVersion: "1" + ) + let factory = RustRuntimeScriptsFactory( + bootstrapScript: "/*bootstrap*/", + hostTelemetry: telemetry + ) let scripts = try factory.makeScripts() - #expect(scripts.count == 2) - #expect(scripts[0].content == "/*bootstrap*/") + #expect(scripts.count == 3) + #expect(scripts[0].content.contains("Object.defineProperty(globalThis, \"__trinityHostTelemetry\"")) + #expect(scripts[0].content.contains("\"hostSessionId\":\"d93fe846-8f3c-4cfa-a440-402b6f7b43d3\"")) + #expect(scripts[0].content.contains("\"appVersion\":\"1.2.3\"")) + #expect(scripts[0].content.contains("\"appBuild\":\"456\"")) + #expect(scripts[0].content.contains("\"appVariant\":\"nightly\"")) + #expect(scripts[0].content.contains("\"platform\":\"ios\"")) + #expect(scripts[0].content.contains("\"osVersion\":\"18.1\"")) + #expect(scripts[0].content.contains("\"truapiHostVersion\":\"0.9.0\"")) + #expect(scripts[0].content.contains("\"schemaVersion\":\"1\"")) #expect(scripts[0].insertionPoint == .atDocStart) #expect(scripts[0].frameScope == .mainFrameOnly) - #expect(scripts[1].content.contains("__truapi_localhost")) + #expect(try factory.makeScripts()[0].content == scripts[0].content) + #expect(scripts[1].content == "/*bootstrap*/") #expect(scripts[1].insertionPoint == .atDocStart) - #expect(scripts[1].frameScope == .allFrames) + #expect(scripts[1].frameScope == .mainFrameOnly) + #expect(scripts[2].content.contains("__truapi_localhost")) + #expect(scripts[2].insertionPoint == .atDocStart) + #expect(scripts[2].frameScope == .allFrames) } @Test func directURLHandlerAllowsSameHostInterceptsOthers() throws { From 411203d4fbc6d7a0b1c6ba877e875b24dd52af76 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:33:55 +0200 Subject: [PATCH 02/22] fix(ios): use the agreed debug telemetry variant --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 12fe127018..f8adb6e95b 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -33,7 +33,7 @@ struct NativeHostTelemetry: Encodable { #elseif SAFETYNET "safetynet" #elseif UNSTABLE - "unstable" + "debug" #elseif DEBUG "debug" #else From bad3dc4f8c2e19b5d313d6ed82e60f141a05131e Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:34:42 +0200 Subject: [PATCH 03/22] fix(ios): keep host telemetry best effort --- .../TrUAPI/RustRuntimeScriptsFactory.swift | 44 +++++++++++-------- .../TrUAPI/SPARustRuntimeTests.swift | 4 ++ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index f8adb6e95b..a98bce4f32 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -12,19 +12,23 @@ struct NativeHostTelemetry: Encodable { let truapiHostVersion: String let schemaVersion: String - static func current(hostSessionId: UUID) -> NativeHostTelemetry { - let hostVersion = truapiHostVersion() + static func current(hostSessionId: UUID) -> NativeHostTelemetry? { + #if NIGHTLY || SAFETYNET || UNSTABLE || DEBUG + let hostVersion = truapiHostVersion() - return NativeHostTelemetry( - hostSessionId: hostSessionId.uuidString.lowercased(), - appVersion: Bundle.main.appVersion, - appBuild: Bundle.main.appBuild, - appVariant: currentAppVariant, - platform: "ios", - osVersion: ProcessInfo.processInfo.operatingSystemVersionString, - truapiHostVersion: hostVersion, - schemaVersion: "1" - ) + return NativeHostTelemetry( + hostSessionId: hostSessionId.uuidString.lowercased(), + appVersion: Bundle.main.appVersion, + appBuild: Bundle.main.appBuild, + appVariant: currentAppVariant, + platform: "ios", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + truapiHostVersion: hostVersion, + schemaVersion: "1" + ) + #else + return nil + #endif } private static var currentAppVariant: String { @@ -55,8 +59,8 @@ final class RustRuntimeScriptsFactory { func makeScripts() throws -> [JSEngineScript] { var scripts: [JSEngineScript] = [] - if let hostTelemetry { - scripts.append(try makeHostTelemetryScript(hostTelemetry)) + if let hostTelemetry, let telemetryScript = try? makeHostTelemetryScript(hostTelemetry) { + scripts.append(telemetryScript) } scripts.append(contentsOf: try [ @@ -75,11 +79,13 @@ final class RustRuntimeScriptsFactory { let data = try JSONEncoder().encode(telemetry) let json = String(decoding: data, as: UTF8.self) let script = """ - Object.defineProperty(globalThis, "__trinityHostTelemetry", { - value: Object.freeze(\(json)), - writable: false, - configurable: false - }); + try { + Object.defineProperty(globalThis, "__trinityHostTelemetry", { + value: Object.freeze(\(json)), + writable: false, + configurable: false + }); + } catch {} """ return JSEngineScript(content: script, insertionPoint: .atDocStart) diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index ed9b06923c..e26318d6ac 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -79,6 +79,8 @@ struct SPARustRuntimeTests { #expect(engine.initializedScripts[0].content.contains("Object.freeze")) #expect(engine.initializedScripts[0].content.contains("writable: false")) #expect(engine.initializedScripts[0].content.contains("configurable: false")) + #expect(engine.initializedScripts[0].content.contains("try {")) + #expect(engine.initializedScripts[0].content.contains("} catch {}")) #expect(engine.initializedScripts[0].insertionPoint == .atDocStart) #expect(engine.initializedScripts[0].frameScope == .mainFrameOnly) #expect(engine.initializedScripts[1] @@ -217,6 +219,8 @@ struct SPARustRuntimeTests { #expect(scripts.count == 3) #expect(scripts[0].content.contains("Object.defineProperty(globalThis, \"__trinityHostTelemetry\"")) + #expect(scripts[0].content.contains("try {")) + #expect(scripts[0].content.contains("} catch {}")) #expect(scripts[0].content.contains("\"hostSessionId\":\"d93fe846-8f3c-4cfa-a440-402b6f7b43d3\"")) #expect(scripts[0].content.contains("\"appVersion\":\"1.2.3\"")) #expect(scripts[0].content.contains("\"appBuild\":\"456\"")) From 0f12e1e324a7918d97c2b18b0aae345f4b2422ca Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:08:43 +0200 Subject: [PATCH 04/22] fix(ios): qualify TrUAPI host version lookup --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index a98bce4f32..4458f94455 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -14,7 +14,7 @@ struct NativeHostTelemetry: Encodable { static func current(hostSessionId: UUID) -> NativeHostTelemetry? { #if NIGHTLY || SAFETYNET || UNSTABLE || DEBUG - let hostVersion = truapiHostVersion() + let hostVersion = TrUAPIHost.truapiHostVersion() return NativeHostTelemetry( hostSessionId: hostSessionId.uuidString.lowercased(), From f175b27be50988967c6c3deb56f0bbbfa5085e74 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:07:00 +0200 Subject: [PATCH 05/22] fix(ios): call generated host version function --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 4458f94455..59621647d1 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -2,6 +2,10 @@ import Foundation import Products import TrUAPIHost +private func currentTrUAPIHostVersion() -> String { + truapiHostVersion() +} + struct NativeHostTelemetry: Encodable { let hostSessionId: String let appVersion: String? @@ -14,7 +18,7 @@ struct NativeHostTelemetry: Encodable { static func current(hostSessionId: UUID) -> NativeHostTelemetry? { #if NIGHTLY || SAFETYNET || UNSTABLE || DEBUG - let hostVersion = TrUAPIHost.truapiHostVersion() + let hostVersion = currentTrUAPIHostVersion() return NativeHostTelemetry( hostSessionId: hostSessionId.uuidString.lowercased(), From ac385d0948ac278e2309ab87343b58a3c1cb2bbb Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:44:36 +0200 Subject: [PATCH 06/22] fix(ios): use failable telemetry JSON conversion --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 59621647d1..87e77576fc 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -81,7 +81,9 @@ final class RustRuntimeScriptsFactory { private func makeHostTelemetryScript(_ telemetry: NativeHostTelemetry) throws -> JSEngineScript { let data = try JSONEncoder().encode(telemetry) - let json = String(decoding: data, as: UTF8.self) + guard let json = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileReadInapplicableStringEncoding) + } let script = """ try { Object.defineProperty(globalThis, "__trinityHostTelemetry", { From 2c11c4a94920a34df418a75bc640a7fdd78edf40 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:46:31 +0200 Subject: [PATCH 07/22] fix(ios): stabilize telemetry bootstrap JSON --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 87e77576fc..585a1e2bf7 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -80,7 +80,9 @@ final class RustRuntimeScriptsFactory { } private func makeHostTelemetryScript(_ telemetry: NativeHostTelemetry) throws -> JSEngineScript { - let data = try JSONEncoder().encode(telemetry) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(telemetry) guard let json = String(data: data, encoding: .utf8) else { throw CocoaError(.fileReadInapplicableStringEncoding) } From 9a027f3301cd0e4564957d61518249e3f96113b2 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:17:06 +0200 Subject: [PATCH 08/22] fix(ios): log host telemetry bootstrap failures --- hosts/ios/README.md | 3 ++ .../Products/SPA/Rust/SPARustRuntime.swift | 3 +- .../TrUAPI/RustRuntimeScriptsFactory.swift | 50 ++++++++++++------- .../TrUAPI/Worker/TrUAPIWorkerRuntime.swift | 5 +- .../TrUAPI/SPARustRuntimeTests.swift | 30 +++++++---- 5 files changed, 61 insertions(+), 30 deletions(-) diff --git a/hosts/ios/README.md b/hosts/ios/README.md index d7ddc32800..bb0d75e5e0 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -115,6 +115,9 @@ Polkadot iOS is a self-custodial superapp: your keys are created on your phone, Built with **UIKit** and programmatic layout (no Storyboards), using **VIPER** for every feature module: code is split between the main app target and 28 local Swift packages under [`Packages/`](./Packages) with `AppDependencies` as the root package, chain access goes through [substrate-sdk-ios](https://github.com/novasamatech/substrate-sdk-ios) (JSON-RPC, storage subscriptions, extrinsics), and local data lives in CoreData. +Non-release TrUAPI SPA runtimes publish immutable native host metadata before the product container starts, +so product diagnostics can correlate one WebView activation with its host version and build. + This repository ships a **GitHub Actions + Fastlane CI/CD setup** — PR build and tests, plus maintainer-gated TestFlight and Firebase App Distribution. Build-time configuration, signing, the required secrets, and the pipeline itself are diff --git a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift index 62995dcf0c..4b89c62ece 100644 --- a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift +++ b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift @@ -77,7 +77,8 @@ extension SPARustRuntime: SPARuntimeProtocol { let bootstrapScript = try executionModel.startBridge() let scriptsFactory = RustRuntimeScriptsFactory( bootstrapScript: bootstrapScript, - hostTelemetry: .current(hostSessionId: hostSessionId) + hostTelemetry: .current(hostSessionId: hostSessionId), + logger: logger ) await engine.registerJSDeviceCapabilityHandler( diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 585a1e2bf7..9f5727b93a 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -18,23 +18,27 @@ struct NativeHostTelemetry: Encodable { static func current(hostSessionId: UUID) -> NativeHostTelemetry? { #if NIGHTLY || SAFETYNET || UNSTABLE || DEBUG - let hostVersion = currentTrUAPIHostVersion() - - return NativeHostTelemetry( - hostSessionId: hostSessionId.uuidString.lowercased(), - appVersion: Bundle.main.appVersion, - appBuild: Bundle.main.appBuild, - appVariant: currentAppVariant, - platform: "ios", - osVersion: ProcessInfo.processInfo.operatingSystemVersionString, - truapiHostVersion: hostVersion, - schemaVersion: "1" - ) + current(hostSessionId: hostSessionId, enabled: true) #else - return nil + current(hostSessionId: hostSessionId, enabled: false) #endif } + static func current(hostSessionId: UUID, enabled: Bool) -> NativeHostTelemetry? { + guard enabled else { return nil } + + return NativeHostTelemetry( + hostSessionId: hostSessionId.uuidString.lowercased(), + appVersion: Bundle.main.appVersion, + appBuild: Bundle.main.appBuild, + appVariant: currentAppVariant, + platform: "ios", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + truapiHostVersion: currentTrUAPIHostVersion(), + schemaVersion: "1" + ) + } + private static var currentAppVariant: String { #if NIGHTLY "nightly" @@ -54,17 +58,27 @@ struct NativeHostTelemetry: Encodable { final class RustRuntimeScriptsFactory { private let bootstrapScript: String private let hostTelemetry: NativeHostTelemetry? + private let logger: LoggerProtocol - init(bootstrapScript: String, hostTelemetry: NativeHostTelemetry? = nil) { + init( + bootstrapScript: String, + hostTelemetry: NativeHostTelemetry? = nil, + logger: LoggerProtocol + ) { self.bootstrapScript = bootstrapScript self.hostTelemetry = hostTelemetry + self.logger = logger } func makeScripts() throws -> [JSEngineScript] { var scripts: [JSEngineScript] = [] - if let hostTelemetry, let telemetryScript = try? makeHostTelemetryScript(hostTelemetry) { - scripts.append(telemetryScript) + if let hostTelemetry { + do { + scripts.append(try makeHostTelemetryScript(hostTelemetry)) + } catch { + logger.warning("[truapi] host telemetry bootstrap could not be encoded: \(error)") + } } scripts.append(contentsOf: try [ @@ -93,7 +107,9 @@ final class RustRuntimeScriptsFactory { writable: false, configurable: false }); - } catch {} + } catch (error) { + console.warn("Native host telemetry bootstrap was not installed", error); + } """ return JSEngineScript(content: script, insertionPoint: .atDocStart) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift index 230b23cc90..41a2e97ee7 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift @@ -44,7 +44,10 @@ actor TrUAPIWorkerRuntime { try checkNotDisposed() let bootstrap = try executionModel.startBridge() - let scripts = try RustRuntimeScriptsFactory(bootstrapScript: bootstrap).makeScripts() + let scripts = try RustRuntimeScriptsFactory( + bootstrapScript: bootstrap, + logger: logger + ).makeScripts() let jsEngine = try await bootEngine(scripts: scripts) try checkNotDisposed() diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index e26318d6ac..8cca584c35 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -80,7 +80,7 @@ struct SPARustRuntimeTests { #expect(engine.initializedScripts[0].content.contains("writable: false")) #expect(engine.initializedScripts[0].content.contains("configurable: false")) #expect(engine.initializedScripts[0].content.contains("try {")) - #expect(engine.initializedScripts[0].content.contains("} catch {}")) + #expect(engine.initializedScripts[0].content.contains("console.warn")) #expect(engine.initializedScripts[0].insertionPoint == .atDocStart) #expect(engine.initializedScripts[0].frameScope == .mainFrameOnly) #expect(engine.initializedScripts[1] @@ -212,23 +212,22 @@ struct SPARustRuntimeTests { ) let factory = RustRuntimeScriptsFactory( bootstrapScript: "/*bootstrap*/", - hostTelemetry: telemetry + hostTelemetry: telemetry, + logger: StubLogger() ) let scripts = try factory.makeScripts() + let expectedJSON = [ + #"{"appBuild":"456","appVariant":"nightly","appVersion":"1.2.3","#, + #""hostSessionId":"d93fe846-8f3c-4cfa-a440-402b6f7b43d3","osVersion":"18.1","#, + #""platform":"ios","schemaVersion":"1","truapiHostVersion":"0.9.0"}"# + ].joined() #expect(scripts.count == 3) #expect(scripts[0].content.contains("Object.defineProperty(globalThis, \"__trinityHostTelemetry\"")) #expect(scripts[0].content.contains("try {")) - #expect(scripts[0].content.contains("} catch {}")) - #expect(scripts[0].content.contains("\"hostSessionId\":\"d93fe846-8f3c-4cfa-a440-402b6f7b43d3\"")) - #expect(scripts[0].content.contains("\"appVersion\":\"1.2.3\"")) - #expect(scripts[0].content.contains("\"appBuild\":\"456\"")) - #expect(scripts[0].content.contains("\"appVariant\":\"nightly\"")) - #expect(scripts[0].content.contains("\"platform\":\"ios\"")) - #expect(scripts[0].content.contains("\"osVersion\":\"18.1\"")) - #expect(scripts[0].content.contains("\"truapiHostVersion\":\"0.9.0\"")) - #expect(scripts[0].content.contains("\"schemaVersion\":\"1\"")) + #expect(scripts[0].content.contains("console.warn")) + #expect(scripts[0].content.contains(expectedJSON)) #expect(scripts[0].insertionPoint == .atDocStart) #expect(scripts[0].frameScope == .mainFrameOnly) #expect(try factory.makeScripts()[0].content == scripts[0].content) @@ -240,6 +239,15 @@ struct SPARustRuntimeTests { #expect(scripts[2].frameScope == .allFrames) } + @Test func nativeHostTelemetryIsAbsentWhenDisabled() throws { + let telemetry = NativeHostTelemetry.current( + hostSessionId: try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")), + enabled: false + ) + + #expect(telemetry == nil) + } + @Test func directURLHandlerAllowsSameHostInterceptsOthers() throws { let handler = try DirectURLNavigationDecisionHandler( baseURL: #require(URL(string: "http://localhost:3000")) From 2da35d1db04a162c385cfdd1d50258a67fab03b6 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:57:58 +0200 Subject: [PATCH 09/22] fix(ios): normalize host telemetry OS version --- .../Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 9f5727b93a..a4dd1e42b5 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -1,6 +1,7 @@ import Foundation import Products import TrUAPIHost +import UIKit private func currentTrUAPIHostVersion() -> String { truapiHostVersion() @@ -33,7 +34,7 @@ struct NativeHostTelemetry: Encodable { appBuild: Bundle.main.appBuild, appVariant: currentAppVariant, platform: "ios", - osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + osVersion: UIDevice.current.systemVersion, truapiHostVersion: currentTrUAPIHostVersion(), schemaVersion: "1" ) From 2d7f127139901537f8e5ebff6e1cf5034e06e031 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:27:26 +0200 Subject: [PATCH 10/22] fix(ios): avoid actor-isolated OS metadata --- .../TrUAPI/RustRuntimeScriptsFactory.swift | 8 ++++-- .../TrUAPI/SPARustRuntimeTests.swift | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index a4dd1e42b5..498787369f 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -1,7 +1,6 @@ import Foundation import Products import TrUAPIHost -import UIKit private func currentTrUAPIHostVersion() -> String { truapiHostVersion() @@ -34,12 +33,17 @@ struct NativeHostTelemetry: Encodable { appBuild: Bundle.main.appBuild, appVariant: currentAppVariant, platform: "ios", - osVersion: UIDevice.current.systemVersion, + osVersion: plainOSVersion(ProcessInfo.processInfo.operatingSystemVersion), truapiHostVersion: currentTrUAPIHostVersion(), schemaVersion: "1" ) } + static func plainOSVersion(_ version: OperatingSystemVersion) -> String { + let baseVersion = "\(version.majorVersion).\(version.minorVersion)" + return version.patchVersion == 0 ? baseVersion : "\(baseVersion).\(version.patchVersion)" + } + private static var currentAppVariant: String { #if NIGHTLY "nightly" diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index 8cca584c35..a679d2ba5a 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -248,6 +248,34 @@ struct SPARustRuntimeTests { #expect(telemetry == nil) } + @Test func nativeHostTelemetryUsesCanonicalRuntimeMetadata() throws { + let telemetry = try #require( + NativeHostTelemetry.current( + hostSessionId: #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")), + enabled: true + ) + ) + let operatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + + #expect(telemetry.osVersion == NativeHostTelemetry.plainOSVersion(operatingSystemVersion)) + #expect(!telemetry.osVersion.hasPrefix("Version ")) + #expect(!telemetry.osVersion.contains("Build")) + #expect(telemetry.truapiHostVersion == truapiHostVersion()) + } + + @Test func plainOSVersionOmitsOnlyAnEmptyPatchComponent() { + #expect( + NativeHostTelemetry.plainOSVersion( + OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0) + ) == "26.5" + ) + #expect( + NativeHostTelemetry.plainOSVersion( + OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 1) + ) == "26.5.1" + ) + } + @Test func directURLHandlerAllowsSameHostInterceptsOthers() throws { let handler = try DirectURLNavigationDecisionHandler( baseURL: #require(URL(string: "http://localhost:3000")) From 4f28bc3f7c46776b49035c8e4b74db96f6a0cd3e Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:29:02 +0200 Subject: [PATCH 11/22] test(ios): avoid nested requirement macros --- hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index a679d2ba5a..4d17c6f864 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -249,9 +249,10 @@ struct SPARustRuntimeTests { } @Test func nativeHostTelemetryUsesCanonicalRuntimeMetadata() throws { + let hostSessionId = try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) let telemetry = try #require( NativeHostTelemetry.current( - hostSessionId: #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")), + hostSessionId: hostSessionId, enabled: true ) ) From dabb79ec08eb5ffe8cf2d7eef962ec842b335f2b Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:17:48 +0200 Subject: [PATCH 12/22] feat(ios): report game reminder lifecycle to Sentry Adds host.reminder.* events through the IssueMonitoring seam (no-op in Release): scheduled / not_scheduled (alarm or notification, with the skip reason), fired (AlarmKit alerting seen while the app runs), tapped and product_opened from the alarm's Play intent, product_opened on the auto-open at the start, missed when the start is reached past the open grace, and cancelled when the product cancels. Each carries the product, the game start and the delay after the start in ms as numbers, plus the host platform, OS version, app version/build and launch id. --- .../Sources/GameReminderEvent.swift | 85 +++++++++++++++ .../Sources/GameReminderTelemetry.swift | 12 +++ .../Sources/HostEventOrigin.swift | 43 ++++++++ .../Sources/IssueMonitoringFactory.swift | 10 ++ .../Sources/SentryGameReminderTelemetry.swift | 15 +++ .../GameReminder/AlarmKitGameReminder.swift | 20 +++- .../GameReminder/GameAlarmStopIntent.swift | 19 +++- .../GameReminderEvent+AppState.swift | 17 +++ .../LocalNotificationGameReminder.swift | 26 ++++- .../Products/ProductGameReminderCenter.swift | 73 +++++++++++-- .../ProductGameReminderServicesFactory.swift | 28 ++++- .../Modules/Products/ProductOpener.swift | 19 ++-- .../RecordingGameReminderTelemetry.swift | 13 +++ .../Products/GameReminderEventTests.swift | 71 +++++++++++++ .../ProductGameReminderCenterTests.swift | 100 +++++++++++++++++- 15 files changed, 532 insertions(+), 19 deletions(-) create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift create mode 100644 hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift create mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift create mode 100644 hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift new file mode 100644 index 0000000000..3d384df527 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift @@ -0,0 +1,85 @@ +import Foundation + +/// One step of a product's game reminder, from booking the alarm to opening the product. Each step names +/// the product and the game start, so a missed game can be followed through the host. +public struct GameReminderEvent: Sendable, Equatable { + public enum Step: String, Sendable { + case scheduled + case notScheduled = "not_scheduled" + case fired + case tapped + case productOpened = "product_opened" + case missed + case cancelled + } + + public enum Delivery: String, Sendable { + case alarm + case notification + } + + public enum SkipReason: String, Sendable { + case notAuthorized = "not_authorized" + case startInPast = "start_in_past" + case failed + } + + public enum OpenSource: String, Sendable { + case autoOpen = "auto_open" + case alarm + } + + public enum AppState: String, Sendable { + case active + case inactive + case background + } + + public let step: Step + public let productId: String + /// Nil only for an alarm booked before the start was attached to it. + public let startsAt: Date? + public let occurredAt: Date + public var delivery: Delivery? + public var skipReason: SkipReason? + public var openSource: OpenSource? + public var appState: AppState? + /// False when opening found the product already on screen. + public var navigated: Bool? + + public init(step: Step, productId: String, startsAt: Date?, occurredAt: Date = Date()) { + self.step = step + self.productId = productId + self.startsAt = startsAt + self.occurredAt = occurredAt + } + + public var name: String { + "host.reminder.\(step.rawValue)" + } + + /// Times are device wall clock. The alarm rings at the start, so `delay_after_start_ms` is also how + /// late a step came against the alarm's fire time. + public func sentryFields(origin: HostEventOrigin) -> HostEventFields { + var tags = origin.tags + tags["reminder.product_id"] = productId + tags["reminder.delivery"] = delivery?.rawValue + tags["reminder.skip_reason"] = skipReason?.rawValue + tags["reminder.open_source"] = openSource?.rawValue + tags["host.app_state"] = appState?.rawValue + + var extras = origin.extras + extras["reminder.navigated"] = navigated + if let startsAt { + extras["reminder.starts_at_unix_ms"] = Int64(startsAt.timeIntervalSince1970 * 1_000) + extras["reminder.delay_after_start_ms"] = Int64(occurredAt.timeIntervalSince(startsAt) * 1_000) + } + + return HostEventFields(tags: tags, extras: extras) + } +} + +public struct HostEventFields { + public let tags: [String: String] + public let extras: [String: Any] +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift new file mode 100644 index 0000000000..f9d6f6b0ec --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Records the steps of a product's game reminder. A no-op in Release, where no reporting SDK is linked. +public protocol GameReminderTelemetry: Sendable { + func record(_ event: GameReminderEvent) +} + +public struct NoopGameReminderTelemetry: GameReminderTelemetry { + public init() {} + + public func record(_: GameReminderEvent) {} +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift new file mode 100644 index 0000000000..94dda9734a --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift @@ -0,0 +1,43 @@ +import Foundation + +/// The fields every host event carries to say which install and which launch sent it. +public struct HostEventOrigin: Sendable { + public let launchID: UUID + public let osVersion: String + public let appVersion: String? + public let appBuild: Int? + + public init(launchID: UUID, osVersion: String, appVersion: String?, appBuild: Int?) { + self.launchID = launchID + self.osVersion = osVersion + self.appVersion = appVersion + self.appBuild = appBuild + } + + public static let current = HostEventOrigin( + launchID: UUID(), + osVersion: plainOSVersion(ProcessInfo.processInfo.operatingSystemVersion), + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + appBuild: (Bundle.main.infoDictionary?["CFBundleVersion"] as? String).flatMap { Int($0) } + ) + + var tags: [String: String] { + var tags = [ + "host.platform": "ios", + "host.os_version": osVersion + ] + tags["host.native_app_version"] = appVersion + return tags + } + + var extras: [String: Any] { + var extras: [String: Any] = ["app.launch_id": launchID.uuidString.lowercased()] + extras["host.app_build"] = appBuild + return extras + } + + static func plainOSVersion(_ version: OperatingSystemVersion) -> String { + let baseVersion = "\(version.majorVersion).\(version.minorVersion)" + return version.patchVersion == 0 ? baseVersion : "\(baseVersion).\(version.patchVersion)" + } +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift index a71f7a0b3e..a9372905d3 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift @@ -32,4 +32,14 @@ public enum IssueMonitoringFactory { NoopIssueReporter() #endif } + + /// Records game reminder steps through the SDK `IssueMonitoringServiceProtocol.setup()` started; a no-op until + /// then and in Release. + public static func createGameReminderTelemetry() -> GameReminderTelemetry { + #if SENTRY_ENABLED + SentryGameReminderTelemetry() + #else + NoopGameReminderTelemetry() + #endif + } } diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift new file mode 100644 index 0000000000..fbf700a999 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift @@ -0,0 +1,15 @@ +#if SENTRY_ENABLED + import Foundation + import Sentry + + struct SentryGameReminderTelemetry: GameReminderTelemetry { + func record(_ event: GameReminderEvent) { + let fields = event.sentryFields(origin: .current) + SentrySDK.capture(message: event.name) { scope in + scope.setFingerprint([event.name]) + fields.tags.forEach { scope.setTag(value: $0.value, key: $0.key) } + fields.extras.forEach { scope.setExtra(value: $0.value, key: $0.key) } + } + } + } +#endif diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift index a698df7bfe..a69ae99658 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift @@ -5,6 +5,7 @@ import Keystore_iOS import Products import SwiftUI import Individuality +import IssueMonitoring @available(iOS 26.1, *) final class AlarmKitGameReminder: GameStartReminderServicing { @@ -12,6 +13,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private let settingsManager: SettingsManagerProtocol private let keys: GameReminderStorageKeys private let logger: LoggerProtocol + private let telemetry: GameReminderTelemetry private let taskLock = NSLock() private var lastTask: Task? @@ -19,12 +21,14 @@ final class AlarmKitGameReminder: GameStartReminderServicing { alarmManger: AlarmManager, settingsManager: SettingsManagerProtocol, keys: GameReminderStorageKeys, - logger: LoggerProtocol = Logger.shared + logger: LoggerProtocol = Logger.shared, + telemetry: GameReminderTelemetry = NoopGameReminderTelemetry() ) { self.alarmManger = alarmManger self.settingsManager = settingsManager self.keys = keys self.logger = logger + self.telemetry = telemetry } func scheduleReminder(gameDate: Date, productId: ProductId) { @@ -58,11 +62,13 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private func performScheduleReminder(gameDate fireDate: Date, productId: ProductId) async { guard alarmManger.authorizationState == .authorized else { logger.debug("Not authorized, skipping schedule") + recordNotScheduled(.notAuthorized, gameDate: fireDate, productId: productId) return } guard fireDate > Date() else { logger.debug("Fire date is in the past, skipping") + recordNotScheduled(.startInPast, gameDate: fireDate, productId: productId) performCancelReminder(alarmId: nil) return } @@ -88,11 +94,22 @@ final class AlarmKitGameReminder: GameStartReminderServicing { settingsManager.set(string: newAlarmId.uuidString, for: keys.alarmId) settingsManager.set(value: Int(fireDate.timeIntervalSinceReferenceDate), for: keys.alarmFireDate) logger.debug("Alarm scheduled \(newAlarmId) for \(fireDate)") + var event = GameReminderEvent(step: .scheduled, productId: productId, startsAt: fireDate) + event.delivery = .alarm + telemetry.record(event) } catch { logger.error("Failed to schedule AlarmKit alarm: \(error)") + recordNotScheduled(.failed, gameDate: fireDate, productId: productId) } } + private func recordNotScheduled(_ reason: GameReminderEvent.SkipReason, gameDate: Date, productId: ProductId) { + var event = GameReminderEvent(step: .notScheduled, productId: productId, startsAt: gameDate) + event.delivery = .alarm + event.skipReason = reason + telemetry.record(event) + } + private func performCancelReminder(alarmId: UUID?) { guard let alarmId = alarmId ?? settingsManager .string(for: keys.alarmId) @@ -146,6 +163,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { let playIntent = GameAlarmPlayIntent() playIntent.alarmID = newAlarmId.uuidString playIntent.productId = productId + playIntent.startsAt = date _ = try await alarmManger.schedule( id: newAlarmId, diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift index b61a131589..dff29f61dd 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift @@ -1,6 +1,8 @@ import Foundation import AlarmKit import AppIntents +import IssueMonitoring +import UIKit @available(iOS 26.1, *) struct GameAlarmPlayIntent: LiveActivityIntent { @@ -13,10 +15,25 @@ struct GameAlarmPlayIntent: LiveActivityIntent { @Parameter(title: "Product ID") var productId: String? + @Parameter(title: "Starts At") + var startsAt: Date? + @MainActor func perform() async throws -> some IntentResult { if let productId { - ProductOpener().open(productId: productId) + let telemetry = IssueMonitoringFactory.createGameReminderTelemetry() + let appState = GameReminderEvent.AppState(UIApplication.shared.applicationState) + + var tapped = GameReminderEvent(step: .tapped, productId: productId, startsAt: startsAt) + tapped.delivery = .alarm + tapped.appState = appState + telemetry.record(tapped) + + var opened = GameReminderEvent(step: .productOpened, productId: productId, startsAt: startsAt) + opened.openSource = .alarm + opened.appState = appState + opened.navigated = ProductOpener().open(productId: productId) + telemetry.record(opened) } if let alarmIDString = alarmID, diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift new file mode 100644 index 0000000000..6b6ee79431 --- /dev/null +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift @@ -0,0 +1,17 @@ +import IssueMonitoring +import UIKit + +extension GameReminderEvent.AppState { + init?(_ state: UIApplication.State) { + switch state { + case .active: + self = .active + case .inactive: + self = .inactive + case .background: + self = .background + @unknown default: + return nil + } + } +} diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift index 215448c999..81d4524c9c 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift @@ -3,23 +3,27 @@ import UserNotifications import Keystore_iOS import Products import Individuality +import IssueMonitoring final class LocalNotificationGameReminder { private let localNotificationService: UserNotificationServicing private let settingsManager: SettingsManagerProtocol private let keys: GameReminderStorageKeys private let logger: LoggerProtocol + private let telemetry: GameReminderTelemetry init( localNotificationService: UserNotificationServicing, settingsManager: SettingsManagerProtocol, keys: GameReminderStorageKeys, - logger: LoggerProtocol = Logger.shared + logger: LoggerProtocol = Logger.shared, + telemetry: GameReminderTelemetry = NoopGameReminderTelemetry() ) { self.localNotificationService = localNotificationService self.settingsManager = settingsManager self.keys = keys self.logger = logger + self.telemetry = telemetry } } @@ -27,6 +31,7 @@ extension LocalNotificationGameReminder: GameStartReminderServicing { func scheduleReminder(gameDate: Date, productId: ProductId) { localNotificationService.notificationAccessStatus { status in guard status == .allowed else { + self.record(.notScheduled, gameDate: gameDate, productId: productId, skipReason: .notAuthorized) return } self.performScheduleReminder(gameDate: gameDate, productId: productId) @@ -56,6 +61,7 @@ private extension LocalNotificationGameReminder { guard fireDate > Date() else { logger.debug("fire date is in the past, skipping") + record(.notScheduled, gameDate: fireDate, productId: productId, skipReason: .startInPast) return } @@ -67,9 +73,27 @@ private extension LocalNotificationGameReminder { after: fireDate.timeIntervalSince(Date()) ) { [logger] error in logger.debug("gameStart scheduled with error: \(String(describing: error))") + self.record( + error == nil ? .scheduled : .notScheduled, + gameDate: fireDate, + productId: productId, + skipReason: error == nil ? nil : .failed + ) } } + func record( + _ step: GameReminderEvent.Step, + gameDate: Date, + productId: ProductId, + skipReason: GameReminderEvent.SkipReason? = nil + ) { + var event = GameReminderEvent(step: step, productId: productId, startsAt: gameDate) + event.delivery = .notification + event.skipReason = skipReason + telemetry.record(event) + } + func cancel() { localNotificationService.cancelScheduledNotifications(withIdentifiers: [keys.notificationIdentifier]) } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index edaf9ce550..c1c1a38745 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -1,6 +1,7 @@ import AsyncExtensions import Foundation import FoundationExt +import IssueMonitoring import Keystore_iOS import Products import UIKit @@ -29,20 +30,24 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam let applicationStateStreams: ApplicationStateStreamFactory let productOpener: ProductOpening let dateProvider: CurrentDateProviding + let telemetry: GameReminderTelemetry } static func makeDefault() -> ProductGameReminderCenter { - ProductGameReminderCenter( + let telemetry = IssueMonitoringFactory.createGameReminderTelemetry() + return ProductGameReminderCenter( dependencies: Dependencies( services: ProductGameReminderServicesFactory( settingsManager: SettingsManager.shared, - localNotificationService: UserNotificationService.shared + localNotificationService: UserNotificationService.shared, + telemetry: telemetry ), settingsManager: SettingsManager.shared, applicationState: UIApplication.shared, applicationStateStreams: ApplicationStateStreamFactory(), productOpener: ProductOpener(), - dateProvider: NowDateProvider() + dateProvider: NowDateProvider(), + telemetry: telemetry ) ) } @@ -66,12 +71,15 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam private let applicationStateStreams: ApplicationStateStreamFactory private let productOpener: ProductOpening private let dateProvider: CurrentDateProviding + private let telemetry: GameReminderTelemetry /// One pair per product: AlarmKit serialises its calls per instance. private var reminders: [ProductId: Reminders] = [:] private let pillSubject = AsyncCurrentValueSubject(nil) private var wakeUp: Task? private var becameActive: Task? + private var alarmAlerts: Task? + private var firedAlarmIds: Set = [] init(dependencies: Dependencies) { services = dependencies.services @@ -80,6 +88,7 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam applicationStateStreams = dependencies.applicationStateStreams productOpener = dependencies.productOpener dateProvider = dependencies.dateProvider + telemetry = dependencies.telemetry } /// Held reminders, soonest first. @@ -101,6 +110,7 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam func start() { observeBecomingActive() + observeAlarmAlerts() refresh() } @@ -129,10 +139,11 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam } func cancel(productId: ProductId) { - guard slot(for: productId) != nil else { + guard let slot = slot(for: productId) else { return } drop(productId) + record(.cancelled, for: slot) refresh() } @@ -154,9 +165,11 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam continue } drop(slot.productId) - if !isLate, !opened { + if isLate { + record(.missed, for: slot) + } else if !opened { opened = true - productOpener.open(productId: slot.productId) + recordAutoOpen(slot, navigated: productOpener.open(productId: slot.productId)) } continue } @@ -284,6 +297,54 @@ private extension ProductGameReminderCenter { } } + /// AlarmKit rings outside the app, so a ring is seen only while the app runs, and only once per alarm. + func observeAlarmAlerts() { + guard alarmAlerts == nil else { + return + } + let alerts = services.alertingAlarms() + alarmAlerts = Task { [weak self] in + for await alarmIds in alerts { + self?.recordFired(alarmIds) + } + } + } + + func recordFired(_ alarmIds: Set) { + for slot in slots { + guard let alarmId = settingsManager.string(for: GameReminderStorageKeys.product(slot.productId).alarmId) + .flatMap(UUID.init(uuidString:)), + alarmIds.contains(alarmId), + firedAlarmIds.insert(alarmId).inserted else { + continue + } + record(.fired, for: slot) { $0.delivery = .alarm } + } + } + + func recordAutoOpen(_ slot: Slot, navigated: Bool) { + record(.productOpened, for: slot) { event in + event.openSource = .autoOpen + event.navigated = navigated + } + } + + func record( + _ step: GameReminderEvent.Step, + for slot: Slot, + configure: (inout GameReminderEvent) -> Void = { _ in } + ) { + var event = GameReminderEvent( + step: step, + productId: slot.productId, + startsAt: slot.startsAt, + occurredAt: dateProvider.now + ) + event.appState = GameReminderEvent.AppState(applicationState.applicationState) + configure(&event) + telemetry.record(event) + } + func wake(at date: Date, from now: Date) { let delay = date.timeIntervalSince(now) wakeUp = Task { [weak self] in diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift index 72ef682321..ecac571985 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift @@ -1,5 +1,7 @@ +import AlarmKit import EventKit import Foundation +import IssueMonitoring import Keystore_iOS import Products @@ -10,18 +12,23 @@ protocol ProductGameReminderServicesMaking { func makeNotification(for productId: ProductId) -> any GameStartReminderServicing /// Built at add time, so its store sees the current calendar grant. func makeCalendar() -> any GameCalendarServicing + /// The ids of the alarms ringing now, each time AlarmKit reports a change while the app runs. Never yields + /// without AlarmKit. + func alertingAlarms() -> AsyncStream> } struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { let settingsManager: SettingsManagerProtocol let localNotificationService: UserNotificationServicing + let telemetry: GameReminderTelemetry func makeAlarm(for productId: ProductId) -> (any GameStartReminderServicing)? { if #available(iOS 26.1, *) { return AlarmKitGameReminder( alarmManger: .shared, settingsManager: settingsManager, - keys: .product(productId) + keys: .product(productId), + telemetry: telemetry ) } return nil @@ -31,11 +38,28 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { LocalNotificationGameReminder( localNotificationService: localNotificationService, settingsManager: settingsManager, - keys: .product(productId) + keys: .product(productId), + telemetry: telemetry ) } func makeCalendar() -> any GameCalendarServicing { GameCalendarService(eventStore: EKEventStore(), settings: settingsManager) } + + func alertingAlarms() -> AsyncStream> { + guard #available(iOS 26.1, *) else { + return AsyncStream { $0.finish() } + } + let updates = AlarmManager.shared.alarmUpdates + return AsyncStream { continuation in + let task = Task { + for await alarms in updates { + continuation.yield(Set(alarms.filter { $0.state == .alerting }.map(\.id))) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductOpener.swift b/hosts/ios/polkadot-app/Modules/Products/ProductOpener.swift index 3ca1e910e9..2cf73416d8 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductOpener.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductOpener.swift @@ -4,7 +4,9 @@ import UIKit @MainActor protocol ProductOpening { - func open(productId: ProductId) + /// False when the product is already on screen and nothing was navigated. + @discardableResult + func open(productId: ProductId) -> Bool } @MainActor @@ -16,13 +18,14 @@ struct ProductOpener: ProductOpening { } /// An existing tab keeps its current page; before the tab bar is up the link is deferred. - func open(productId: ProductId) { + @discardableResult + func open(productId: ProductId) -> Bool { let tabBar = UIApplication.shared.mainTabBarController guard tabBar?.mountedProductId != productId else { - return + return false } if tabBar?.mountExistingTab(where: { $0.dotDomain == productId }) == true { - return + return true } // Opened directly: the product link is only routed when the products feature is on, @@ -30,8 +33,12 @@ struct ProductOpener: ProductOpening { if tabBar != nil, let host = ProductHostFactory(tldProvider: DotNsTldProviderFacade.shared).host(rawString: productId) { navigator.openProduct(page: ProductPage(host: host)) - } else if let url = URL(string: "\(AppConfig.ProductUniversalLink.scheme)://\(productId)") { - DeferredLinkHandler.shared.handle(with: url) + return true } + guard let url = URL(string: "\(AppConfig.ProductUniversalLink.scheme)://\(productId)") else { + return false + } + DeferredLinkHandler.shared.handle(with: url) + return true } } diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift new file mode 100644 index 0000000000..cf6ea5fc73 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift @@ -0,0 +1,13 @@ +import Foundation +import IssueMonitoring +import os + +final class RecordingGameReminderTelemetry: GameReminderTelemetry, @unchecked Sendable { + private let state = OSAllocatedUnfairLock<[GameReminderEvent]>(initialState: []) + + var events: [GameReminderEvent] { state.withLock { $0 } } + + func record(_ event: GameReminderEvent) { + state.withLock { $0.append(event) } + } +} diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift new file mode 100644 index 0000000000..e2569fff92 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift @@ -0,0 +1,71 @@ +import Foundation +import IssueMonitoring +import Testing + +struct GameReminderEventTests { + private let startsAt = Date(timeIntervalSince1970: 2_000_000_000) + private let origin = HostEventOrigin( + launchID: UUID(uuidString: "D93FE846-8F3C-4CFA-A440-402B6F7B43D3")!, + osVersion: "26.5", + appVersion: "1.2.3", + appBuild: 456 + ) + + @Test func eventCarriesTheProductTheStartAndHowLateItCameAsNumbers() { + var event = GameReminderEvent( + step: .productOpened, + productId: "game.dot", + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(1.5) + ) + event.openSource = .alarm + event.appState = .inactive + event.navigated = true + + let fields = event.sentryFields(origin: origin) + + #expect(event.name == "host.reminder.product_opened") + #expect(fields.tags["reminder.product_id"] == "game.dot") + #expect(fields.tags["reminder.open_source"] == "alarm") + #expect(fields.tags["host.app_state"] == "inactive") + #expect(fields.extras["reminder.starts_at_unix_ms"] as? Int64 == 2_000_000_000_000) + #expect(fields.extras["reminder.delay_after_start_ms"] as? Int64 == 1_500) + #expect(fields.extras["reminder.navigated"] as? Bool == true) + } + + @Test func eventCarriesTheCanonicalHostFields() { + let fields = GameReminderEvent(step: .scheduled, productId: "game.dot", startsAt: startsAt) + .sentryFields(origin: origin) + + #expect(fields.tags["host.platform"] == "ios") + #expect(fields.tags["host.os_version"] == "26.5") + #expect(fields.tags["host.native_app_version"] == "1.2.3") + #expect(fields.extras["host.app_build"] as? Int == 456) + #expect(fields.extras["app.launch_id"] as? String == "d93fe846-8f3c-4cfa-a440-402b6f7b43d3") + } + + @Test func skippedScheduleNamesItsDeliveryAndReason() { + var event = GameReminderEvent(step: .notScheduled, productId: "game.dot", startsAt: startsAt) + event.delivery = .alarm + event.skipReason = .notAuthorized + + let fields = event.sentryFields(origin: origin) + + #expect(event.name == "host.reminder.not_scheduled") + #expect(fields.tags["reminder.delivery"] == "alarm") + #expect(fields.tags["reminder.skip_reason"] == "not_authorized") + } + + @Test func absentValuesAreOmittedInsteadOfInvented() { + let fields = GameReminderEvent(step: .tapped, productId: "game.dot", startsAt: nil) + .sentryFields(origin: HostEventOrigin(launchID: UUID(), osVersion: "26.5", appVersion: nil, appBuild: nil)) + + #expect(fields.extras["reminder.starts_at_unix_ms"] == nil) + #expect(fields.extras["reminder.delay_after_start_ms"] == nil) + #expect(fields.extras["reminder.navigated"] == nil) + #expect(fields.extras["host.app_build"] == nil) + #expect(fields.tags["host.native_app_version"] == nil) + #expect(fields.tags["reminder.delivery"] == nil) + #expect(fields.tags["reminder.open_source"] == nil) + } +} diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index 35a81a0483..23c1707b30 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -1,5 +1,6 @@ import Foundation import FoundationExt +import IssueMonitoring import Testing import UIKit import Keystore_iOS @@ -27,7 +28,8 @@ struct ProductGameReminderCenterTests { applicationState: fakes, applicationStateStreams: ApplicationStateStreamFactory(), productOpener: fakes, - dateProvider: fakes + dateProvider: fakes, + telemetry: fakes.telemetry ) ) center.start() @@ -230,6 +232,78 @@ struct ProductGameReminderCenterTests { #expect(relaunched.pill == ProductGamePill(productId: productId, startsAt: startsAt)) } + @Test("A product's cancel is reported with its start; a start it never held reports nothing") + func reportsCancel() async { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + center.cancel(productId: other) + center.cancel(productId: productId) + + #expect(fakes.telemetry.events.map(\.step) == [.cancelled]) + #expect(fakes.telemetry.events.first?.productId == productId) + #expect(fakes.telemetry.events.first?.startsAt == startsAt) + } + + @Test("Opening at the start is reported as an auto-open with the app state and whether it navigated") + func reportsAutoOpen() async throws { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + fakes.openNavigates = false + + fakes.applicationState = .inactive + fakes.now = startsAt.addingTimeInterval(2) + center.refresh() + + let event = try #require(fakes.telemetry.events.last) + #expect(fakes.telemetry.events.map(\.step) == [.productOpened]) + #expect(event.openSource == .autoOpen) + #expect(event.appState == .inactive) + #expect(event.navigated == false) + #expect(event.occurredAt == startsAt.addingTimeInterval(2)) + } + + @Test("A start reached past the grace is reported as missed, and nothing opens") + func reportsMissedStart() async throws { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + fakes.applicationState = .background + fakes.now = startsAt.addingTimeInterval(600) + center.refresh() + + let event = try #require(fakes.telemetry.events.last) + #expect(fakes.telemetry.events.map(\.step) == [.missed]) + #expect(event.appState == .background) + #expect(event.occurredAt == startsAt.addingTimeInterval(600)) + #expect(fakes.opened.isEmpty) + } + + @Test("A ringing alarm is reported once, for the product that holds it") + func reportsFiredAlarmOnce() async throws { + let settings = InMemorySettingsManager() + let alarmId = UUID() + let otherAlarmId = UUID() + settings.set(string: alarmId.uuidString, for: GameReminderStorageKeys.product(productId).alarmId) + settings.set(string: otherAlarmId.uuidString, for: GameReminderStorageKeys.product(other).alarmId) + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + await center.schedule(productId: other, startsAt: startsAt.addingTimeInterval(60), addCalendarEvent: false) + fakes.now = startsAt.addingTimeInterval(1) + + // The stream is read in order, so the last product's ring arriving proves the repeat was read first. + for alarmIds: Set in [[UUID()], [alarmId], [alarmId], [otherAlarmId]] { + fakes.alarmAlerts.yield(alarmIds) + } + try await waitUntil { fakes.telemetry.events.count == 2 } + + let events = fakes.telemetry.events + #expect(events.map(\.step) == [.fired, .fired]) + #expect(events.map(\.productId) == [productId, other]) + #expect(events.first?.delivery == .alarm) + #expect(events.first?.occurredAt == startsAt.addingTimeInterval(1)) + } + @Test("A cancel while calendar access is being asked adds no event") func cancelDuringCalendarAccessAddsNoEvent() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-7_200)) @@ -253,13 +327,29 @@ private func reminderCall(_ startsAt: Date, _ productId: String) -> RecordingGam .init(gameDate: startsAt, productId: productId) } +private struct WaitTimedOut: Error {} + +@MainActor +private func waitUntil(_ condition: @MainActor () -> Bool) async throws { + for _ in 0 ..< 200 where !condition() { + try await Task.sleep(for: .milliseconds(10)) + } + guard condition() else { + throw WaitTimedOut() + } +} + // MARK: - Fakes private final class Fakes: @unchecked Sendable { var now: Date var applicationState: UIApplication.State = .active var opened: [String] = [] + var openNavigates = true let calendar = RecordingCalendar() + let telemetry = RecordingGameReminderTelemetry() + let alarmAlerts: AsyncStream>.Continuation + private let alarmAlertStream: AsyncStream> private let alarmAvailable: Bool private var alarms: [String: RecordingGameReminder] = [:] private var notifications: [String: RecordingGameReminder] = [:] @@ -267,6 +357,7 @@ private final class Fakes: @unchecked Sendable { init(now: Date, alarmAvailable: Bool) { self.now = now self.alarmAvailable = alarmAvailable + (alarmAlertStream, alarmAlerts) = AsyncStream.makeStream() } func alarm(for productId: String) -> RecordingGameReminder { @@ -303,8 +394,13 @@ extension Fakes: ProductGameReminderServicesMaking, ApplicationStateProviding, P calendar } - func open(productId: String) { + func alertingAlarms() -> AsyncStream> { + alarmAlertStream + } + + func open(productId: String) -> Bool { opened.append(productId) + return openNavigates } } From 03bbc2b09b7a4866c6738cfb0019c2a81684d096 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:21:27 +0200 Subject: [PATCH 13/22] fix(ios): count the Play tap as the open, match Android's reminder fields The alarm's Play button now goes through ProductGameReminderCenter, which drops the held reminder before opening, so a tap is never followed by a second product_opened or by missed. Every event carries logger host.telemetry, and reminder.delivery is set only on scheduled and fired, as on Android. A GameAlarmManaging seam over AlarmManager lets the alarm reminder's scheduled and not_scheduled be tested, alongside the notification reminder's. --- .../Sources/GameReminderEvent.swift | 3 + .../Sources/SentryGameReminderTelemetry.swift | 5 +- .../GameReminder/AlarmKitGameReminder.swift | 48 +++++-- .../GameReminder/GameAlarmStopIntent.swift | 20 +-- .../GameReminder/LegacyGameAlarmCleanup.swift | 2 +- .../LocalNotificationGameReminder.swift | 2 +- .../Products/ProductGameReminderCenter.swift | 32 ++++- .../ProductGameReminderServicesFactory.swift | 2 +- .../Mocks/MockUserNotificationService.swift | 41 ++++++ .../polkadot-appTests/Helpers/WaitUntil.swift | 14 ++ .../GameReminderDeliveryTelemetryTests.swift | 125 ++++++++++++++++++ .../Products/GameReminderEventTests.swift | 1 + .../Products/Mocks/MockGameAlarmManager.swift | 25 ++++ .../ProductGameReminderCenterTests.swift | 43 ++++-- 14 files changed, 313 insertions(+), 50 deletions(-) create mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/MockUserNotificationService.swift create mode 100644 hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift create mode 100644 hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/MockGameAlarmManager.swift diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift index 3d384df527..656f702ea6 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift @@ -54,6 +54,9 @@ public struct GameReminderEvent: Sendable, Equatable { self.occurredAt = occurredAt } + /// Shared with the Android host, so both platforms' reminder events filter together. + public static let logger = "host.telemetry" + public var name: String { "host.reminder.\(step.rawValue)" } diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift index fbf700a999..fb760e7169 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift @@ -5,7 +5,10 @@ struct SentryGameReminderTelemetry: GameReminderTelemetry { func record(_ event: GameReminderEvent) { let fields = event.sentryFields(origin: .current) - SentrySDK.capture(message: event.name) { scope in + let sentryEvent = Event(level: .info) + sentryEvent.message = SentryMessage(formatted: event.name) + sentryEvent.logger = GameReminderEvent.logger + SentrySDK.capture(event: sentryEvent) { scope in scope.setFingerprint([event.name]) fields.tags.forEach { scope.setTag(value: $0.value, key: $0.key) } fields.extras.forEach { scope.setExtra(value: $0.value, key: $0.key) } diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift index a69ae99658..3b04d563ca 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift @@ -7,9 +7,18 @@ import SwiftUI import Individuality import IssueMonitoring +/// The AlarmKit calls a game reminder makes, so the reminder can be exercised without the system alarm store. +@available(iOS 26.1, *) +protocol GameAlarmManaging { + var isAuthorized: Bool { get } + func alarmIds() throws -> [UUID] + func scheduleGameAlarm(id: UUID, at date: Date, playIntent: GameAlarmPlayIntent) async throws + func cancel(id: UUID) throws +} + @available(iOS 26.1, *) final class AlarmKitGameReminder: GameStartReminderServicing { - private let alarmManger: AlarmManager + private let alarmManger: GameAlarmManaging private let settingsManager: SettingsManagerProtocol private let keys: GameReminderStorageKeys private let logger: LoggerProtocol @@ -18,7 +27,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private var lastTask: Task? init( - alarmManger: AlarmManager, + alarmManger: GameAlarmManaging, settingsManager: SettingsManagerProtocol, keys: GameReminderStorageKeys, logger: LoggerProtocol = Logger.shared, @@ -60,7 +69,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { } private func performScheduleReminder(gameDate fireDate: Date, productId: ProductId) async { - guard alarmManger.authorizationState == .authorized else { + guard alarmManger.isAuthorized else { logger.debug("Not authorized, skipping schedule") recordNotScheduled(.notAuthorized, gameDate: fireDate, productId: productId) return @@ -105,7 +114,6 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private func recordNotScheduled(_ reason: GameReminderEvent.SkipReason, gameDate: Date, productId: ProductId) { var event = GameReminderEvent(step: .notScheduled, productId: productId, startsAt: gameDate) - event.delivery = .alarm event.skipReason = reason telemetry.record(event) } @@ -131,8 +139,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private func isAlarmScheduled(id: UUID) -> Bool { do { - let alarms = try alarmManger.alarms - return alarms.contains { $0.id == id } + return try alarmManger.alarmIds().contains(id) } catch { logger.error("Failed to fetch scheduled alarms: \(error)") return false @@ -144,6 +151,26 @@ final class AlarmKitGameReminder: GameStartReminderServicing { at date: Date, productId: ProductId ) async throws { + let playIntent = GameAlarmPlayIntent() + playIntent.alarmID = newAlarmId.uuidString + playIntent.productId = productId + playIntent.startsAt = date + + try await alarmManger.scheduleGameAlarm(id: newAlarmId, at: date, playIntent: playIntent) + } +} + +@available(iOS 26.1, *) +extension AlarmManager: GameAlarmManaging { + var isAuthorized: Bool { + authorizationState == .authorized + } + + func alarmIds() throws -> [UUID] { + try alarms.map(\.id) + } + + func scheduleGameAlarm(id: UUID, at date: Date, playIntent: GameAlarmPlayIntent) async throws { let attributes = AlarmAttributes( presentation: AlarmPresentation( alert: .init( @@ -160,13 +187,8 @@ final class AlarmKitGameReminder: GameStartReminderServicing { tintColor: Color.accentColor ) - let playIntent = GameAlarmPlayIntent() - playIntent.alarmID = newAlarmId.uuidString - playIntent.productId = productId - playIntent.startsAt = date - - _ = try await alarmManger.schedule( - id: newAlarmId, + _ = try await schedule( + id: id, configuration: AlarmManager.AlarmConfiguration( countdownDuration: nil, schedule: .fixed(date), diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift index dff29f61dd..91b3d9adfb 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift @@ -1,8 +1,6 @@ import Foundation import AlarmKit import AppIntents -import IssueMonitoring -import UIKit @available(iOS 26.1, *) struct GameAlarmPlayIntent: LiveActivityIntent { @@ -21,19 +19,11 @@ struct GameAlarmPlayIntent: LiveActivityIntent { @MainActor func perform() async throws -> some IntentResult { if let productId { - let telemetry = IssueMonitoringFactory.createGameReminderTelemetry() - let appState = GameReminderEvent.AppState(UIApplication.shared.applicationState) - - var tapped = GameReminderEvent(step: .tapped, productId: productId, startsAt: startsAt) - tapped.delivery = .alarm - tapped.appState = appState - telemetry.record(tapped) - - var opened = GameReminderEvent(step: .productOpened, productId: productId, startsAt: startsAt) - opened.openSource = .alarm - opened.appState = appState - opened.navigated = ProductOpener().open(productId: productId) - telemetry.record(opened) + // A tap that launches the app can come before the services are up; the center then works on the + // same stored reminders. + let registered: ProductGameReminderScheduling? = RootDependencyLocator.getDependency() + let reminders = registered as? ProductGameReminderCenter ?? ProductGameReminderCenter.makeDefault() + reminders.openFromAlarm(productId: productId, startsAt: startsAt) } if let alarmIDString = alarmID, diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/LegacyGameAlarmCleanup.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/LegacyGameAlarmCleanup.swift index b2ad4ce2d0..3909498d69 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/LegacyGameAlarmCleanup.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/LegacyGameAlarmCleanup.swift @@ -28,7 +28,7 @@ enum LegacyGameAlarmCleanup { if #available(iOS 26.1, *) { AlarmKitGameReminder( - alarmManger: .shared, + alarmManger: AlarmManager.shared, settingsManager: settingsManager, keys: keys ).cancelReminder() diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift index 81d4524c9c..59d909a9a4 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift @@ -89,7 +89,7 @@ private extension LocalNotificationGameReminder { skipReason: GameReminderEvent.SkipReason? = nil ) { var event = GameReminderEvent(step: step, productId: productId, startsAt: gameDate) - event.delivery = .notification + event.delivery = step == .scheduled ? .notification : nil event.skipReason = skipReason telemetry.record(event) } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index c1c1a38745..0108b801b3 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -147,6 +147,22 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam refresh() } + /// The alarm's Play button. The tap is the open, so the held reminder goes first: the start can then + /// neither open the product a second time nor count as missed. + func openFromAlarm(productId: ProductId, startsAt: Date?) { + let startsAt = startsAt ?? slot(for: productId)?.startsAt + if slot(for: productId) != nil { + drop(productId) + refresh() + } + record(.tapped, productId: productId, startsAt: startsAt) + let navigated = productOpener.open(productId: productId) + record(.productOpened, productId: productId, startsAt: startsAt) { event in + event.openSource = .alarm + event.navigated = navigated + } + } + func refresh() { wakeUp?.cancel() wakeUp = nil @@ -334,12 +350,16 @@ private extension ProductGameReminderCenter { for slot: Slot, configure: (inout GameReminderEvent) -> Void = { _ in } ) { - var event = GameReminderEvent( - step: step, - productId: slot.productId, - startsAt: slot.startsAt, - occurredAt: dateProvider.now - ) + record(step, productId: slot.productId, startsAt: slot.startsAt, configure: configure) + } + + func record( + _ step: GameReminderEvent.Step, + productId: ProductId, + startsAt: Date?, + configure: (inout GameReminderEvent) -> Void = { _ in } + ) { + var event = GameReminderEvent(step: step, productId: productId, startsAt: startsAt, occurredAt: dateProvider.now) event.appState = GameReminderEvent.AppState(applicationState.applicationState) configure(&event) telemetry.record(event) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift index ecac571985..012551d488 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift @@ -25,7 +25,7 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { func makeAlarm(for productId: ProductId) -> (any GameStartReminderServicing)? { if #available(iOS 26.1, *) { return AlarmKitGameReminder( - alarmManger: .shared, + alarmManger: AlarmManager.shared, settingsManager: settingsManager, keys: .product(productId), telemetry: telemetry diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/MockUserNotificationService.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/MockUserNotificationService.swift new file mode 100644 index 0000000000..4f2cd37562 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Helpers/Mocks/MockUserNotificationService.swift @@ -0,0 +1,41 @@ +import Foundation +import UserNotifications +@testable import polkadot_app + +final class MockUserNotificationService: UserNotificationServicing, @unchecked Sendable { + var accessStatus: NotificationAccessStatus = .allowed + var scheduleError: Error? + private(set) var scheduledIdentifiers: [String] = [] + + func notificationAccessStatus() async -> NotificationAccessStatus { + accessStatus + } + + func requestNotificationsAuthorization(completion: ((Bool) -> Void)?) { + completion?(accessStatus == .allowed) + } + + func scheduleNotification( + withIdentifier identifier: String, + content _: UNNotificationContent, + after _: TimeInterval, + completion: ((Error?) -> Void)? + ) { + scheduledIdentifiers.append(identifier) + completion?(scheduleError) + } + + func cancelScheduledNotifications(withIdentifiers _: [String]) {} + + func isNotificationScheduled(withIdentifier identifier: String, completion: @escaping (Bool) -> Void) { + completion(scheduledIdentifiers.contains(identifier)) + } + + func deliveredNotifications() async -> [UNNotification] { + [] + } + + func removeDeliveredNotifications(withIdentifiers _: [String]) {} + + func setBadge(_: Int) async {} +} diff --git a/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift b/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift new file mode 100644 index 0000000000..1462422b60 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift @@ -0,0 +1,14 @@ +import Foundation + +struct WaitTimedOut: Error {} + +/// Polls `condition` for work that finishes on a task the test cannot await. +@MainActor +func waitUntil(_ condition: @MainActor () -> Bool) async throws { + for _ in 0 ..< 200 where !condition() { + try await Task.sleep(for: .milliseconds(10)) + } + guard condition() else { + throw WaitTimedOut() + } +} diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift new file mode 100644 index 0000000000..d9642abb2a --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift @@ -0,0 +1,125 @@ +import Foundation +import IssueMonitoring +import Keystore_iOS +import Testing +@testable import polkadot_app + +@MainActor +@Suite("Game reminder delivery telemetry") +struct GameReminderDeliveryTelemetryTests { + private struct ScheduleFailed: Error {} + + private let productId = "game.dot" + private let keys = GameReminderStorageKeys.product("game.dot") + + @available(iOS 26.1, *) + private func makeAlarm(_ manager: MockGameAlarmManager, _ telemetry: RecordingGameReminderTelemetry) + -> AlarmKitGameReminder { + AlarmKitGameReminder( + alarmManger: manager, + settingsManager: InMemorySettingsManager(), + keys: keys, + telemetry: telemetry + ) + } + + private func makeNotification( + _ service: MockUserNotificationService, + _ telemetry: RecordingGameReminderTelemetry + ) -> LocalNotificationGameReminder { + LocalNotificationGameReminder( + localNotificationService: service, + settingsManager: InMemorySettingsManager(), + keys: keys, + telemetry: telemetry + ) + } + + @available(iOS 26.1, *) + @Test func alarmBookedOnceIsReportedOnceAsScheduled() async throws { + let manager = MockGameAlarmManager() + let telemetry = RecordingGameReminderTelemetry() + let reminder = makeAlarm(manager, telemetry) + let startsAt = Date().addingTimeInterval(3_600) + + reminder.scheduleReminder(gameDate: startsAt, productId: productId) + reminder.scheduleReminder(gameDate: startsAt, productId: productId) + try await waitUntil { manager.scheduled.count == 1 && !telemetry.events.isEmpty } + try await Task.sleep(for: .milliseconds(50)) + + #expect(telemetry.events.map(\.step) == [.scheduled]) + #expect(telemetry.events.first?.delivery == .alarm) + #expect(telemetry.events.first?.startsAt == startsAt) + } + + @Test( + "An alarm that is not booked says why", + arguments: [ + (false, 3_600.0, false, GameReminderEvent.SkipReason.notAuthorized), + (true, -60.0, false, .startInPast), + (true, 3_600.0, true, .failed) + ] + ) + @available(iOS 26.1, *) + func alarmNotScheduled( + authorized: Bool, + secondsToStart: TimeInterval, + fails: Bool, + reason: GameReminderEvent.SkipReason + ) async throws { + let manager = MockGameAlarmManager() + manager.isAuthorized = authorized + manager.scheduleError = fails ? ScheduleFailed() : nil + let telemetry = RecordingGameReminderTelemetry() + + // The reminder holds itself only weakly while its work is queued. + let reminder = makeAlarm(manager, telemetry) + reminder.scheduleReminder(gameDate: Date().addingTimeInterval(secondsToStart), productId: productId) + try await waitUntil { !telemetry.events.isEmpty } + + #expect(telemetry.events.map(\.step) == [.notScheduled]) + #expect(telemetry.events.first?.skipReason == reason) + #expect(telemetry.events.first?.delivery == nil) + } + + @Test func notificationBookedIsReportedAsScheduled() async throws { + let telemetry = RecordingGameReminderTelemetry() + + makeNotification(MockUserNotificationService(), telemetry) + .scheduleReminder(gameDate: Date().addingTimeInterval(3_600), productId: productId) + try await waitUntil { !telemetry.events.isEmpty } + + #expect(telemetry.events.map(\.step) == [.scheduled]) + #expect(telemetry.events.first?.delivery == .notification) + } + + @Test( + "A notification that is not booked says why", + arguments: [ + (false, 3_600.0, false, GameReminderEvent.SkipReason.notAuthorized), + (true, -60.0, false, .startInPast), + (true, 3_600.0, true, .failed) + ] + ) + func notificationNotScheduled( + allowed: Bool, + secondsToStart: TimeInterval, + fails: Bool, + reason: GameReminderEvent.SkipReason + ) async throws { + let service = MockUserNotificationService() + service.accessStatus = allowed ? .allowed : .notAllowed(denied: true) + service.scheduleError = fails ? ScheduleFailed() : nil + let telemetry = RecordingGameReminderTelemetry() + + makeNotification(service, telemetry).scheduleReminder( + gameDate: Date().addingTimeInterval(secondsToStart), + productId: productId + ) + try await waitUntil { !telemetry.events.isEmpty } + + #expect(telemetry.events.map(\.step) == [.notScheduled]) + #expect(telemetry.events.first?.skipReason == reason) + #expect(telemetry.events.first?.delivery == nil) + } +} diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift index e2569fff92..8128bc1f8b 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift @@ -25,6 +25,7 @@ struct GameReminderEventTests { let fields = event.sentryFields(origin: origin) #expect(event.name == "host.reminder.product_opened") + #expect(GameReminderEvent.logger == "host.telemetry") #expect(fields.tags["reminder.product_id"] == "game.dot") #expect(fields.tags["reminder.open_source"] == "alarm") #expect(fields.tags["host.app_state"] == "inactive") diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/MockGameAlarmManager.swift b/hosts/ios/polkadot-appTests/Products/Mocks/MockGameAlarmManager.swift new file mode 100644 index 0000000000..c8d4da6b76 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/MockGameAlarmManager.swift @@ -0,0 +1,25 @@ +import Foundation +@testable import polkadot_app + +@available(iOS 26.1, *) +final class MockGameAlarmManager: GameAlarmManaging, @unchecked Sendable { + var isAuthorized = true + var scheduleError: Error? + private(set) var scheduled: [(id: UUID, date: Date)] = [] + private(set) var cancelled: [UUID] = [] + + func alarmIds() throws -> [UUID] { + scheduled.map(\.id).filter { !cancelled.contains($0) } + } + + func scheduleGameAlarm(id: UUID, at date: Date, playIntent _: GameAlarmPlayIntent) async throws { + if let scheduleError { + throw scheduleError + } + scheduled.append((id, date)) + } + + func cancel(id: UUID) throws { + cancelled.append(id) + } +} diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index 23c1707b30..76eadfc327 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -304,6 +304,37 @@ struct ProductGameReminderCenterTests { #expect(events.first?.occurredAt == startsAt.addingTimeInterval(1)) } + @Test("The Play tap drops the reminder first, so the start neither opens again nor counts as missed") + func playTapIsTheOpen() async throws { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + fakes.now = startsAt.addingTimeInterval(5) + + center.openFromAlarm(productId: productId, startsAt: startsAt) + fakes.now = startsAt.addingTimeInterval(600) + center.refresh() + + let events = fakes.telemetry.events + #expect(events.map(\.step) == [.tapped, .productOpened]) + #expect(events.last?.openSource == .alarm) + #expect(events.last?.navigated == true) + #expect(events.allSatisfy { $0.startsAt == startsAt && $0.occurredAt == startsAt.addingTimeInterval(5) }) + #expect(fakes.opened == [productId]) + #expect(center.slots.isEmpty) + #expect(fakes.alarm(for: productId).cancelCount == 1) + } + + @Test("A Play tap with no held reminder still opens the product and reports it") + func playTapWithoutReminder() throws { + let (center, fakes) = makeSUT(now: startsAt) + + center.openFromAlarm(productId: productId, startsAt: nil) + + #expect(fakes.telemetry.events.map(\.step) == [.tapped, .productOpened]) + #expect(fakes.telemetry.events.first?.startsAt == nil) + #expect(fakes.opened == [productId]) + } + @Test("A cancel while calendar access is being asked adds no event") func cancelDuringCalendarAccessAddsNoEvent() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-7_200)) @@ -327,18 +358,6 @@ private func reminderCall(_ startsAt: Date, _ productId: String) -> RecordingGam .init(gameDate: startsAt, productId: productId) } -private struct WaitTimedOut: Error {} - -@MainActor -private func waitUntil(_ condition: @MainActor () -> Bool) async throws { - for _ in 0 ..< 200 where !condition() { - try await Task.sleep(for: .milliseconds(10)) - } - guard condition() else { - throw WaitTimedOut() - } -} - // MARK: - Fakes private final class Fakes: @unchecked Sendable { From bc4b84127abb07cc2d54021d0e67ebab3314aa43 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:24:52 +0200 Subject: [PATCH 14/22] style(ios): wrap the reminder event line to the 120-column limit --- .../Modules/Products/ProductGameReminderCenter.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index 0108b801b3..0f56b5d7f4 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -359,7 +359,12 @@ private extension ProductGameReminderCenter { startsAt: Date?, configure: (inout GameReminderEvent) -> Void = { _ in } ) { - var event = GameReminderEvent(step: step, productId: productId, startsAt: startsAt, occurredAt: dateProvider.now) + var event = GameReminderEvent( + step: step, + productId: productId, + startsAt: startsAt, + occurredAt: dateProvider.now + ) event.appState = GameReminderEvent.AppState(applicationState.applicationState) configure(&event) telemetry.record(event) From ab88afef3808ac5f47bf3c46dd226dcd3ef1ba43 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:08:39 +0100 Subject: [PATCH 15/22] feat(ios): consolidate native host telemetry --- .../IssueMonitoring/Sources/HostEvent.swift | 71 ++++++++++ .../Sources/HostEventOrigin.swift | 53 ++++++++ .../Sources/HostEventRecording.swift | 11 ++ .../Sources/IssueMonitoringFactory.swift | 23 ++++ .../Sources/NativeHostTelemetry.swift | 23 ++++ .../Sources/SentryHostEventRecorder.swift | 28 ++++ hosts/ios/README.md | 5 +- hosts/ios/polkadot-app/AppDelegate.swift | 7 + .../Products/SPA/Rust/SPARustRuntime.swift | 19 ++- .../SPA/Rust/SPARustRuntimeFactory.swift | 4 +- .../TrUAPI/RustRuntimeScriptsFactory.swift | 71 +--------- .../TrUAPI/Worker/TrUAPIWorkerRuntime.swift | 5 +- .../Mocks/RecordingHostEventRecorder.swift | 15 +++ .../TrUAPI/HostEventTests.swift | 54 ++++++++ .../TrUAPI/SPARustRuntimeTests.swift | 126 ++++++++---------- 15 files changed, 364 insertions(+), 151 deletions(-) create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/HostEventRecording.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/NativeHostTelemetry.swift create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/SentryHostEventRecorder.swift create mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingHostEventRecorder.swift create mode 100644 hosts/ios/polkadot-appTests/TrUAPI/HostEventTests.swift diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift b/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift new file mode 100644 index 0000000000..2574597ca3 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift @@ -0,0 +1,71 @@ +import Foundation + +public enum HostEventValue: Sendable, Equatable { + case string(String) + case integer(Int) + case double(Double) + case boolean(Bool) +} + +public struct HostEvent: Sendable, Equatable { + public let name: String + public let tags: [String: String] + public let extras: [String: HostEventValue] + + public init(name: String, tags: [String: String], extras: [String: HostEventValue]) { + self.name = name + self.tags = tags + self.extras = extras + } + + public static func processStarted( + origin: HostEventOrigin, + truapiHostVersion: String, + monotonicMilliseconds: Double + ) -> HostEvent { + HostEvent( + name: "host.process.started", + tags: tags(origin: origin, truapiHostVersion: truapiHostVersion).merging([ + "host.os_version": origin.osVersion + ]) { _, value in value }, + extras: appMetadata(origin: origin).merging([ + "app.launch_id": .string(origin.launchID.uuidString.lowercased()), + "event.monotonic_ms": .double(monotonicMilliseconds) + ]) { _, value in value } + ) + } + + public static func webViewCreated( + hostSessionID: UUID, + origin: HostEventOrigin, + truapiHostVersion: String, + monotonicMilliseconds: Double + ) -> HostEvent { + HostEvent( + name: "host.webview.created", + tags: tags(origin: origin, truapiHostVersion: truapiHostVersion), + extras: appMetadata(origin: origin).merging([ + "host.session_id": .string(hostSessionID.uuidString.lowercased()), + "event.monotonic_ms": .double(monotonicMilliseconds) + ]) { _, value in value } + ) + } +} + +private extension HostEvent { + static func tags(origin: HostEventOrigin, truapiHostVersion: String) -> [String: String] { + var tags = [ + "host.app_variant": origin.appVariant, + "host.platform": "ios", + "truapi.host_version": truapiHostVersion + ] + tags["host.native_app_version"] = origin.appVersion + return tags + } + + static func appMetadata(origin: HostEventOrigin) -> [String: HostEventValue] { + var extras: [String: HostEventValue] = [:] + extras["host.app_build"] = origin.appBuild.map(HostEventValue.integer) + return extras + } +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift new file mode 100644 index 0000000000..b970f61841 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventOrigin.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Process-wide metadata shared by native host telemetry events. +public struct HostEventOrigin: Sendable { + public let launchID: UUID + public let osVersion: String + public let appVersion: String? + public let appBuild: Int? + public let appVariant: String + + public init( + launchID: UUID, + osVersion: String, + appVersion: String?, + appBuild: Int?, + appVariant: String + ) { + self.launchID = launchID + self.osVersion = osVersion + self.appVersion = appVersion + self.appBuild = appBuild + self.appVariant = appVariant + } + + public static let current = HostEventOrigin( + launchID: UUID(), + osVersion: plainOSVersion(ProcessInfo.processInfo.operatingSystemVersion), + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, + appBuild: (Bundle.main.infoDictionary?["CFBundleVersion"] as? String).flatMap { Int($0) }, + appVariant: currentAppVariant + ) +} + +private extension HostEventOrigin { + static func plainOSVersion(_ version: OperatingSystemVersion) -> String { + let baseVersion = "\(version.majorVersion).\(version.minorVersion)" + return version.patchVersion == 0 ? baseVersion : "\(baseVersion).\(version.patchVersion)" + } + + static var currentAppVariant: String { + let bundleIdentifier = Bundle.main.bundleIdentifier + + if bundleIdentifier?.hasSuffix(".safety") == true { + return "safetynet" + } + + if bundleIdentifier?.hasSuffix(".develop") == true { + return "debug" + } + + return IssueMonitoringFactory.isReportingSupported ? "nightly" : "release" + } +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/HostEventRecording.swift b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventRecording.swift new file mode 100644 index 0000000000..c5a9437a8c --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/HostEventRecording.swift @@ -0,0 +1,11 @@ +import Foundation + +public protocol HostEventRecording: Sendable { + func record(_ event: HostEvent) +} + +public struct NoopHostEventRecorder: HostEventRecording { + public init() {} + + public func record(_: HostEvent) {} +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift index a71f7a0b3e..12dca10dcd 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift @@ -24,6 +24,29 @@ public enum IssueMonitoringFactory { #endif } + /// Builds the immutable product bootstrap only when this build links reporting support. + public static func createNativeHostTelemetry( + hostSessionID: UUID, + truapiHostVersion: String + ) -> NativeHostTelemetry? { + guard isReportingSupported else { return nil } + + return NativeHostTelemetry( + hostSessionID: hostSessionID, + origin: .current, + truapiHostVersion: truapiHostVersion + ) + } + + /// Records typed native host lifecycle events; builds without reporting support receive a no-op recorder. + public static func createHostEventRecorder() -> HostEventRecording { + #if SENTRY_ENABLED + SentryHostEventRecorder() + #else + NoopHostEventRecorder() + #endif + } + /// Reports through the SDK `IssueMonitoringServiceProtocol.setup()` started; a no-op until then and in Release. public static func createReporter() -> IssueReporting { #if SENTRY_ENABLED diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/NativeHostTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/NativeHostTelemetry.swift new file mode 100644 index 0000000000..d610bcea03 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/NativeHostTelemetry.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct NativeHostTelemetry: Encodable, Sendable { + public let hostSessionId: String + public let appVersion: String? + public let appBuild: String? + public let appVariant: String + public let platform: String + public let osVersion: String + public let truapiHostVersion: String + public let schemaVersion: String + + public init(hostSessionID: UUID, origin: HostEventOrigin, truapiHostVersion: String) { + hostSessionId = hostSessionID.uuidString.lowercased() + appVersion = origin.appVersion + appBuild = origin.appBuild.map(String.init) + appVariant = origin.appVariant + platform = "ios" + osVersion = origin.osVersion + self.truapiHostVersion = truapiHostVersion + schemaVersion = "1" + } +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/SentryHostEventRecorder.swift b/hosts/ios/Packages/IssueMonitoring/Sources/SentryHostEventRecorder.swift new file mode 100644 index 0000000000..b3d48dae26 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/SentryHostEventRecorder.swift @@ -0,0 +1,28 @@ +#if SENTRY_ENABLED + import Foundation + import Sentry + + struct SentryHostEventRecorder: HostEventRecording { + func record(_ event: HostEvent) { + let sentryEvent = Event(level: .info) + sentryEvent.message = SentryMessage(formatted: event.name) + sentryEvent.logger = "host.telemetry" + sentryEvent.fingerprint = [event.name] + sentryEvent.tags = event.tags + sentryEvent.extra = event.extras.mapValues(\.sentryValue) + + SentrySDK.capture(event: sentryEvent) + } + } + + private extension HostEventValue { + var sentryValue: Any { + switch self { + case let .string(value): value + case let .integer(value): value + case let .double(value): value + case let .boolean(value): value + } + } + } +#endif diff --git a/hosts/ios/README.md b/hosts/ios/README.md index bb0d75e5e0..8ba1194930 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -115,8 +115,9 @@ Polkadot iOS is a self-custodial superapp: your keys are created on your phone, Built with **UIKit** and programmatic layout (no Storyboards), using **VIPER** for every feature module: code is split between the main app target and 28 local Swift packages under [`Packages/`](./Packages) with `AppDependencies` as the root package, chain access goes through [substrate-sdk-ios](https://github.com/novasamatech/substrate-sdk-ios) (JSON-RPC, storage subscriptions, extrinsics), and local data lives in CoreData. -Non-release TrUAPI SPA runtimes publish immutable native host metadata before the product container starts, -so product diagnostics can correlate one WebView activation with its host version and build. +Non-release TrUAPI SPA runtimes publish an immutable `globalThis.__trinityHostTelemetry` schema-v1 object before +the product container starts. It contains the WebView's `hostSessionId` plus the native app version, build, variant, +platform, OS version and TrUAPI host version used to correlate product diagnostics with `host.webview.created`. This repository ships a **GitHub Actions + Fastlane CI/CD setup** — PR build and tests, plus maintainer-gated TestFlight and Firebase App Distribution. Build-time diff --git a/hosts/ios/polkadot-app/AppDelegate.swift b/hosts/ios/polkadot-app/AppDelegate.swift index f7cc3eaca9..86a715e8b0 100644 --- a/hosts/ios/polkadot-app/AppDelegate.swift +++ b/hosts/ios/polkadot-app/AppDelegate.swift @@ -2,6 +2,7 @@ import UIKit import TipKit import IssueMonitoring import Keystore_iOS +import TrUAPIHost @main final class AppDelegate: UIResponder, UIApplicationDelegate { @@ -9,6 +10,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { let issueMonitoringService: IssueMonitoringServiceProtocol = IssueMonitoringFactory.createService( dsn: GeneratedSecrets.sentryDSN ) + let hostEventRecorder = IssueMonitoringFactory.createHostEventRecorder() var apnsTokenProvider: APNSTokenProviding { APNSTokenProviderFacade.sharedManager @@ -28,6 +30,11 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { configureTips() issueMonitoringService.setup() + hostEventRecorder.record(.processStarted( + origin: .current, + truapiHostVersion: truapiHostVersion(), + monotonicMilliseconds: ProcessInfo.processInfo.systemUptime * 1_000 + )) // The OS requires every background task handler to be registered before launch finishes. StatementRenewalBackgroundTask.shared?.register() diff --git a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift index 4b89c62ece..f18d0d3617 100644 --- a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift +++ b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntime.swift @@ -1,4 +1,5 @@ import Foundation +import IssueMonitoring import Products import TrUAPIHost import UIKitExt @@ -21,6 +22,7 @@ actor SPARustRuntime { private let productResolver: ProductResolving private let schemeHandlerProxy: SchemeHandlerProxy private let logger: LoggerProtocol + private let hostEventRecorder: HostEventRecording private let hostSessionId = UUID() private var engine: JSEngineProtocol? @@ -34,7 +36,8 @@ actor SPARustRuntime { dotNsResolver: DotNsResolverProtocol, productResolver: ProductResolving, schemeHandlerProxy: SchemeHandlerProxy, - logger: LoggerProtocol + logger: LoggerProtocol, + hostEventRecorder: HostEventRecording ) { self.executionModel = executionModel self.configuration = configuration @@ -42,6 +45,7 @@ actor SPARustRuntime { self.productResolver = productResolver self.schemeHandlerProxy = schemeHandlerProxy self.logger = logger + self.hostEventRecorder = hostEventRecorder } deinit { @@ -77,8 +81,10 @@ extension SPARustRuntime: SPARuntimeProtocol { let bootstrapScript = try executionModel.startBridge() let scriptsFactory = RustRuntimeScriptsFactory( bootstrapScript: bootstrapScript, - hostTelemetry: .current(hostSessionId: hostSessionId), - logger: logger + hostTelemetry: IssueMonitoringFactory.createNativeHostTelemetry( + hostSessionID: hostSessionId, + truapiHostVersion: truapiHostVersion() + ) ) await engine.registerJSDeviceCapabilityHandler( @@ -89,6 +95,13 @@ extension SPARustRuntime: SPARuntimeProtocol { try await engine.initialize(with: scriptsFactory.makeScripts() + [.disableZoom]) + hostEventRecorder.record(.webViewCreated( + hostSessionID: hostSessionId, + origin: .current, + truapiHostVersion: truapiHostVersion(), + monotonicMilliseconds: ProcessInfo.processInfo.systemUptime * 1_000 + )) + // Disposed while initializing: dispose captured nil for the engine, // so this start is the only owner left — destroy before bailing. guard !disposed else { diff --git a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntimeFactory.swift b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntimeFactory.swift index 74a8b12e7f..fccdcc9594 100644 --- a/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntimeFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/SPA/Rust/SPARustRuntimeFactory.swift @@ -1,4 +1,5 @@ import Foundation +import IssueMonitoring import Products import UIKitExt @@ -47,7 +48,8 @@ extension SPARustRuntimeFactory: SPARuntimeFactoryProtocol { dotNsResolver: environment.dotNsResolver, productResolver: environment.productResolver, schemeHandlerProxy: environment.schemeHandlerProxy, - logger: environment.rust.logger + logger: environment.rust.logger, + hostEventRecorder: IssueMonitoringFactory.createHostEventRecorder() ) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift index 498787369f..b89bb85748 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustRuntimeScriptsFactory.swift @@ -1,92 +1,29 @@ import Foundation +import IssueMonitoring import Products import TrUAPIHost -private func currentTrUAPIHostVersion() -> String { - truapiHostVersion() -} - -struct NativeHostTelemetry: Encodable { - let hostSessionId: String - let appVersion: String? - let appBuild: String? - let appVariant: String - let platform: String - let osVersion: String - let truapiHostVersion: String - let schemaVersion: String - - static func current(hostSessionId: UUID) -> NativeHostTelemetry? { - #if NIGHTLY || SAFETYNET || UNSTABLE || DEBUG - current(hostSessionId: hostSessionId, enabled: true) - #else - current(hostSessionId: hostSessionId, enabled: false) - #endif - } - - static func current(hostSessionId: UUID, enabled: Bool) -> NativeHostTelemetry? { - guard enabled else { return nil } - - return NativeHostTelemetry( - hostSessionId: hostSessionId.uuidString.lowercased(), - appVersion: Bundle.main.appVersion, - appBuild: Bundle.main.appBuild, - appVariant: currentAppVariant, - platform: "ios", - osVersion: plainOSVersion(ProcessInfo.processInfo.operatingSystemVersion), - truapiHostVersion: currentTrUAPIHostVersion(), - schemaVersion: "1" - ) - } - - static func plainOSVersion(_ version: OperatingSystemVersion) -> String { - let baseVersion = "\(version.majorVersion).\(version.minorVersion)" - return version.patchVersion == 0 ? baseVersion : "\(baseVersion).\(version.patchVersion)" - } - - private static var currentAppVariant: String { - #if NIGHTLY - "nightly" - #elseif SAFETYNET - "safetynet" - #elseif UNSTABLE - "debug" - #elseif DEBUG - "debug" - #else - "release" - #endif - } -} - /// Bootstrap must publish the bridge endpoint before the container runs. final class RustRuntimeScriptsFactory { private let bootstrapScript: String private let hostTelemetry: NativeHostTelemetry? - private let logger: LoggerProtocol init( bootstrapScript: String, - hostTelemetry: NativeHostTelemetry? = nil, - logger: LoggerProtocol + hostTelemetry: NativeHostTelemetry? = nil ) { self.bootstrapScript = bootstrapScript self.hostTelemetry = hostTelemetry - self.logger = logger } func makeScripts() throws -> [JSEngineScript] { var scripts: [JSEngineScript] = [] if let hostTelemetry { - do { - scripts.append(try makeHostTelemetryScript(hostTelemetry)) - } catch { - logger.warning("[truapi] host telemetry bootstrap could not be encoded: \(error)") - } + try scripts.append(makeHostTelemetryScript(hostTelemetry)) } - scripts.append(contentsOf: try [ + try scripts.append(contentsOf: [ JSEngineScript(content: bootstrapScript, insertionPoint: .atDocStart), JSEngineScript( content: ContainerScriptBundle.load(), diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift index 41a2e97ee7..230b23cc90 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Worker/TrUAPIWorkerRuntime.swift @@ -44,10 +44,7 @@ actor TrUAPIWorkerRuntime { try checkNotDisposed() let bootstrap = try executionModel.startBridge() - let scripts = try RustRuntimeScriptsFactory( - bootstrapScript: bootstrap, - logger: logger - ).makeScripts() + let scripts = try RustRuntimeScriptsFactory(bootstrapScript: bootstrap).makeScripts() let jsEngine = try await bootEngine(scripts: scripts) try checkNotDisposed() diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingHostEventRecorder.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingHostEventRecorder.swift new file mode 100644 index 0000000000..9ff9907bee --- /dev/null +++ b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingHostEventRecorder.swift @@ -0,0 +1,15 @@ +import Foundation +import IssueMonitoring + +final class RecordingHostEventRecorder: HostEventRecording, @unchecked Sendable { + private let lock = NSLock() + private var recordedEvents: [HostEvent] = [] + + var events: [HostEvent] { + lock.withLock { recordedEvents } + } + + func record(_ event: HostEvent) { + lock.withLock { recordedEvents.append(event) } + } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/HostEventTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/HostEventTests.swift new file mode 100644 index 0000000000..d5be8f5f80 --- /dev/null +++ b/hosts/ios/polkadot-appTests/TrUAPI/HostEventTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing +import IssueMonitoring + +struct HostEventTests { + @Test func processStartedKeepsNumericMeasurementsTyped() throws { + let origin = try HostEventOrigin( + launchID: #require(UUID(uuidString: "18a5320e-3a4d-46df-ad7a-ebba4ef3ee3f")), + osVersion: "26.5.1", + appVersion: "1.2.3", + appBuild: 456, + appVariant: "nightly" + ) + + let event = HostEvent.processStarted( + origin: origin, + truapiHostVersion: "0.9.0", + monotonicMilliseconds: 42.5 + ) + + #expect(event.name == "host.process.started") + #expect(event.tags["host.app_variant"] == "nightly") + #expect(event.tags["host.os_version"] == "26.5.1") + #expect(event.tags["host.native_app_version"] == "1.2.3") + #expect(event.tags["truapi.host_version"] == "0.9.0") + #expect(event.extras["app.launch_id"] == .string("18a5320e-3a4d-46df-ad7a-ebba4ef3ee3f")) + #expect(event.extras["host.app_build"] == .integer(456)) + #expect(event.extras["event.monotonic_ms"] == .double(42.5)) + } + + @Test func webViewCreatedCarriesItsCorrelationIDAsAnExtra() throws { + let origin = HostEventOrigin( + launchID: UUID(), + osVersion: "26.5", + appVersion: nil, + appBuild: nil, + appVariant: "debug" + ) + let hostSessionID = try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) + + let event = HostEvent.webViewCreated( + hostSessionID: hostSessionID, + origin: origin, + truapiHostVersion: "0.9.0", + monotonicMilliseconds: 84 + ) + + #expect(event.name == "host.webview.created") + #expect(event.extras["host.session_id"] == .string("d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) + #expect(event.extras["event.monotonic_ms"] == .double(84)) + #expect(event.extras["app.launch_id"] == nil) + #expect(event.tags["host.os_version"] == nil) + } +} diff --git a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift index 4d17c6f864..f3808a82b3 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/SPARustRuntimeTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import AsyncExtensions +import IssueMonitoring import Products import TrUAPIHost @testable import polkadot_app @@ -44,7 +45,8 @@ private func makeRuntime( chainConnections: MockChainConnections = MockChainConnections(), configuration: SPAConfiguration, dotNsResolver: DotNsResolverProtocol = StubDotNsResolver(), - productResolver: ProductResolving = StubProductResolver() + productResolver: ProductResolving = StubProductResolver(), + hostEventRecorder: HostEventRecording = NoopHostEventRecorder() ) -> SPARustRuntime { SPARustRuntime( executionModel: makeExecutionModel(execution: execution, chainConnections: chainConnections), @@ -52,7 +54,8 @@ private func makeRuntime( dotNsResolver: dotNsResolver, productResolver: productResolver, schemeHandlerProxy: SchemeHandlerProxy(), - logger: Logger.shared + logger: Logger.shared, + hostEventRecorder: hostEventRecorder ) } @@ -64,7 +67,12 @@ struct SPARustRuntimeTests { let configuration = try makeConfiguration(contentSource: .directURL(directURL)) let execution = MockProductExecution() let engine = MockJSEngine() - let runtime = makeRuntime(execution: execution, configuration: configuration) + let hostEventRecorder = RecordingHostEventRecorder() + let runtime = makeRuntime( + execution: execution, + configuration: configuration, + hostEventRecorder: hostEventRecorder + ) let url = try await runtime.start(with: engine) @@ -73,25 +81,23 @@ struct SPARustRuntimeTests { #expect(execution.permissionRequests.isEmpty) #expect(engine.mediaHandlerWasInstalledAtInitialization) - // Host telemetry → bootstrap → container → zoom disable, doc-start ordering intact. - #expect(engine.initializedScripts.count == 4) - #expect(engine.initializedScripts[0].content.contains("__trinityHostTelemetry")) - #expect(engine.initializedScripts[0].content.contains("Object.freeze")) - #expect(engine.initializedScripts[0].content.contains("writable: false")) - #expect(engine.initializedScripts[0].content.contains("configurable: false")) - #expect(engine.initializedScripts[0].content.contains("try {")) - #expect(engine.initializedScripts[0].content.contains("console.warn")) - #expect(engine.initializedScripts[0].insertionPoint == .atDocStart) - #expect(engine.initializedScripts[0].frameScope == .mainFrameOnly) - #expect(engine.initializedScripts[1] + // Tests build IssueMonitoring without Sentry: bootstrap → container → zoom disable. + #expect(engine.initializedScripts.count == 3) + #expect(engine.initializedScripts[0] .content == #"window.__truapi_localhost = { url: "ws://127.0.0.1:0/?t=test" };"#) + #expect(engine.initializedScripts[0].insertionPoint == .atDocStart) + #expect(!engine.initializedScripts[0].content.contains("__truapi_policy__")) + #expect(engine.initializedScripts[1].content.contains("freezeAndDelete")) #expect(engine.initializedScripts[1].insertionPoint == .atDocStart) - #expect(!engine.initializedScripts[1].content.contains("__truapi_policy__")) - #expect(engine.initializedScripts[2].content.contains("freezeAndDelete")) - #expect(engine.initializedScripts[2].insertionPoint == .atDocStart) - #expect(engine.initializedScripts[3].content.contains("viewport")) - #expect(engine.initializedScripts[3].insertionPoint == .atDocEnd) - #expect(engine.initializedScripts[3].frameScope == .mainFrameOnly) + #expect(engine.initializedScripts[2].content.contains("viewport")) + #expect(engine.initializedScripts[2].insertionPoint == .atDocEnd) + #expect(engine.initializedScripts[2].frameScope == .mainFrameOnly) + + let webViewEvent = try #require(hostEventRecorder.events.first) + #expect(webViewEvent.name == "host.webview.created") + #expect(webViewEvent.tags["host.platform"] == "ios") + #expect(webViewEvent.extras["host.session_id"] != nil) + #expect(webViewEvent.extras["event.monotonic_ms"] != nil) await runtime.dispose() } @@ -138,7 +144,7 @@ struct SPARustRuntimeTests { #expect(url.scheme == ProductScriptSchemeHandler.scheme) #expect(url.host == "test.dot") #expect(url.path == "/") - #expect(engine.initializedScripts[1] + #expect(engine.initializedScripts[0] .content == #"window.__truapi_localhost = { url: "ws://127.0.0.1:0/?t=test" };"#) await runtime.dispose() @@ -200,20 +206,22 @@ struct SPARustRuntimeTests { } @Test func rustScriptsFactoryOrdersBootstrapBeforeContainer() throws { - let telemetry = NativeHostTelemetry( - hostSessionId: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3", - appVersion: "1.2.3", - appBuild: "456", - appVariant: "nightly", - platform: "ios", + let hostSessionID = try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) + let origin = try HostEventOrigin( + launchID: #require(UUID(uuidString: "18a5320e-3a4d-46df-ad7a-ebba4ef3ee3f")), osVersion: "18.1", - truapiHostVersion: "0.9.0", - schemaVersion: "1" + appVersion: "1.2.3", + appBuild: 456, + appVariant: "nightly" + ) + let telemetry = NativeHostTelemetry( + hostSessionID: hostSessionID, + origin: origin, + truapiHostVersion: "0.9.0" ) let factory = RustRuntimeScriptsFactory( bootstrapScript: "/*bootstrap*/", - hostTelemetry: telemetry, - logger: StubLogger() + hostTelemetry: telemetry ) let scripts = try factory.makeScripts() @@ -224,13 +232,21 @@ struct SPARustRuntimeTests { ].joined() #expect(scripts.count == 3) - #expect(scripts[0].content.contains("Object.defineProperty(globalThis, \"__trinityHostTelemetry\"")) - #expect(scripts[0].content.contains("try {")) - #expect(scripts[0].content.contains("console.warn")) - #expect(scripts[0].content.contains(expectedJSON)) + let expectedTelemetryScript = """ + try { + Object.defineProperty(globalThis, "__trinityHostTelemetry", { + value: Object.freeze(\(expectedJSON)), + writable: false, + configurable: false + }); + } catch (error) { + console.warn("Native host telemetry bootstrap was not installed", error); + } + """ + + #expect(scripts[0].content == expectedTelemetryScript) #expect(scripts[0].insertionPoint == .atDocStart) #expect(scripts[0].frameScope == .mainFrameOnly) - #expect(try factory.makeScripts()[0].content == scripts[0].content) #expect(scripts[1].content == "/*bootstrap*/") #expect(scripts[1].insertionPoint == .atDocStart) #expect(scripts[1].frameScope == .mainFrameOnly) @@ -239,44 +255,6 @@ struct SPARustRuntimeTests { #expect(scripts[2].frameScope == .allFrames) } - @Test func nativeHostTelemetryIsAbsentWhenDisabled() throws { - let telemetry = NativeHostTelemetry.current( - hostSessionId: try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")), - enabled: false - ) - - #expect(telemetry == nil) - } - - @Test func nativeHostTelemetryUsesCanonicalRuntimeMetadata() throws { - let hostSessionId = try #require(UUID(uuidString: "d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) - let telemetry = try #require( - NativeHostTelemetry.current( - hostSessionId: hostSessionId, - enabled: true - ) - ) - let operatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion - - #expect(telemetry.osVersion == NativeHostTelemetry.plainOSVersion(operatingSystemVersion)) - #expect(!telemetry.osVersion.hasPrefix("Version ")) - #expect(!telemetry.osVersion.contains("Build")) - #expect(telemetry.truapiHostVersion == truapiHostVersion()) - } - - @Test func plainOSVersionOmitsOnlyAnEmptyPatchComponent() { - #expect( - NativeHostTelemetry.plainOSVersion( - OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 0) - ) == "26.5" - ) - #expect( - NativeHostTelemetry.plainOSVersion( - OperatingSystemVersion(majorVersion: 26, minorVersion: 5, patchVersion: 1) - ) == "26.5.1" - ) - } - @Test func directURLHandlerAllowsSameHostInterceptsOthers() throws { let handler = try DirectURLNavigationDecisionHandler( baseURL: #require(URL(string: "http://localhost:3000")) From 15331ddac27d9de57cb696890583220d2bfeac88 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:54:23 +0100 Subject: [PATCH 16/22] refactor(ios): use game reminder diagnostics --- .../Sources/GameReminderDiagnostics.swift | 24 ++++++++ .../Sources/GameReminderEvent.swift | 2 +- .../Sources/GameReminderTelemetry.swift | 12 ---- .../Sources/IssueMonitoringFactory.swift | 10 ---- .../Sources/IssueReporting.swift | 6 +- .../Sources/SentryGameReminderTelemetry.swift | 18 ------ .../Sources/SentryIssueReporter.swift | 12 ++++ .../GameReminder/AlarmKitGameReminder.swift | 10 ++-- .../LocalNotificationGameReminder.swift | 8 +-- .../Products/ProductGameReminderCenter.swift | 14 ++--- .../ProductGameReminderServicesFactory.swift | 6 +- .../RecordingGameReminderIssueReporter.swift | 19 ++++++ .../RecordingGameReminderTelemetry.swift | 13 ----- ...ameReminderDeliveryDiagnosticsTests.swift} | 58 +++++++++---------- .../Products/GameReminderEventTests.swift | 8 +-- .../ProductGameReminderCenterTests.swift | 29 +++++----- 16 files changed, 128 insertions(+), 121 deletions(-) create mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/GameReminderDiagnostics.swift delete mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift delete mode 100644 hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift create mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderIssueReporter.swift delete mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift rename hosts/ios/polkadot-appTests/Products/{GameReminderDeliveryTelemetryTests.swift => GameReminderDeliveryDiagnosticsTests.swift} (63%) diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderDiagnostics.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderDiagnostics.swift new file mode 100644 index 0000000000..a969c32b76 --- /dev/null +++ b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderDiagnostics.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Reports the steps of a product's game reminder through the issue-monitoring abstraction. +public struct GameReminderDiagnostics: Sendable { + private let reporter: IssueReporting + + public init(reporter: IssueReporting) { + self.reporter = reporter + } + + public func record(_ event: GameReminderEvent) { + reporter.report(event) + } +} + +public extension GameReminderDiagnostics { + static func make(reporter: IssueReporting) -> Self { + guard IssueMonitoringFactory.isReportingSupported else { + return Self(reporter: NoopIssueReporter()) + } + + return Self(reporter: reporter) + } +} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift index 656f702ea6..28dcb5b9e7 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderEvent.swift @@ -63,7 +63,7 @@ public struct GameReminderEvent: Sendable, Equatable { /// Times are device wall clock. The alarm rings at the start, so `delay_after_start_ms` is also how /// late a step came against the alarm's fire time. - public func sentryFields(origin: HostEventOrigin) -> HostEventFields { + public func fields(origin: HostEventOrigin) -> HostEventFields { var tags = origin.tags tags["reminder.product_id"] = productId tags["reminder.delivery"] = delivery?.rawValue diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift deleted file mode 100644 index f9d6f6b0ec..0000000000 --- a/hosts/ios/Packages/IssueMonitoring/Sources/GameReminderTelemetry.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// Records the steps of a product's game reminder. A no-op in Release, where no reporting SDK is linked. -public protocol GameReminderTelemetry: Sendable { - func record(_ event: GameReminderEvent) -} - -public struct NoopGameReminderTelemetry: GameReminderTelemetry { - public init() {} - - public func record(_: GameReminderEvent) {} -} diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift index a9372905d3..a71f7a0b3e 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/IssueMonitoringFactory.swift @@ -32,14 +32,4 @@ public enum IssueMonitoringFactory { NoopIssueReporter() #endif } - - /// Records game reminder steps through the SDK `IssueMonitoringServiceProtocol.setup()` started; a no-op until - /// then and in Release. - public static func createGameReminderTelemetry() -> GameReminderTelemetry { - #if SENTRY_ENABLED - SentryGameReminderTelemetry() - #else - NoopGameReminderTelemetry() - #endif - } } diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/IssueReporting.swift b/hosts/ios/Packages/IssueMonitoring/Sources/IssueReporting.swift index 508d1bfa31..fb46ae7cd4 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/IssueReporting.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/IssueReporting.swift @@ -1,8 +1,10 @@ import Foundation -/// Sends critical, flow-breaking issues. See `.claude/docs/code/error-handling.md`. +/// Sends critical, flow-breaking issues and structured diagnostic events. +/// See `.claude/docs/code/error-handling.md`. public protocol IssueReporting: Sendable { func report(_ issue: CriticalIssue) + func report(_ event: GameReminderEvent) } /// An error that names its case for reporting without exposing associated values. @@ -30,4 +32,6 @@ public struct NoopIssueReporter: IssueReporting { public init() {} public func report(_: CriticalIssue) {} + + public func report(_: GameReminderEvent) {} } diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift b/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift deleted file mode 100644 index fb760e7169..0000000000 --- a/hosts/ios/Packages/IssueMonitoring/Sources/SentryGameReminderTelemetry.swift +++ /dev/null @@ -1,18 +0,0 @@ -#if SENTRY_ENABLED - import Foundation - import Sentry - - struct SentryGameReminderTelemetry: GameReminderTelemetry { - func record(_ event: GameReminderEvent) { - let fields = event.sentryFields(origin: .current) - let sentryEvent = Event(level: .info) - sentryEvent.message = SentryMessage(formatted: event.name) - sentryEvent.logger = GameReminderEvent.logger - SentrySDK.capture(event: sentryEvent) { scope in - scope.setFingerprint([event.name]) - fields.tags.forEach { scope.setTag(value: $0.value, key: $0.key) } - fields.extras.forEach { scope.setExtra(value: $0.value, key: $0.key) } - } - } - } -#endif diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/SentryIssueReporter.swift b/hosts/ios/Packages/IssueMonitoring/Sources/SentryIssueReporter.swift index cafb76bcfe..01778c7c8d 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/SentryIssueReporter.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/SentryIssueReporter.swift @@ -13,5 +13,17 @@ scope.setContext(value: issue.counters, key: "counters") } } + + func report(_ event: GameReminderEvent) { + let fields = event.fields(origin: .current) + let sentryEvent = Event(level: .info) + sentryEvent.message = SentryMessage(formatted: event.name) + sentryEvent.logger = GameReminderEvent.logger + SentrySDK.capture(event: sentryEvent) { scope in + scope.setFingerprint([event.name]) + fields.tags.forEach { scope.setTag(value: $0.value, key: $0.key) } + fields.extras.forEach { scope.setExtra(value: $0.value, key: $0.key) } + } + } } #endif diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift index 3b04d563ca..a5c644dd76 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift @@ -22,7 +22,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private let settingsManager: SettingsManagerProtocol private let keys: GameReminderStorageKeys private let logger: LoggerProtocol - private let telemetry: GameReminderTelemetry + private let diagnostics: GameReminderDiagnostics private let taskLock = NSLock() private var lastTask: Task? @@ -31,13 +31,13 @@ final class AlarmKitGameReminder: GameStartReminderServicing { settingsManager: SettingsManagerProtocol, keys: GameReminderStorageKeys, logger: LoggerProtocol = Logger.shared, - telemetry: GameReminderTelemetry = NoopGameReminderTelemetry() + diagnostics: GameReminderDiagnostics = GameReminderDiagnostics(reporter: NoopIssueReporter()) ) { self.alarmManger = alarmManger self.settingsManager = settingsManager self.keys = keys self.logger = logger - self.telemetry = telemetry + self.diagnostics = diagnostics } func scheduleReminder(gameDate: Date, productId: ProductId) { @@ -105,7 +105,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { logger.debug("Alarm scheduled \(newAlarmId) for \(fireDate)") var event = GameReminderEvent(step: .scheduled, productId: productId, startsAt: fireDate) event.delivery = .alarm - telemetry.record(event) + diagnostics.record(event) } catch { logger.error("Failed to schedule AlarmKit alarm: \(error)") recordNotScheduled(.failed, gameDate: fireDate, productId: productId) @@ -115,7 +115,7 @@ final class AlarmKitGameReminder: GameStartReminderServicing { private func recordNotScheduled(_ reason: GameReminderEvent.SkipReason, gameDate: Date, productId: ProductId) { var event = GameReminderEvent(step: .notScheduled, productId: productId, startsAt: gameDate) event.skipReason = reason - telemetry.record(event) + diagnostics.record(event) } private func performCancelReminder(alarmId: UUID?) { diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift index 59d909a9a4..69f6bcbe97 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift @@ -10,20 +10,20 @@ final class LocalNotificationGameReminder { private let settingsManager: SettingsManagerProtocol private let keys: GameReminderStorageKeys private let logger: LoggerProtocol - private let telemetry: GameReminderTelemetry + private let diagnostics: GameReminderDiagnostics init( localNotificationService: UserNotificationServicing, settingsManager: SettingsManagerProtocol, keys: GameReminderStorageKeys, logger: LoggerProtocol = Logger.shared, - telemetry: GameReminderTelemetry = NoopGameReminderTelemetry() + diagnostics: GameReminderDiagnostics = GameReminderDiagnostics(reporter: NoopIssueReporter()) ) { self.localNotificationService = localNotificationService self.settingsManager = settingsManager self.keys = keys self.logger = logger - self.telemetry = telemetry + self.diagnostics = diagnostics } } @@ -91,7 +91,7 @@ private extension LocalNotificationGameReminder { var event = GameReminderEvent(step: step, productId: productId, startsAt: gameDate) event.delivery = step == .scheduled ? .notification : nil event.skipReason = skipReason - telemetry.record(event) + diagnostics.record(event) } func cancel() { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index 0f56b5d7f4..aaef96857b 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -30,24 +30,24 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam let applicationStateStreams: ApplicationStateStreamFactory let productOpener: ProductOpening let dateProvider: CurrentDateProviding - let telemetry: GameReminderTelemetry + let diagnostics: GameReminderDiagnostics } static func makeDefault() -> ProductGameReminderCenter { - let telemetry = IssueMonitoringFactory.createGameReminderTelemetry() + let diagnostics = GameReminderDiagnostics.make(reporter: IssueReportingFacade.shared) return ProductGameReminderCenter( dependencies: Dependencies( services: ProductGameReminderServicesFactory( settingsManager: SettingsManager.shared, localNotificationService: UserNotificationService.shared, - telemetry: telemetry + diagnostics: diagnostics ), settingsManager: SettingsManager.shared, applicationState: UIApplication.shared, applicationStateStreams: ApplicationStateStreamFactory(), productOpener: ProductOpener(), dateProvider: NowDateProvider(), - telemetry: telemetry + diagnostics: diagnostics ) ) } @@ -71,7 +71,7 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam private let applicationStateStreams: ApplicationStateStreamFactory private let productOpener: ProductOpening private let dateProvider: CurrentDateProviding - private let telemetry: GameReminderTelemetry + private let diagnostics: GameReminderDiagnostics /// One pair per product: AlarmKit serialises its calls per instance. private var reminders: [ProductId: Reminders] = [:] @@ -88,7 +88,7 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam applicationStateStreams = dependencies.applicationStateStreams productOpener = dependencies.productOpener dateProvider = dependencies.dateProvider - telemetry = dependencies.telemetry + diagnostics = dependencies.diagnostics } /// Held reminders, soonest first. @@ -367,7 +367,7 @@ private extension ProductGameReminderCenter { ) event.appState = GameReminderEvent.AppState(applicationState.applicationState) configure(&event) - telemetry.record(event) + diagnostics.record(event) } func wake(at date: Date, from now: Date) { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift index 012551d488..6c4f3fd602 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift @@ -20,7 +20,7 @@ protocol ProductGameReminderServicesMaking { struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { let settingsManager: SettingsManagerProtocol let localNotificationService: UserNotificationServicing - let telemetry: GameReminderTelemetry + let diagnostics: GameReminderDiagnostics func makeAlarm(for productId: ProductId) -> (any GameStartReminderServicing)? { if #available(iOS 26.1, *) { @@ -28,7 +28,7 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { alarmManger: AlarmManager.shared, settingsManager: settingsManager, keys: .product(productId), - telemetry: telemetry + diagnostics: diagnostics ) } return nil @@ -39,7 +39,7 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { localNotificationService: localNotificationService, settingsManager: settingsManager, keys: .product(productId), - telemetry: telemetry + diagnostics: diagnostics ) } diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderIssueReporter.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderIssueReporter.swift new file mode 100644 index 0000000000..f4d419d47a --- /dev/null +++ b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderIssueReporter.swift @@ -0,0 +1,19 @@ +import Foundation +import IssueMonitoring +import os + +final class RecordingGameReminderIssueReporter: IssueReporting, @unchecked Sendable { + private let state = OSAllocatedUnfairLock<[GameReminderEvent]>(initialState: []) + + var events: [GameReminderEvent] { state.withLock { $0 } } + + func report(_: CriticalIssue) {} + + func report(_ event: GameReminderEvent) { + state.withLock { $0.append(event) } + } + + var diagnostics: GameReminderDiagnostics { + GameReminderDiagnostics(reporter: self) + } +} diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift deleted file mode 100644 index cf6ea5fc73..0000000000 --- a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderTelemetry.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Foundation -import IssueMonitoring -import os - -final class RecordingGameReminderTelemetry: GameReminderTelemetry, @unchecked Sendable { - private let state = OSAllocatedUnfairLock<[GameReminderEvent]>(initialState: []) - - var events: [GameReminderEvent] { state.withLock { $0 } } - - func record(_ event: GameReminderEvent) { - state.withLock { $0.append(event) } - } -} diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift similarity index 63% rename from hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift rename to hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift index d9642abb2a..9ac357b45b 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryTelemetryTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift @@ -5,51 +5,51 @@ import Testing @testable import polkadot_app @MainActor -@Suite("Game reminder delivery telemetry") -struct GameReminderDeliveryTelemetryTests { +@Suite("Game reminder delivery diagnostics") +struct GameReminderDeliveryDiagnosticsTests { private struct ScheduleFailed: Error {} private let productId = "game.dot" private let keys = GameReminderStorageKeys.product("game.dot") @available(iOS 26.1, *) - private func makeAlarm(_ manager: MockGameAlarmManager, _ telemetry: RecordingGameReminderTelemetry) + private func makeAlarm(_ manager: MockGameAlarmManager, _ reporter: RecordingGameReminderIssueReporter) -> AlarmKitGameReminder { AlarmKitGameReminder( alarmManger: manager, settingsManager: InMemorySettingsManager(), keys: keys, - telemetry: telemetry + diagnostics: reporter.diagnostics ) } private func makeNotification( _ service: MockUserNotificationService, - _ telemetry: RecordingGameReminderTelemetry + _ reporter: RecordingGameReminderIssueReporter ) -> LocalNotificationGameReminder { LocalNotificationGameReminder( localNotificationService: service, settingsManager: InMemorySettingsManager(), keys: keys, - telemetry: telemetry + diagnostics: reporter.diagnostics ) } @available(iOS 26.1, *) @Test func alarmBookedOnceIsReportedOnceAsScheduled() async throws { let manager = MockGameAlarmManager() - let telemetry = RecordingGameReminderTelemetry() - let reminder = makeAlarm(manager, telemetry) + let reporter = RecordingGameReminderIssueReporter() + let reminder = makeAlarm(manager, reporter) let startsAt = Date().addingTimeInterval(3_600) reminder.scheduleReminder(gameDate: startsAt, productId: productId) reminder.scheduleReminder(gameDate: startsAt, productId: productId) - try await waitUntil { manager.scheduled.count == 1 && !telemetry.events.isEmpty } + try await waitUntil { manager.scheduled.count == 1 && !reporter.events.isEmpty } try await Task.sleep(for: .milliseconds(50)) - #expect(telemetry.events.map(\.step) == [.scheduled]) - #expect(telemetry.events.first?.delivery == .alarm) - #expect(telemetry.events.first?.startsAt == startsAt) + #expect(reporter.events.map(\.step) == [.scheduled]) + #expect(reporter.events.first?.delivery == .alarm) + #expect(reporter.events.first?.startsAt == startsAt) } @Test( @@ -70,27 +70,27 @@ struct GameReminderDeliveryTelemetryTests { let manager = MockGameAlarmManager() manager.isAuthorized = authorized manager.scheduleError = fails ? ScheduleFailed() : nil - let telemetry = RecordingGameReminderTelemetry() + let reporter = RecordingGameReminderIssueReporter() // The reminder holds itself only weakly while its work is queued. - let reminder = makeAlarm(manager, telemetry) + let reminder = makeAlarm(manager, reporter) reminder.scheduleReminder(gameDate: Date().addingTimeInterval(secondsToStart), productId: productId) - try await waitUntil { !telemetry.events.isEmpty } + try await waitUntil { !reporter.events.isEmpty } - #expect(telemetry.events.map(\.step) == [.notScheduled]) - #expect(telemetry.events.first?.skipReason == reason) - #expect(telemetry.events.first?.delivery == nil) + #expect(reporter.events.map(\.step) == [.notScheduled]) + #expect(reporter.events.first?.skipReason == reason) + #expect(reporter.events.first?.delivery == nil) } @Test func notificationBookedIsReportedAsScheduled() async throws { - let telemetry = RecordingGameReminderTelemetry() + let reporter = RecordingGameReminderIssueReporter() - makeNotification(MockUserNotificationService(), telemetry) + makeNotification(MockUserNotificationService(), reporter) .scheduleReminder(gameDate: Date().addingTimeInterval(3_600), productId: productId) - try await waitUntil { !telemetry.events.isEmpty } + try await waitUntil { !reporter.events.isEmpty } - #expect(telemetry.events.map(\.step) == [.scheduled]) - #expect(telemetry.events.first?.delivery == .notification) + #expect(reporter.events.map(\.step) == [.scheduled]) + #expect(reporter.events.first?.delivery == .notification) } @Test( @@ -110,16 +110,16 @@ struct GameReminderDeliveryTelemetryTests { let service = MockUserNotificationService() service.accessStatus = allowed ? .allowed : .notAllowed(denied: true) service.scheduleError = fails ? ScheduleFailed() : nil - let telemetry = RecordingGameReminderTelemetry() + let reporter = RecordingGameReminderIssueReporter() - makeNotification(service, telemetry).scheduleReminder( + makeNotification(service, reporter).scheduleReminder( gameDate: Date().addingTimeInterval(secondsToStart), productId: productId ) - try await waitUntil { !telemetry.events.isEmpty } + try await waitUntil { !reporter.events.isEmpty } - #expect(telemetry.events.map(\.step) == [.notScheduled]) - #expect(telemetry.events.first?.skipReason == reason) - #expect(telemetry.events.first?.delivery == nil) + #expect(reporter.events.map(\.step) == [.notScheduled]) + #expect(reporter.events.first?.skipReason == reason) + #expect(reporter.events.first?.delivery == nil) } } diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift index 8128bc1f8b..58c8bd4850 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift @@ -22,7 +22,7 @@ struct GameReminderEventTests { event.appState = .inactive event.navigated = true - let fields = event.sentryFields(origin: origin) + let fields = event.fields(origin: origin) #expect(event.name == "host.reminder.product_opened") #expect(GameReminderEvent.logger == "host.telemetry") @@ -36,7 +36,7 @@ struct GameReminderEventTests { @Test func eventCarriesTheCanonicalHostFields() { let fields = GameReminderEvent(step: .scheduled, productId: "game.dot", startsAt: startsAt) - .sentryFields(origin: origin) + .fields(origin: origin) #expect(fields.tags["host.platform"] == "ios") #expect(fields.tags["host.os_version"] == "26.5") @@ -50,7 +50,7 @@ struct GameReminderEventTests { event.delivery = .alarm event.skipReason = .notAuthorized - let fields = event.sentryFields(origin: origin) + let fields = event.fields(origin: origin) #expect(event.name == "host.reminder.not_scheduled") #expect(fields.tags["reminder.delivery"] == "alarm") @@ -59,7 +59,7 @@ struct GameReminderEventTests { @Test func absentValuesAreOmittedInsteadOfInvented() { let fields = GameReminderEvent(step: .tapped, productId: "game.dot", startsAt: nil) - .sentryFields(origin: HostEventOrigin(launchID: UUID(), osVersion: "26.5", appVersion: nil, appBuild: nil)) + .fields(origin: HostEventOrigin(launchID: UUID(), osVersion: "26.5", appVersion: nil, appBuild: nil)) #expect(fields.extras["reminder.starts_at_unix_ms"] == nil) #expect(fields.extras["reminder.delay_after_start_ms"] == nil) diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index 76eadfc327..74d0cd9e01 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -29,7 +29,7 @@ struct ProductGameReminderCenterTests { applicationStateStreams: ApplicationStateStreamFactory(), productOpener: fakes, dateProvider: fakes, - telemetry: fakes.telemetry + diagnostics: fakes.diagnostics ) ) center.start() @@ -240,9 +240,9 @@ struct ProductGameReminderCenterTests { center.cancel(productId: other) center.cancel(productId: productId) - #expect(fakes.telemetry.events.map(\.step) == [.cancelled]) - #expect(fakes.telemetry.events.first?.productId == productId) - #expect(fakes.telemetry.events.first?.startsAt == startsAt) + #expect(fakes.reporter.events.map(\.step) == [.cancelled]) + #expect(fakes.reporter.events.first?.productId == productId) + #expect(fakes.reporter.events.first?.startsAt == startsAt) } @Test("Opening at the start is reported as an auto-open with the app state and whether it navigated") @@ -255,8 +255,8 @@ struct ProductGameReminderCenterTests { fakes.now = startsAt.addingTimeInterval(2) center.refresh() - let event = try #require(fakes.telemetry.events.last) - #expect(fakes.telemetry.events.map(\.step) == [.productOpened]) + let event = try #require(fakes.reporter.events.last) + #expect(fakes.reporter.events.map(\.step) == [.productOpened]) #expect(event.openSource == .autoOpen) #expect(event.appState == .inactive) #expect(event.navigated == false) @@ -272,8 +272,8 @@ struct ProductGameReminderCenterTests { fakes.now = startsAt.addingTimeInterval(600) center.refresh() - let event = try #require(fakes.telemetry.events.last) - #expect(fakes.telemetry.events.map(\.step) == [.missed]) + let event = try #require(fakes.reporter.events.last) + #expect(fakes.reporter.events.map(\.step) == [.missed]) #expect(event.appState == .background) #expect(event.occurredAt == startsAt.addingTimeInterval(600)) #expect(fakes.opened.isEmpty) @@ -295,9 +295,9 @@ struct ProductGameReminderCenterTests { for alarmIds: Set in [[UUID()], [alarmId], [alarmId], [otherAlarmId]] { fakes.alarmAlerts.yield(alarmIds) } - try await waitUntil { fakes.telemetry.events.count == 2 } + try await waitUntil { fakes.reporter.events.count == 2 } - let events = fakes.telemetry.events + let events = fakes.reporter.events #expect(events.map(\.step) == [.fired, .fired]) #expect(events.map(\.productId) == [productId, other]) #expect(events.first?.delivery == .alarm) @@ -314,7 +314,7 @@ struct ProductGameReminderCenterTests { fakes.now = startsAt.addingTimeInterval(600) center.refresh() - let events = fakes.telemetry.events + let events = fakes.reporter.events #expect(events.map(\.step) == [.tapped, .productOpened]) #expect(events.last?.openSource == .alarm) #expect(events.last?.navigated == true) @@ -330,8 +330,8 @@ struct ProductGameReminderCenterTests { center.openFromAlarm(productId: productId, startsAt: nil) - #expect(fakes.telemetry.events.map(\.step) == [.tapped, .productOpened]) - #expect(fakes.telemetry.events.first?.startsAt == nil) + #expect(fakes.reporter.events.map(\.step) == [.tapped, .productOpened]) + #expect(fakes.reporter.events.first?.startsAt == nil) #expect(fakes.opened == [productId]) } @@ -366,7 +366,8 @@ private final class Fakes: @unchecked Sendable { var opened: [String] = [] var openNavigates = true let calendar = RecordingCalendar() - let telemetry = RecordingGameReminderTelemetry() + let reporter = RecordingGameReminderIssueReporter() + var diagnostics: GameReminderDiagnostics { reporter.diagnostics } let alarmAlerts: AsyncStream>.Continuation private let alarmAlertStream: AsyncStream> private let alarmAvailable: Bool From 495593605ef766156c8e9fa10bf1f1b65b6d7071 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:23:49 +0100 Subject: [PATCH 17/22] fix(ios): drop stale reminder on past replacement --- .../Products/ProductGameReminderCenter.swift | 4 ++++ .../ProductGameReminderCenterTests.swift | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index 48dd65d04d..4dfb34d7f4 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -131,6 +131,10 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam calendarStartsAt: slot(for: productId)?.calendarStartsAt ) guard next.startsAt > dateProvider.now else { + if slot(for: productId) != nil { + drop(productId) + refresh() + } record(.notScheduled, for: next) { $0.skipReason = .startInPast } return } diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index b4be528063..621ec309f0 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -113,6 +113,22 @@ struct ProductGameReminderCenterTests { #expect(center.slots.isEmpty) } + @Test("A past replacement drops the product's held future reminder") + func pastStartDropsHeldFutureReminder() async { + let heldStart = startsAt.addingTimeInterval(600) + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) + await center.schedule(productId: productId, startsAt: heldStart, addCalendarEvent: false) + fakes.now = startsAt.addingTimeInterval(60) + + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + #expect(fakes.reporter.events.map(\.step) == [.notScheduled]) + #expect(fakes.reporter.events.first?.skipReason == .startInPast) + #expect(fakes.alarm(for: productId).cancelCount == 1) + #expect(center.slots.isEmpty) + #expect(center.pill == nil) + } + @Test("With the calendar flag and a start exactly an hour away, one calendar event is added") func addsCalendarEventOnce() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) From f0f9db6b2eb376722256a66758e18b68e7e6edb8 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Tue, 27 Oct 2026 13:12:49 +0100 Subject: [PATCH 18/22] fix(ios): match reminder recorder autoclosure --- .../Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift index 4651951ecc..94a8011a7d 100644 --- a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift +++ b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift @@ -44,7 +44,8 @@ final class RecordingGameReminderHostEventRecorder: HostEventRecording, @uncheck var events: [RecordedGameReminderEvent] { state.withLock { $0 } } - func record(_ event: HostEvent) { + func record(_ event: @autoclosure () -> HostEvent) { + let event = event() state.withLock { $0.append(RecordedGameReminderEvent(event)) } } From 2fc760ecf1191eb60cee95857f6eab391ac6470f Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:53:33 +0100 Subject: [PATCH 19/22] fix(ios): trim game reminder telemetry to diagnostics plumbing and share the host event fields Drop the missed and past-start events, record product_opened only when the open navigated, gate AlarmKit lookups on reporting support, move the reminder event types into the app target, add HostEvent.make for the shared fields, and build events lazily through an autoclosure. --- README.md | 6 +- .../IssueMonitoring/Sources/HostEvent.swift | 63 ++++----- hosts/ios/Packages/Products/Package.swift | 2 - .../GameReminderDiagnostics.swift | 20 --- .../GameReminder/AlarmKitGameReminder.swift | 13 +- .../GameReminder/GameAlarmStopIntent.swift | 42 ++++-- .../GameReminderDiagnostics.swift | 24 ++++ .../GameReminderEvent+AppState.swift | 17 --- .../GameReminder/GameReminderEvent.swift | 83 +++++++----- .../LocalNotificationGameReminder.swift | 13 +- .../Products/ProductGameReminderCenter.swift | 58 +++----- .../ProductGameReminderServicesFactory.swift | 7 +- ...cordingGameReminderHostEventRecorder.swift | 64 --------- .../polkadot-appTests/Helpers/WaitUntil.swift | 14 +- .../CoreDataPocketCardStoreTests.swift | 7 - ...GameReminderDeliveryDiagnosticsTests.swift | 49 +++---- .../Products/GameReminderEventTests.swift | 117 ++++++++-------- .../GameReminderDiagnostics+Recording.swift | 50 +++++++ .../Mocks/ReadCountingSettingsManager.swift | 27 ++++ .../ProductGameReminderCenterTests.swift | 126 +++++++++--------- 20 files changed, 404 insertions(+), 398 deletions(-) delete mode 100644 hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderDiagnostics.swift create mode 100644 hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderDiagnostics.swift delete mode 100644 hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift rename hosts/ios/{Packages/Products/Sources/Products => polkadot-app/Common/Services}/GameReminder/GameReminderEvent.swift (52%) delete mode 100644 hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/GameReminderDiagnostics+Recording.swift create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/ReadCountingSettingsManager.swift diff --git a/README.md b/README.md index 6bcd24fdde..2076bbc3ee 100644 --- a/README.md +++ b/README.md @@ -417,9 +417,9 @@ playground's Diagnosis, since dot.li serves no `Game` surface. Both CLI host roles still install an in-memory `CliGameHost` that never rings anything. The `truapi` runtime tests cover the product gate, the start-time check, a call withdrawn before it reaches the host, the absence of any permission prompt, and -host failures. Android hosts with Sentry enabled report the reminder lifecycle -through `host.reminder.*` Sentry events without changing reminder delivery or -cleanup. +host failures. Android and iOS hosts with Sentry enabled report the reminder +lifecycle through `host.reminder.*` Sentry events without changing reminder +delivery or cleanup. `Scanner` (`scan`) opens the host's own QR and barcode viewfinder (RFC "Host-drawn scanner"). The Android app draws it. Both CLI host roles answer diff --git a/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift b/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift index fb385b0f90..b68c8fa2ee 100644 --- a/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift +++ b/hosts/ios/Packages/IssueMonitoring/Sources/HostEvent.swift @@ -20,21 +20,41 @@ public struct HostEvent: Sendable, Equatable { self.extras = extras } + /// An event carrying the fields every host event shares; `tags` and `extras` add its own. + public static func make( + name: String, + origin: HostEventOrigin, + monotonicMilliseconds: Int, + tags: [String: String] = [:], + extras: [String: HostEventValue] = [:] + ) -> HostEvent { + var sharedTags = tags + sharedTags["host.app_variant"] = origin.appVariant + sharedTags["host.platform"] = "ios" + sharedTags["host.native_app_version"] = origin.appVersion + + var sharedExtras = extras + sharedExtras["host.app_build"] = origin.appBuild.map(HostEventValue.integer) + sharedExtras["event.monotonic_ms"] = .integer(monotonicMilliseconds) + + return HostEvent(name: name, tags: sharedTags, extras: sharedExtras) + } + /// The event for the app process finishing launch. public static func processStarted( origin: HostEventOrigin, truapiHostVersion: String, monotonicMilliseconds: Int ) -> HostEvent { - HostEvent( + make( name: "host.process.started", - tags: tags(origin: origin, truapiHostVersion: truapiHostVersion).merging([ + origin: origin, + monotonicMilliseconds: monotonicMilliseconds, + tags: [ + "truapi.host_version": truapiHostVersion, "host.os_version": origin.osVersion - ]) { _, value in value }, - extras: appMetadata(origin: origin).merging([ - "app.launch_id": .string(origin.launchID.uuidString.lowercased()), - "event.monotonic_ms": .integer(monotonicMilliseconds) - ]) { _, value in value } + ], + extras: ["app.launch_id": .string(origin.launchID.uuidString.lowercased())] ) } @@ -45,31 +65,12 @@ public struct HostEvent: Sendable, Equatable { truapiHostVersion: String, monotonicMilliseconds: Int ) -> HostEvent { - HostEvent( + make( name: "host.webview.created", - tags: tags(origin: origin, truapiHostVersion: truapiHostVersion), - extras: appMetadata(origin: origin).merging([ - "host.session_id": .string(hostSessionID.uuidString.lowercased()), - "event.monotonic_ms": .integer(monotonicMilliseconds) - ]) { _, value in value } + origin: origin, + monotonicMilliseconds: monotonicMilliseconds, + tags: ["truapi.host_version": truapiHostVersion], + extras: ["host.session_id": .string(hostSessionID.uuidString.lowercased())] ) } } - -private extension HostEvent { - static func tags(origin: HostEventOrigin, truapiHostVersion: String) -> [String: String] { - var tags = [ - "host.app_variant": origin.appVariant, - "host.platform": "ios", - "truapi.host_version": truapiHostVersion - ] - tags["host.native_app_version"] = origin.appVersion - return tags - } - - static func appMetadata(origin: HostEventOrigin) -> [String: HostEventValue] { - var extras: [String: HostEventValue] = [:] - extras["host.app_build"] = origin.appBuild.map(HostEventValue.integer) - return extras - } -} diff --git a/hosts/ios/Packages/Products/Package.swift b/hosts/ios/Packages/Products/Package.swift index 0f4ae5aa27..5f3d5adc94 100644 --- a/hosts/ios/Packages/Products/Package.swift +++ b/hosts/ios/Packages/Products/Package.swift @@ -47,7 +47,6 @@ let package = Package( ), .package(path: "../StatementStore"), .package(path: "../Individuality"), - .package(path: "../IssueMonitoring"), .package(path: "../UIKitExt"), .package(path: "../AssetsManagement"), .package(path: "../ChainStore"), @@ -72,7 +71,6 @@ let package = Package( .product(name: "AsyncExtensions", package: "AsyncExtensions"), .product(name: "StatementStore", package: "StatementStore"), "Individuality", - "IssueMonitoring", "UIKitExt", .product(name: "AssetsManagement", package: "AssetsManagement"), .product(name: "ChainStore", package: "ChainStore"), diff --git a/hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderDiagnostics.swift b/hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderDiagnostics.swift deleted file mode 100644 index 7ef0b7d704..0000000000 --- a/hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderDiagnostics.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -import IssueMonitoring - -/// Records the troubleshooting trail for a product's game reminder through the shared host-event path. -public struct GameReminderDiagnostics: Sendable { - private let recorder: any HostEventRecording - private let origin: HostEventOrigin - - public init( - recorder: any HostEventRecording = NoopHostEventRecorder(), - origin: HostEventOrigin = .current - ) { - self.recorder = recorder - self.origin = origin - } - - public func record(_ event: GameReminderEvent) { - recorder.record(event.hostEvent(origin: origin)) - } -} diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift index a1031b1dcb..8f695fdcc4 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/AlarmKitGameReminder.swift @@ -102,9 +102,9 @@ final class AlarmKitGameReminder: GameStartReminderServicing { settingsManager.set(string: newAlarmId.uuidString, for: keys.alarmId) settingsManager.set(value: Int(fireDate.timeIntervalSinceReferenceDate), for: keys.alarmFireDate) logger.debug("Alarm scheduled \(newAlarmId) for \(fireDate)") - var event = GameReminderEvent(step: .scheduled, productId: productId, startsAt: fireDate) - event.delivery = .alarm - diagnostics.record(event) + diagnostics.record( + GameReminderEvent(step: .scheduled, productId: productId, startsAt: fireDate, delivery: .alarm) + ) } catch { logger.error("Failed to schedule AlarmKit alarm: \(error)") recordNotScheduled(.failed, gameDate: fireDate, productId: productId) @@ -112,9 +112,9 @@ final class AlarmKitGameReminder: GameStartReminderServicing { } private func recordNotScheduled(_ reason: GameReminderEvent.SkipReason, gameDate: Date, productId: ProductId) { - var event = GameReminderEvent(step: .notScheduled, productId: productId, startsAt: gameDate) - event.skipReason = reason - diagnostics.record(event) + diagnostics.record( + GameReminderEvent(step: .notScheduled, productId: productId, startsAt: gameDate, skipReason: reason) + ) } private func performCancelReminder(alarmId: UUID?) { @@ -153,7 +153,6 @@ final class AlarmKitGameReminder: GameStartReminderServicing { let playIntent = GameAlarmPlayIntent() playIntent.alarmID = newAlarmId.uuidString playIntent.productId = productId - playIntent.startsAt = date try await alarmManger.scheduleGameAlarm(id: newAlarmId, at: date, playIntent: playIntent) } diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift index ba98a22a5c..1af042ee74 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameAlarmStopIntent.swift @@ -2,7 +2,7 @@ import Foundation import AlarmKit import AppIntents import IssueMonitoring -import Products +import Keystore_iOS import UIKit @available(iOS 26.1, *) @@ -16,23 +16,31 @@ struct GameAlarmPlayIntent: LiveActivityIntent { @Parameter(title: "Product ID") var productId: String? - @Parameter(title: "Starts At") - var startsAt: Date? - @MainActor func perform() async throws -> some IntentResult { if let productId { let diagnostics = GameReminderDiagnostics(recorder: IssueMonitoringFactory.createHostEventRecorder()) - var tapped = GameReminderEvent(step: .tapped, productId: productId, startsAt: startsAt) - tapped.appState = .init(UIApplication.shared.applicationState) - diagnostics.record(tapped) - - let navigated = ProductOpener().open(productId: productId) - var opened = GameReminderEvent(step: .productOpened, productId: productId, startsAt: startsAt) - opened.openSource = .alarm - opened.appState = .init(UIApplication.shared.applicationState) - opened.navigated = navigated - diagnostics.record(opened) + let startsAt = { storedStartsAt(productId: productId) } + diagnostics.record( + GameReminderEvent( + step: .tapped, + productId: productId, + startsAt: startsAt(), + appState: .init(UIApplication.shared.applicationState) + ) + ) + + if ProductOpener().open(productId: productId) { + diagnostics.record( + GameReminderEvent( + step: .productOpened, + productId: productId, + startsAt: startsAt(), + openSource: .alarm, + appState: .init(UIApplication.shared.applicationState) + ) + ) + } } if let alarmIDString = alarmID, @@ -42,4 +50,10 @@ struct GameAlarmPlayIntent: LiveActivityIntent { return .result() } + + /// The fire date the alarm was booked with, read from the settings that booking persisted. + private func storedStartsAt(productId: String) -> Date? { + SettingsManager.shared.integer(for: GameReminderStorageKeys.product(productId).alarmFireDate) + .map { Date(timeIntervalSinceReferenceDate: TimeInterval($0)) } + } } diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderDiagnostics.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderDiagnostics.swift new file mode 100644 index 0000000000..0873048582 --- /dev/null +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderDiagnostics.swift @@ -0,0 +1,24 @@ +import Foundation +import IssueMonitoring + +/// Records the troubleshooting trail for a product's game reminder through the shared host-event path. +struct GameReminderDiagnostics: Sendable { + private let recorder: any HostEventRecording + private let origin: HostEventOrigin + private let monotonicMilliseconds: @Sendable () -> Int + + init( + recorder: any HostEventRecording = NoopHostEventRecorder(), + origin: HostEventOrigin = .current, + monotonicMilliseconds: @escaping @Sendable () -> Int = HostEventClock.monotonicMilliseconds + ) { + self.recorder = recorder + self.origin = origin + self.monotonicMilliseconds = monotonicMilliseconds + } + + /// The event is built only when the recorder keeps it. + func record(_ event: @autoclosure () -> GameReminderEvent) { + recorder.record(event().hostEvent(origin: origin, monotonicMilliseconds: monotonicMilliseconds())) + } +} diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift deleted file mode 100644 index abcf111752..0000000000 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent+AppState.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Products -import UIKit - -extension GameReminderEvent.AppState { - init?(_ state: UIApplication.State) { - switch state { - case .active: - self = .active - case .inactive: - self = .inactive - case .background: - self = .background - @unknown default: - return nil - } - } -} diff --git a/hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderEvent.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift similarity index 52% rename from hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderEvent.swift rename to hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift index cd57f6232d..e3874fc199 100644 --- a/hosts/ios/Packages/Products/Sources/Products/GameReminder/GameReminderEvent.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift @@ -1,89 +1,108 @@ import Foundation import IssueMonitoring +import UIKit /// One step of a product's game reminder, from booking the alarm to opening the product. Each step names /// the product and the game start, so a missed game can be followed through the host. -public struct GameReminderEvent: Sendable, Equatable { - public enum Step: String, Sendable { +struct GameReminderEvent: Equatable { + enum Step: String { case scheduled case notScheduled = "not_scheduled" case fired case tapped case productOpened = "product_opened" - case missed case cancelled } - public enum Delivery: String, Sendable { + enum Delivery: String { case alarm case notification } - public enum SkipReason: String, Sendable { + enum SkipReason: String { case notAuthorized = "not_authorized" case startInPast = "start_in_past" case failed } - public enum OpenSource: String, Sendable { + enum OpenSource: String { case autoOpen = "auto_open" case alarm } - public enum AppState: String, Sendable { + enum AppState: String { case active case inactive case background + + init?(_ state: UIApplication.State) { + switch state { + case .active: + self = .active + case .inactive: + self = .inactive + case .background: + self = .background + @unknown default: + return nil + } + } } - public let step: Step - public let productId: String + let step: Step + let productId: String /// Nil only for an alarm booked before the start was attached to it. - public let startsAt: Date? - public let occurredAt: Date - public var delivery: Delivery? - public var skipReason: SkipReason? - public var openSource: OpenSource? - public var appState: AppState? - /// False when opening found the product already on screen. - public var navigated: Bool? + let startsAt: Date? + let occurredAt: Date + let delivery: Delivery? + let skipReason: SkipReason? + let openSource: OpenSource? + let appState: AppState? - public init(step: Step, productId: String, startsAt: Date?, occurredAt: Date = Date()) { + init( + step: Step, + productId: String, + startsAt: Date?, + occurredAt: Date = Date(), + delivery: Delivery? = nil, + skipReason: SkipReason? = nil, + openSource: OpenSource? = nil, + appState: AppState? = nil + ) { self.step = step self.productId = productId self.startsAt = startsAt self.occurredAt = occurredAt - } - - public var name: String { - "host.reminder.\(step.rawValue)" + self.delivery = delivery + self.skipReason = skipReason + self.openSource = openSource + self.appState = appState } /// Times are device wall clock. The alarm rings at the start, so `delay_after_start_ms` is also how /// late a step came against the alarm's fire time. - public func hostEvent(origin: HostEventOrigin) -> HostEvent { + func hostEvent(origin: HostEventOrigin, monotonicMilliseconds: Int) -> HostEvent { var tags = [ - "host.app_variant": origin.appVariant, - "host.platform": "ios", "host.os_version": origin.osVersion, "reminder.product_id": productId ] - tags["host.native_app_version"] = origin.appVersion tags["reminder.delivery"] = delivery?.rawValue tags["reminder.skip_reason"] = skipReason?.rawValue tags["reminder.open_source"] = openSource?.rawValue tags["host.app_state"] = appState?.rawValue - var extras: [String: HostEventValue] = [ - "app.launch_id": .string(origin.launchID.uuidString.lowercased()) - ] - extras["host.app_build"] = origin.appBuild.map(HostEventValue.integer) - extras["reminder.navigated"] = navigated.map(HostEventValue.boolean) + var extras = ["app.launch_id": HostEventValue.string(origin.launchID.uuidString.lowercased())] if let startsAt { extras["reminder.starts_at_unix_ms"] = .integer(Int(startsAt.timeIntervalSince1970 * 1_000)) extras["reminder.delay_after_start_ms"] = .integer(Int(occurredAt.timeIntervalSince(startsAt) * 1_000)) } - return HostEvent(name: name, tags: tags, extras: extras) + return .make( + name: "host.reminder.\(step.rawValue)", + origin: origin, + monotonicMilliseconds: monotonicMilliseconds, + tags: tags, + extras: extras + ) } } diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift index bdfb0942c8..b39e92af42 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/LocalNotificationGameReminder.swift @@ -87,10 +87,15 @@ private extension LocalNotificationGameReminder { productId: ProductId, skipReason: GameReminderEvent.SkipReason? = nil ) { - var event = GameReminderEvent(step: step, productId: productId, startsAt: gameDate) - event.delivery = step == .scheduled ? .notification : nil - event.skipReason = skipReason - diagnostics.record(event) + diagnostics.record( + GameReminderEvent( + step: step, + productId: productId, + startsAt: gameDate, + delivery: step == .scheduled ? .notification : nil, + skipReason: skipReason + ) + ) } func cancel() { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index 4dfb34d7f4..5942b01f83 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -130,14 +130,6 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam ringAlarm: ringAlarm, calendarStartsAt: slot(for: productId)?.calendarStartsAt ) - guard next.startsAt > dateProvider.now else { - if slot(for: productId) != nil { - drop(productId) - refresh() - } - record(.notScheduled, for: next) { $0.skipReason = .startInPast } - return - } deliver(next) refresh() @@ -176,11 +168,11 @@ final class ProductGameReminderCenter: ProductGameReminderScheduling, ProductGam continue } drop(slot.productId) - if isLate { - record(.missed, for: slot) - } else if !opened { + if !isLate, !opened { opened = true - recordAutoOpen(slot, navigated: productOpener.open(productId: slot.productId)) + if productOpener.open(productId: slot.productId) { + record(.productOpened, for: slot, openSource: .autoOpen) + } } continue } @@ -322,6 +314,9 @@ private extension ProductGameReminderCenter { } func recordFired(_ alarmIds: Set) { + guard !alarmIds.isEmpty else { + return + } for slot in slots { guard let alarmId = settingsManager.string(for: GameReminderStorageKeys.product(slot.productId).alarmId) .flatMap(UUID.init(uuidString:)), @@ -329,40 +324,27 @@ private extension ProductGameReminderCenter { firedAlarmIds.insert(alarmId).inserted else { continue } - record(.fired, for: slot) { $0.delivery = .alarm } - } - } - - func recordAutoOpen(_ slot: Slot, navigated: Bool) { - record(.productOpened, for: slot) { event in - event.openSource = .autoOpen - event.navigated = navigated + record(.fired, for: slot, delivery: .alarm) } } func record( _ step: GameReminderEvent.Step, for slot: Slot, - configure: (inout GameReminderEvent) -> Void = { _ in } + delivery: GameReminderEvent.Delivery? = nil, + openSource: GameReminderEvent.OpenSource? = nil ) { - record(step, productId: slot.productId, startsAt: slot.startsAt, configure: configure) - } - - func record( - _ step: GameReminderEvent.Step, - productId: ProductId, - startsAt: Date?, - configure: (inout GameReminderEvent) -> Void = { _ in } - ) { - var event = GameReminderEvent( - step: step, - productId: productId, - startsAt: startsAt, - occurredAt: dateProvider.now + diagnostics.record( + GameReminderEvent( + step: step, + productId: slot.productId, + startsAt: slot.startsAt, + occurredAt: dateProvider.now, + delivery: delivery, + openSource: openSource, + appState: GameReminderEvent.AppState(applicationState.applicationState) + ) ) - event.appState = GameReminderEvent.AppState(applicationState.applicationState) - configure(&event) - diagnostics.record(event) } func wake(at date: Date, from now: Date) { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift index 07aeb6a201..7a24f5e91e 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderServicesFactory.swift @@ -13,7 +13,7 @@ protocol ProductGameReminderServicesMaking { /// Built at add time, so its store sees the current calendar grant. func makeCalendar() -> any GameCalendarServicing /// The ids of the alarms ringing now, each time AlarmKit reports a change while the app runs. Never yields - /// without AlarmKit. + /// without AlarmKit or a build that reports. func alertingAlarms() -> AsyncStream> /// The alarms already ringing when the app refreshes its stored reminders. func currentAlertingAlarmIds() -> Set @@ -50,7 +50,7 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { } func alertingAlarms() -> AsyncStream> { - guard #available(iOS 26.1, *) else { + guard IssueMonitoringFactory.isReportingSupported, #available(iOS 26.1, *) else { return AsyncStream { $0.finish() } } let updates = AlarmManager.shared.alarmUpdates @@ -66,7 +66,8 @@ struct ProductGameReminderServicesFactory: ProductGameReminderServicesMaking { } func currentAlertingAlarmIds() -> Set { - guard #available(iOS 26.1, *), + guard IssueMonitoringFactory.isReportingSupported, + #available(iOS 26.1, *), let alarms = try? AlarmManager.shared.alarms else { return [] } diff --git a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift b/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift deleted file mode 100644 index 94a8011a7d..0000000000 --- a/hosts/ios/polkadot-appTests/Helpers/Mocks/RecordingGameReminderHostEventRecorder.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import IssueMonitoring -import Products -import os - -struct RecordedGameReminderEvent { - let step: GameReminderEvent.Step - let productId: String - let startsAt: Date? - let occurredAt: Date? - let delivery: GameReminderEvent.Delivery? - let skipReason: GameReminderEvent.SkipReason? - let openSource: GameReminderEvent.OpenSource? - let appState: GameReminderEvent.AppState? - let navigated: Bool? - - init(_ event: HostEvent) { - step = GameReminderEvent.Step(rawValue: String(event.name.dropFirst("host.reminder.".count)))! - productId = event.tags["reminder.product_id"]! - startsAt = event.extras["reminder.starts_at_unix_ms"].flatMap { value in - guard case let .integer(milliseconds) = value else { return nil } - return Date(timeIntervalSince1970: Double(milliseconds) / 1_000) - } - occurredAt = startsAt.flatMap { startsAt in - event.extras["reminder.delay_after_start_ms"].flatMap { value in - guard case let .integer(milliseconds) = value else { return nil } - return startsAt.addingTimeInterval(Double(milliseconds) / 1_000) - } - } - delivery = event.tags["reminder.delivery"].flatMap(GameReminderEvent.Delivery.init(rawValue:)) - skipReason = event.tags["reminder.skip_reason"].flatMap(GameReminderEvent.SkipReason.init(rawValue:)) - openSource = event.tags["reminder.open_source"].flatMap(GameReminderEvent.OpenSource.init(rawValue:)) - appState = event.tags["host.app_state"].flatMap(GameReminderEvent.AppState.init(rawValue:)) - if case let .boolean(value) = event.extras["reminder.navigated"] { - navigated = value - } else { - navigated = nil - } - } -} - -final class RecordingGameReminderHostEventRecorder: HostEventRecording, @unchecked Sendable { - private let state = OSAllocatedUnfairLock<[RecordedGameReminderEvent]>(initialState: []) - - var events: [RecordedGameReminderEvent] { state.withLock { $0 } } - - func record(_ event: @autoclosure () -> HostEvent) { - let event = event() - state.withLock { $0.append(RecordedGameReminderEvent(event)) } - } - - var diagnostics: GameReminderDiagnostics { - GameReminderDiagnostics( - recorder: self, - origin: HostEventOrigin( - launchID: UUID(), - osVersion: "test", - appVersion: nil, - appBuild: nil, - appVariant: "test" - ) - ) - } -} diff --git a/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift b/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift index 1462422b60..4b84a37936 100644 --- a/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift +++ b/hosts/ios/polkadot-appTests/Helpers/WaitUntil.swift @@ -1,14 +1,8 @@ import Foundation -struct WaitTimedOut: Error {} - -/// Polls `condition` for work that finishes on a task the test cannot await. -@MainActor -func waitUntil(_ condition: @MainActor () -> Bool) async throws { - for _ in 0 ..< 200 where !condition() { - try await Task.sleep(for: .milliseconds(10)) - } - guard condition() else { - throw WaitTimedOut() +func waitUntil(_ condition: () async -> Bool) async throws { + for _ in 0 ..< 50 { + if await condition() { return } + try await Task.sleep(for: .milliseconds(50)) } } diff --git a/hosts/ios/polkadot-appTests/Products/CoreDataPocketCardStoreTests.swift b/hosts/ios/polkadot-appTests/Products/CoreDataPocketCardStoreTests.swift index d542450121..4d09b68363 100644 --- a/hosts/ios/polkadot-appTests/Products/CoreDataPocketCardStoreTests.swift +++ b/hosts/ios/polkadot-appTests/Products/CoreDataPocketCardStoreTests.swift @@ -256,10 +256,3 @@ private actor Seen { all.append(cards) } } - -private func waitUntil(_ condition: () async -> Bool) async throws { - for _ in 0 ..< 50 { - if await condition() { return } - try await Task.sleep(for: .milliseconds(50)) - } -} diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift index a436c59fce..30f35ae043 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift @@ -1,6 +1,6 @@ import Foundation +import IssueMonitoring import Keystore_iOS -import Products import Testing @testable import polkadot_app @@ -13,44 +13,41 @@ struct GameReminderDeliveryDiagnosticsTests { private let keys = GameReminderStorageKeys.product("game.dot") @available(iOS 26.1, *) - private func makeAlarm(_ manager: MockGameAlarmManager, _ recorder: RecordingGameReminderHostEventRecorder) + private func makeAlarm(_ manager: MockGameAlarmManager, _ recorder: RecordingHostEventRecorder) -> AlarmKitGameReminder { AlarmKitGameReminder( alarmManger: manager, settingsManager: InMemorySettingsManager(), keys: keys, - diagnostics: recorder.diagnostics + diagnostics: .recording(into: recorder) ) } private func makeNotification( _ service: MockUserNotificationService, - _ recorder: RecordingGameReminderHostEventRecorder + _ recorder: RecordingHostEventRecorder ) -> LocalNotificationGameReminder { LocalNotificationGameReminder( localNotificationService: service, settingsManager: InMemorySettingsManager(), keys: keys, - diagnostics: recorder.diagnostics + diagnostics: .recording(into: recorder) ) } @available(iOS 26.1, *) - @Test func alarmBookedOnceIsReportedOnceAsScheduled() async throws { + @Test func alarmBookedIsReportedAsScheduled() async throws { let manager = MockGameAlarmManager() - let recorder = RecordingGameReminderHostEventRecorder() - let reminder = makeAlarm(manager, recorder) + let recorder = RecordingHostEventRecorder() let startsAt = Date().addingTimeInterval(3_600) - reminder.scheduleReminder(gameDate: startsAt, productId: productId) - reminder.scheduleReminder(gameDate: startsAt, productId: productId) - try await waitUntil { manager.scheduled.count == 1 && !recorder.events.isEmpty } - try await Task.sleep(for: .milliseconds(50)) + makeAlarm(manager, recorder).scheduleReminder(gameDate: startsAt, productId: productId) + try await waitUntil { !recorder.events.isEmpty } - #expect(recorder.events.map(\.step) == [.scheduled]) - #expect(recorder.events.first?.delivery == .alarm) - let recordedStart = try #require(recorder.events.first?.startsAt) - #expect(abs(recordedStart.timeIntervalSince(startsAt)) < 0.001) + #expect(recorder.events.map(\.name) == ["host.reminder.scheduled"]) + #expect(recorder.events.first?.tags["reminder.delivery"] == "alarm") + #expect(recorder.events.first?.extras["reminder.starts_at_unix_ms"] + == .integer(Int(startsAt.timeIntervalSince1970 * 1_000))) } @Test( @@ -71,27 +68,26 @@ struct GameReminderDeliveryDiagnosticsTests { let manager = MockGameAlarmManager() manager.isAuthorized = authorized manager.scheduleError = fails ? ScheduleFailed() : nil - let recorder = RecordingGameReminderHostEventRecorder() + let recorder = RecordingHostEventRecorder() // The reminder holds itself only weakly while its work is queued. let reminder = makeAlarm(manager, recorder) reminder.scheduleReminder(gameDate: Date().addingTimeInterval(secondsToStart), productId: productId) try await waitUntil { !recorder.events.isEmpty } - #expect(recorder.events.map(\.step) == [.notScheduled]) - #expect(recorder.events.first?.skipReason == reason) - #expect(recorder.events.first?.delivery == nil) + #expect(recorder.events.map(\.name) == ["host.reminder.not_scheduled"]) + #expect(recorder.events.first?.tags["reminder.skip_reason"] == reason.rawValue) } @Test func notificationBookedIsReportedAsScheduled() async throws { - let recorder = RecordingGameReminderHostEventRecorder() + let recorder = RecordingHostEventRecorder() makeNotification(MockUserNotificationService(), recorder) .scheduleReminder(gameDate: Date().addingTimeInterval(3_600), productId: productId) try await waitUntil { !recorder.events.isEmpty } - #expect(recorder.events.map(\.step) == [.scheduled]) - #expect(recorder.events.first?.delivery == .notification) + #expect(recorder.events.map(\.name) == ["host.reminder.scheduled"]) + #expect(recorder.events.first?.tags["reminder.delivery"] == "notification") } @Test( @@ -111,7 +107,7 @@ struct GameReminderDeliveryDiagnosticsTests { let service = MockUserNotificationService() service.accessStatus = allowed ? .allowed : .notAllowed(denied: true) service.scheduleError = fails ? ScheduleFailed() : nil - let recorder = RecordingGameReminderHostEventRecorder() + let recorder = RecordingHostEventRecorder() makeNotification(service, recorder).scheduleReminder( gameDate: Date().addingTimeInterval(secondsToStart), @@ -119,8 +115,7 @@ struct GameReminderDeliveryDiagnosticsTests { ) try await waitUntil { !recorder.events.isEmpty } - #expect(recorder.events.map(\.step) == [.notScheduled]) - #expect(recorder.events.first?.skipReason == reason) - #expect(recorder.events.first?.delivery == nil) + #expect(recorder.events.map(\.name) == ["host.reminder.not_scheduled"]) + #expect(recorder.events.first?.tags["reminder.skip_reason"] == reason.rawValue) } } diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift index 20f0e52183..4364d3026b 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderEventTests.swift @@ -1,80 +1,81 @@ import Foundation import IssueMonitoring -import Products import Testing +@testable import polkadot_app struct GameReminderEventTests { private let startsAt = Date(timeIntervalSince1970: 2_000_000_000) - private let origin = HostEventOrigin( - launchID: UUID(uuidString: "D93FE846-8F3C-4CFA-A440-402B6F7B43D3")!, - osVersion: "26.5", - appVersion: "1.2.3", - appBuild: 456, - appVariant: "nightly" - ) @Test func eventCarriesTheProductTheStartAndHowLateItCameAsNumbers() { - var event = GameReminderEvent( + let event = GameReminderEvent( step: .productOpened, productId: "game.dot", startsAt: startsAt, - occurredAt: startsAt.addingTimeInterval(1.5) + occurredAt: startsAt.addingTimeInterval(1.5), + openSource: .alarm, + appState: .inactive ) - event.openSource = .alarm - event.appState = .inactive - event.navigated = true - let hostEvent = event.hostEvent(origin: origin) - - #expect(hostEvent.name == "host.reminder.product_opened") - #expect(hostEvent.tags["reminder.product_id"] == "game.dot") - #expect(hostEvent.tags["reminder.open_source"] == "alarm") - #expect(hostEvent.tags["host.app_state"] == "inactive") - #expect(hostEvent.extras["reminder.starts_at_unix_ms"] == .integer(2_000_000_000_000)) - #expect(hostEvent.extras["reminder.delay_after_start_ms"] == .integer(1_500)) - #expect(hostEvent.extras["reminder.navigated"] == .boolean(true)) + #expect( + event.hostEvent(origin: GameReminderDiagnostics.testOrigin, monotonicMilliseconds: 7) + == HostEvent( + name: "host.reminder.product_opened", + tags: [ + "host.app_variant": "test", + "host.platform": "ios", + "host.native_app_version": "1.2.3", + "host.os_version": "26.5", + "reminder.product_id": "game.dot", + "reminder.open_source": "alarm", + "host.app_state": "inactive" + ], + extras: [ + "host.app_build": .integer(456), + "event.monotonic_ms": .integer(7), + "app.launch_id": .string("d93fe846-8f3c-4cfa-a440-402b6f7b43d3"), + "reminder.starts_at_unix_ms": .integer(2_000_000_000_000), + "reminder.delay_after_start_ms": .integer(1_500) + ] + ) + ) } - @Test func eventCarriesTheCanonicalHostFields() { - let hostEvent = GameReminderEvent(step: .scheduled, productId: "game.dot", startsAt: startsAt) - .hostEvent(origin: origin) + @Test func absentValuesAreOmittedInsteadOfInvented() { + let origin = HostEventOrigin( + launchID: UUID(uuidString: "D93FE846-8F3C-4CFA-A440-402B6F7B43D3")!, + osVersion: "26.5", + appVersion: nil, + appBuild: nil, + appVariant: "release" + ) - #expect(hostEvent.tags["host.platform"] == "ios") - #expect(hostEvent.tags["host.os_version"] == "26.5") - #expect(hostEvent.tags["host.native_app_version"] == "1.2.3") - #expect(hostEvent.tags["host.app_variant"] == "nightly") - #expect(hostEvent.extras["host.app_build"] == .integer(456)) - #expect(hostEvent.extras["app.launch_id"] == .string("d93fe846-8f3c-4cfa-a440-402b6f7b43d3")) + #expect( + GameReminderEvent(step: .tapped, productId: "game.dot", startsAt: nil) + .hostEvent(origin: origin, monotonicMilliseconds: 7) + == HostEvent( + name: "host.reminder.tapped", + tags: [ + "host.app_variant": "release", + "host.platform": "ios", + "host.os_version": "26.5", + "reminder.product_id": "game.dot" + ], + extras: [ + "event.monotonic_ms": .integer(7), + "app.launch_id": .string("d93fe846-8f3c-4cfa-a440-402b6f7b43d3") + ] + ) + ) } - @Test func skippedScheduleNamesItsDeliveryAndReason() { - var event = GameReminderEvent(step: .notScheduled, productId: "game.dot", startsAt: startsAt) - event.delivery = .alarm - event.skipReason = .notAuthorized + @Test func aNoopRecorderNeverBuildsTheEvent() { + var built = false - let hostEvent = event.hostEvent(origin: origin) - - #expect(hostEvent.name == "host.reminder.not_scheduled") - #expect(hostEvent.tags["reminder.delivery"] == "alarm") - #expect(hostEvent.tags["reminder.skip_reason"] == "not_authorized") - } - - @Test func absentValuesAreOmittedInsteadOfInvented() { - let hostEvent = GameReminderEvent(step: .tapped, productId: "game.dot", startsAt: nil) - .hostEvent(origin: HostEventOrigin( - launchID: UUID(), - osVersion: "26.5", - appVersion: nil, - appBuild: nil, - appVariant: "release" - )) + GameReminderDiagnostics().record({ + built = true + return GameReminderEvent(step: .tapped, productId: "game.dot", startsAt: nil) + }()) - #expect(hostEvent.extras["reminder.starts_at_unix_ms"] == nil) - #expect(hostEvent.extras["reminder.delay_after_start_ms"] == nil) - #expect(hostEvent.extras["reminder.navigated"] == nil) - #expect(hostEvent.extras["host.app_build"] == nil) - #expect(hostEvent.tags["host.native_app_version"] == nil) - #expect(hostEvent.tags["reminder.delivery"] == nil) - #expect(hostEvent.tags["reminder.open_source"] == nil) + #expect(!built) } } diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderDiagnostics+Recording.swift b/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderDiagnostics+Recording.swift new file mode 100644 index 0000000000..a2d75d4265 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderDiagnostics+Recording.swift @@ -0,0 +1,50 @@ +import Foundation +import IssueMonitoring +@testable import polkadot_app + +extension GameReminderDiagnostics { + static let testOrigin = HostEventOrigin( + launchID: UUID(uuidString: "D93FE846-8F3C-4CFA-A440-402B6F7B43D3")!, + osVersion: "26.5", + appVersion: "1.2.3", + appBuild: 456, + appVariant: "test" + ) + static let testMonotonicMilliseconds = 7 + + /// Diagnostics whose events land in `recorder` with a fixed origin and clock. + static func recording(into recorder: RecordingHostEventRecorder) -> GameReminderDiagnostics { + GameReminderDiagnostics( + recorder: recorder, + origin: testOrigin, + monotonicMilliseconds: { testMonotonicMilliseconds } + ) + } +} + +/// The host event `GameReminderDiagnostics.recording` records for a step, spelled out field by field. +func expectedReminderHostEvent( + _ step: String, + productId: String, + startsAt: Date, + occurredAt: Date, + tags: [String: String] = [:] +) -> HostEvent { + HostEvent( + name: "host.reminder.\(step)", + tags: [ + "host.app_variant": "test", + "host.platform": "ios", + "host.native_app_version": "1.2.3", + "host.os_version": "26.5", + "reminder.product_id": productId + ].merging(tags) { _, new in new }, + extras: [ + "host.app_build": .integer(456), + "event.monotonic_ms": .integer(GameReminderDiagnostics.testMonotonicMilliseconds), + "app.launch_id": .string("d93fe846-8f3c-4cfa-a440-402b6f7b43d3"), + "reminder.starts_at_unix_ms": .integer(Int(startsAt.timeIntervalSince1970 * 1_000)), + "reminder.delay_after_start_ms": .integer(Int(occurredAt.timeIntervalSince(startsAt) * 1_000)) + ] + ) +} diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/ReadCountingSettingsManager.swift b/hosts/ios/polkadot-appTests/Products/Mocks/ReadCountingSettingsManager.swift new file mode 100644 index 0000000000..67d2d78738 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/ReadCountingSettingsManager.swift @@ -0,0 +1,27 @@ +import Foundation +import Keystore_iOS + +/// Forwards to an in-memory store and records the keys read as strings. +final class ReadCountingSettingsManager: SettingsManagerProtocol { + private let store = InMemorySettingsManager() + private(set) var stringReads: [String] = [] + + func set(value: Bool, for key: String) { store.set(value: value, for: key) } + func set(value: Int, for key: String) { store.set(value: value, for: key) } + func set(value: Double, for key: String) { store.set(value: value, for: key) } + func set(value: String, for key: String) { store.set(value: value, for: key) } + func set(value: Data, for key: String) { store.set(value: value, for: key) } + func set(anyValue: Any, for key: String) { store.set(anyValue: anyValue, for: key) } + func bool(for key: String) -> Bool? { store.bool(for: key) } + func integer(for key: String) -> Int? { store.integer(for: key) } + func double(for key: String) -> Double? { store.double(for: key) } + func data(for key: String) -> Data? { store.data(for: key) } + func anyValue(for key: String) -> Any? { store.anyValue(for: key) } + func removeValue(for key: String) { store.removeValue(for: key) } + func removeAll() { store.removeAll() } + + func string(for key: String) -> String? { + stringReads.append(key) + return store.string(for: key) + } +} diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index 621ec309f0..28a86b5081 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -1,6 +1,5 @@ import Foundation import FoundationExt -import IssueMonitoring import Testing import UIKit import Keystore_iOS @@ -100,35 +99,6 @@ struct ProductGameReminderCenterTests { #expect(fakes.alarm(for: productId).scheduled.last == reminderCall(later, productId)) } - @Test("A past start is reported once and is not handed to either delivery service") - func pastStartIsReportedOnce() async { - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(60)) - - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - - #expect(fakes.reporter.events.map(\.step) == [.notScheduled]) - #expect(fakes.reporter.events.first?.skipReason == .startInPast) - #expect(fakes.alarm(for: productId).scheduled.isEmpty) - #expect(fakes.notification(for: productId).scheduled.isEmpty) - #expect(center.slots.isEmpty) - } - - @Test("A past replacement drops the product's held future reminder") - func pastStartDropsHeldFutureReminder() async { - let heldStart = startsAt.addingTimeInterval(600) - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) - await center.schedule(productId: productId, startsAt: heldStart, addCalendarEvent: false) - fakes.now = startsAt.addingTimeInterval(60) - - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - - #expect(fakes.reporter.events.map(\.step) == [.notScheduled]) - #expect(fakes.reporter.events.first?.skipReason == .startInPast) - #expect(fakes.alarm(for: productId).cancelCount == 1) - #expect(center.slots.isEmpty) - #expect(center.pill == nil) - } - @Test("With the calendar flag and a start exactly an hour away, one calendar event is added") func addsCalendarEventOnce() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) @@ -269,43 +239,52 @@ struct ProductGameReminderCenterTests { center.cancel(productId: other) center.cancel(productId: productId) - #expect(fakes.reporter.events.map(\.step) == [.cancelled]) - #expect(fakes.reporter.events.first?.productId == productId) - #expect(fakes.reporter.events.first?.startsAt == startsAt) + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "cancelled", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(-3_600), + tags: ["host.app_state": "active"] + ) + ] + ) } - @Test("Opening at the start is reported as an auto-open with the app state and whether it navigated") - func reportsAutoOpen() async throws { + @Test("Opening at the start is reported as an auto-open with the app state") + func reportsAutoOpen() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - fakes.openNavigates = false fakes.applicationState = .inactive fakes.now = startsAt.addingTimeInterval(2) center.refresh() - let event = try #require(fakes.reporter.events.last) - #expect(fakes.reporter.events.map(\.step) == [.productOpened]) - #expect(event.openSource == .autoOpen) - #expect(event.appState == .inactive) - #expect(event.navigated == false) - #expect(event.occurredAt == startsAt.addingTimeInterval(2)) + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "product_opened", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(2), + tags: ["reminder.open_source": "auto_open", "host.app_state": "inactive"] + ) + ] + ) } - @Test("A start reached past the grace is reported as missed, and nothing opens") - func reportsMissedStart() async throws { + @Test("An auto-open that finds the product already on screen is not reported") + func autoOpenWithoutNavigationIsNotReported() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + fakes.openNavigates = false - fakes.applicationState = .background - fakes.now = startsAt.addingTimeInterval(600) + fakes.now = startsAt.addingTimeInterval(2) center.refresh() - let event = try #require(fakes.reporter.events.last) - #expect(fakes.reporter.events.map(\.step) == [.missed]) - #expect(event.appState == .background) - #expect(event.occurredAt == startsAt.addingTimeInterval(600)) - #expect(fakes.opened.isEmpty) + #expect(fakes.opened == [productId]) + #expect(fakes.recorder.events.isEmpty) } @Test("A ringing alarm is reported once, for the product that holds it") @@ -324,13 +303,26 @@ struct ProductGameReminderCenterTests { for alarmIds: Set in [[UUID()], [alarmId], [alarmId], [otherAlarmId]] { fakes.alarmAlerts.yield(alarmIds) } - try await waitUntil { fakes.reporter.events.count == 2 } + try await waitUntil { fakes.recorder.events.count == 2 } - let events = fakes.reporter.events - #expect(events.map(\.step) == [.fired, .fired]) - #expect(events.map(\.productId) == [productId, other]) - #expect(events.first?.delivery == .alarm) - #expect(events.first?.occurredAt == startsAt.addingTimeInterval(1)) + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "fired", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(1), + tags: ["reminder.delivery": "alarm", "host.app_state": "active"] + ), + expectedReminderHostEvent( + "fired", + productId: other, + startsAt: startsAt.addingTimeInterval(60), + occurredAt: startsAt.addingTimeInterval(1), + tags: ["reminder.delivery": "alarm", "host.app_state": "active"] + ) + ] + ) } @Test("A ringing alarm is recorded before refresh drops its past slot") @@ -341,14 +333,26 @@ struct ProductGameReminderCenterTests { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) fakes.currentAlertingAlarms = [alarmId] - fakes.now = startsAt.addingTimeInterval(600) + fakes.now = startsAt.addingTimeInterval(1) center.refresh() - #expect(fakes.reporter.events.map(\.step) == [.fired, .missed]) + #expect(fakes.recorder.events.map(\.name) == ["host.reminder.fired", "host.reminder.product_opened"]) #expect(center.slots.isEmpty) } + @Test("With no alarm ringing, a refresh does not look up the held reminders' alarms") + func refreshWithoutRingingAlarmReadsNoAlarmId() async { + let settings = ReadCountingSettingsManager() + settings.set(string: UUID().uuidString, for: GameReminderStorageKeys.product(productId).alarmId) + let (center, _) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + center.refresh() + + #expect(settings.stringReads.isEmpty) + } + @Test("A cancel while calendar access is being asked adds no event") func cancelDuringCalendarAccessAddsNoEvent() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-7_200)) @@ -380,9 +384,9 @@ private final class Fakes: @unchecked Sendable { var opened: [String] = [] var openNavigates = true let calendar = RecordingCalendar() - let reporter = RecordingGameReminderHostEventRecorder() + let recorder = RecordingHostEventRecorder() var currentAlertingAlarms: Set = [] - var diagnostics: GameReminderDiagnostics { reporter.diagnostics } + var diagnostics: GameReminderDiagnostics { .recording(into: recorder) } let alarmAlerts: AsyncStream>.Continuation private let alarmAlertStream: AsyncStream> private let alarmAvailable: Bool From 0e2a4f6bf2acc78668edbf5e632f6569be4330ae Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:17:29 +0100 Subject: [PATCH 20/22] fix(ios): keep the alarm reminder alive while its booking test waits --- .../Products/GameReminderDeliveryDiagnosticsTests.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift index 30f35ae043..4b62178bd5 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift @@ -41,7 +41,9 @@ struct GameReminderDeliveryDiagnosticsTests { let recorder = RecordingHostEventRecorder() let startsAt = Date().addingTimeInterval(3_600) - makeAlarm(manager, recorder).scheduleReminder(gameDate: startsAt, productId: productId) + // The reminder holds itself only weakly while its work is queued. + let reminder = makeAlarm(manager, recorder) + reminder.scheduleReminder(gameDate: startsAt, productId: productId) try await waitUntil { !recorder.events.isEmpty } #expect(recorder.events.map(\.name) == ["host.reminder.scheduled"]) From 0566cc0169a9127c657d060a03c421c00cfdb35a Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:30:01 +0100 Subject: [PATCH 21/22] test(ios): split the game reminder center tests and share their doubles --- .../Mocks/GameReminderCenterFakes.swift | 107 +++++++ .../Products/Mocks/RecordingCalendar.swift | 35 +++ .../Mocks/RecordingGameReminder.swift | 26 ++ .../ProductGameReminderCenterTests.swift | 271 +----------------- .../ProductGameReminderReportingTests.swift | 142 +++++++++ 5 files changed, 312 insertions(+), 269 deletions(-) create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/GameReminderCenterFakes.swift create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/RecordingCalendar.swift create mode 100644 hosts/ios/polkadot-appTests/Products/Mocks/RecordingGameReminder.swift create mode 100644 hosts/ios/polkadot-appTests/Products/ProductGameReminderReportingTests.swift diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderCenterFakes.swift b/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderCenterFakes.swift new file mode 100644 index 0000000000..e772d1ec8e --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/GameReminderCenterFakes.swift @@ -0,0 +1,107 @@ +import Foundation +import FoundationExt +import Keystore_iOS +import PolkadotUI +import Products +import UIKit +@testable import polkadot_app + +final class GameReminderCenterFakes: @unchecked Sendable { + var now: Date + var applicationState: UIApplication.State = .active + var opened: [String] = [] + var openNavigates = true + let calendar = RecordingCalendar() + let recorder = RecordingHostEventRecorder() + var currentAlertingAlarms: Set = [] + var diagnostics: GameReminderDiagnostics { .recording(into: recorder) } + let alarmAlerts: AsyncStream>.Continuation + private let alarmAlertStream: AsyncStream> + private let alarmAvailable: Bool + private var alarms: [String: RecordingGameReminder] = [:] + private var notifications: [String: RecordingGameReminder] = [:] + + init(now: Date, alarmAvailable: Bool) { + self.now = now + self.alarmAvailable = alarmAvailable + (alarmAlertStream, alarmAlerts) = AsyncStream.makeStream() + } + + func alarm(for productId: String) -> RecordingGameReminder { + reminder(in: &alarms, for: productId) + } + + func notification(for productId: String) -> RecordingGameReminder { + reminder(in: ¬ifications, for: productId) + } + + private func reminder( + in store: inout [String: RecordingGameReminder], + for productId: String + ) -> RecordingGameReminder { + if let reminder = store[productId] { + return reminder + } + let reminder = RecordingGameReminder() + store[productId] = reminder + return reminder + } +} + +extension GameReminderCenterFakes: ProductGameReminderServicesMaking, ApplicationStateProviding, ProductOpening, + CurrentDateProviding { + func makeAlarm(for productId: String) -> (any GameStartReminderServicing)? { + alarmAvailable ? alarm(for: productId) : nil + } + + func makeNotification(for productId: String) -> any GameStartReminderServicing { + notification(for: productId) + } + + func makeCalendar() -> any GameCalendarServicing { + calendar + } + + func alertingAlarms() -> AsyncStream> { + alarmAlertStream + } + + func currentAlertingAlarmIds() -> Set { + currentAlertingAlarms + } + + func open(productId: String) -> Bool { + opened.append(productId) + return openNavigates + } +} + +@MainActor +extension GameReminderCenterFakes { + static func makeCenter( + now: Date, + alarmAvailable: Bool = true, + settings: SettingsManagerProtocol = InMemorySettingsManager() + ) -> (center: ProductGameReminderCenter, fakes: GameReminderCenterFakes) { + let fakes = GameReminderCenterFakes(now: now, alarmAvailable: alarmAvailable) + let center = ProductGameReminderCenter( + dependencies: .init( + services: fakes, + settingsManager: settings, + applicationState: fakes, + applicationStateStreams: ApplicationStateStreamFactory(), + productOpener: fakes, + dateProvider: fakes, + diagnostics: fakes.diagnostics + ) + ) + center.start() + return (center, fakes) + } +} + +extension ProductGameReminderCenter { + func schedule(productId: String, startsAt: Date, addCalendarEvent: Bool) async { + await schedule(productId: productId, startsAt: startsAt, ringAlarm: true, addCalendarEvent: addCalendarEvent) + } +} diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/RecordingCalendar.swift b/hosts/ios/polkadot-appTests/Products/Mocks/RecordingCalendar.swift new file mode 100644 index 0000000000..bc2c174e6a --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/RecordingCalendar.swift @@ -0,0 +1,35 @@ +import Foundation +import Products +@testable import polkadot_app + +final class RecordingCalendar: GameCalendarServicing { + struct Event: Equatable { + let title: String + let startDate: Date + let endDate: Date + let remindBefore: TimeInterval? + } + + var writeAccess = true + var onRequestWriteAccess: (@MainActor () -> Void)? + private(set) var added: [Event] = [] + + func requestWriteAccess() async -> Bool { + await onRequestWriteAccess?() + return writeAccess + } + + func addEvent(for game: CalendarGameModel) throws { + added.append( + Event(title: game.title, startDate: game.startDate, endDate: game.endDate, remindBefore: game.remindBefore) + ) + } + + func savedReminder() -> GameCalendarReminder? { + nil + } + + func saveReminder(_: GameCalendarReminder) {} + + func clearReminder() {} +} diff --git a/hosts/ios/polkadot-appTests/Products/Mocks/RecordingGameReminder.swift b/hosts/ios/polkadot-appTests/Products/Mocks/RecordingGameReminder.swift new file mode 100644 index 0000000000..b857f9a8b7 --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/Mocks/RecordingGameReminder.swift @@ -0,0 +1,26 @@ +import Foundation +import Products +@testable import polkadot_app + +final class RecordingGameReminder: GameStartReminderServicing { + struct Call: Equatable { + let gameDate: Date + let productId: ProductId + } + + private(set) var scheduled: [Call] = [] + private(set) var cancelCount = 0 + + func scheduleReminder(gameDate: Date, productId: ProductId) { + scheduled.append(Call(gameDate: gameDate, productId: productId)) + } + + func cancelReminder() { + cancelCount += 1 + } +} + +/// The reminder call the center makes for a product's start, which rings at the start itself. +func reminderCall(_ startsAt: Date, _ productId: String) -> RecordingGameReminder.Call { + .init(gameDate: startsAt, productId: productId) +} diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift index 28a86b5081..ef8e6f9b53 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderCenterTests.swift @@ -18,21 +18,8 @@ struct ProductGameReminderCenterTests { now: Date, alarmAvailable: Bool = true, settings: SettingsManagerProtocol = InMemorySettingsManager() - ) -> (center: ProductGameReminderCenter, fakes: Fakes) { - let fakes = Fakes(now: now, alarmAvailable: alarmAvailable) - let center = ProductGameReminderCenter( - dependencies: .init( - services: fakes, - settingsManager: settings, - applicationState: fakes, - applicationStateStreams: ApplicationStateStreamFactory(), - productOpener: fakes, - dateProvider: fakes, - diagnostics: fakes.diagnostics - ) - ) - center.start() - return (center, fakes) + ) -> (center: ProductGameReminderCenter, fakes: GameReminderCenterFakes) { + GameReminderCenterFakes.makeCenter(now: now, alarmAvailable: alarmAvailable, settings: settings) } @Test("A schedule rings an alarm at the start when AlarmKit is available") @@ -231,128 +218,6 @@ struct ProductGameReminderCenterTests { #expect(relaunched.pill == ProductGamePill(productId: productId, startsAt: startsAt)) } - @Test("A product's cancel is reported with its start; a start it never held reports nothing") - func reportsCancel() async { - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - - center.cancel(productId: other) - center.cancel(productId: productId) - - #expect( - fakes.recorder.events == [ - expectedReminderHostEvent( - "cancelled", - productId: productId, - startsAt: startsAt, - occurredAt: startsAt.addingTimeInterval(-3_600), - tags: ["host.app_state": "active"] - ) - ] - ) - } - - @Test("Opening at the start is reported as an auto-open with the app state") - func reportsAutoOpen() async { - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - - fakes.applicationState = .inactive - fakes.now = startsAt.addingTimeInterval(2) - center.refresh() - - #expect( - fakes.recorder.events == [ - expectedReminderHostEvent( - "product_opened", - productId: productId, - startsAt: startsAt, - occurredAt: startsAt.addingTimeInterval(2), - tags: ["reminder.open_source": "auto_open", "host.app_state": "inactive"] - ) - ] - ) - } - - @Test("An auto-open that finds the product already on screen is not reported") - func autoOpenWithoutNavigationIsNotReported() async { - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - fakes.openNavigates = false - - fakes.now = startsAt.addingTimeInterval(2) - center.refresh() - - #expect(fakes.opened == [productId]) - #expect(fakes.recorder.events.isEmpty) - } - - @Test("A ringing alarm is reported once, for the product that holds it") - func reportsFiredAlarmOnce() async throws { - let settings = InMemorySettingsManager() - let alarmId = UUID() - let otherAlarmId = UUID() - settings.set(string: alarmId.uuidString, for: GameReminderStorageKeys.product(productId).alarmId) - settings.set(string: otherAlarmId.uuidString, for: GameReminderStorageKeys.product(other).alarmId) - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - await center.schedule(productId: other, startsAt: startsAt.addingTimeInterval(60), addCalendarEvent: false) - fakes.now = startsAt.addingTimeInterval(1) - - // The stream is read in order, so the last product's ring arriving proves the repeat was read first. - for alarmIds: Set in [[UUID()], [alarmId], [alarmId], [otherAlarmId]] { - fakes.alarmAlerts.yield(alarmIds) - } - try await waitUntil { fakes.recorder.events.count == 2 } - - #expect( - fakes.recorder.events == [ - expectedReminderHostEvent( - "fired", - productId: productId, - startsAt: startsAt, - occurredAt: startsAt.addingTimeInterval(1), - tags: ["reminder.delivery": "alarm", "host.app_state": "active"] - ), - expectedReminderHostEvent( - "fired", - productId: other, - startsAt: startsAt.addingTimeInterval(60), - occurredAt: startsAt.addingTimeInterval(1), - tags: ["reminder.delivery": "alarm", "host.app_state": "active"] - ) - ] - ) - } - - @Test("A ringing alarm is recorded before refresh drops its past slot") - func recordsFiredBeforeDroppingPastSlot() async { - let settings = InMemorySettingsManager() - let alarmId = UUID() - settings.set(string: alarmId.uuidString, for: GameReminderStorageKeys.product(productId).alarmId) - let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - fakes.currentAlertingAlarms = [alarmId] - fakes.now = startsAt.addingTimeInterval(1) - - center.refresh() - - #expect(fakes.recorder.events.map(\.name) == ["host.reminder.fired", "host.reminder.product_opened"]) - #expect(center.slots.isEmpty) - } - - @Test("With no alarm ringing, a refresh does not look up the held reminders' alarms") - func refreshWithoutRingingAlarmReadsNoAlarmId() async { - let settings = ReadCountingSettingsManager() - settings.set(string: UUID().uuidString, for: GameReminderStorageKeys.product(productId).alarmId) - let (center, _) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) - await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) - - center.refresh() - - #expect(settings.stringReads.isEmpty) - } - @Test("A cancel while calendar access is being asked adds no event") func cancelDuringCalendarAccessAddsNoEvent() async { let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-7_200)) @@ -364,135 +229,3 @@ struct ProductGameReminderCenterTests { #expect(fakes.calendar.added.isEmpty) } } - -private extension ProductGameReminderCenter { - func schedule(productId: String, startsAt: Date, addCalendarEvent: Bool) async { - await schedule(productId: productId, startsAt: startsAt, ringAlarm: true, addCalendarEvent: addCalendarEvent) - } -} - -/// The reminder call the center makes for a product's start, which rings at the start itself. -private func reminderCall(_ startsAt: Date, _ productId: String) -> RecordingGameReminder.Call { - .init(gameDate: startsAt, productId: productId) -} - -// MARK: - Fakes - -private final class Fakes: @unchecked Sendable { - var now: Date - var applicationState: UIApplication.State = .active - var opened: [String] = [] - var openNavigates = true - let calendar = RecordingCalendar() - let recorder = RecordingHostEventRecorder() - var currentAlertingAlarms: Set = [] - var diagnostics: GameReminderDiagnostics { .recording(into: recorder) } - let alarmAlerts: AsyncStream>.Continuation - private let alarmAlertStream: AsyncStream> - private let alarmAvailable: Bool - private var alarms: [String: RecordingGameReminder] = [:] - private var notifications: [String: RecordingGameReminder] = [:] - - init(now: Date, alarmAvailable: Bool) { - self.now = now - self.alarmAvailable = alarmAvailable - (alarmAlertStream, alarmAlerts) = AsyncStream.makeStream() - } - - func alarm(for productId: String) -> RecordingGameReminder { - reminder(in: &alarms, for: productId) - } - - func notification(for productId: String) -> RecordingGameReminder { - reminder(in: ¬ifications, for: productId) - } - - private func reminder( - in store: inout [String: RecordingGameReminder], - for productId: String - ) -> RecordingGameReminder { - if let reminder = store[productId] { - return reminder - } - let reminder = RecordingGameReminder() - store[productId] = reminder - return reminder - } -} - -extension Fakes: ProductGameReminderServicesMaking, ApplicationStateProviding, ProductOpening, CurrentDateProviding { - func makeAlarm(for productId: String) -> (any GameStartReminderServicing)? { - alarmAvailable ? alarm(for: productId) : nil - } - - func makeNotification(for productId: String) -> any GameStartReminderServicing { - notification(for: productId) - } - - func makeCalendar() -> any GameCalendarServicing { - calendar - } - - func alertingAlarms() -> AsyncStream> { - alarmAlertStream - } - - func currentAlertingAlarmIds() -> Set { - currentAlertingAlarms - } - - func open(productId: String) -> Bool { - opened.append(productId) - return openNavigates - } -} - -private final class RecordingGameReminder: GameStartReminderServicing { - struct Call: Equatable { - let gameDate: Date - let productId: ProductId - } - - private(set) var scheduled: [Call] = [] - private(set) var cancelCount = 0 - - func scheduleReminder(gameDate: Date, productId: ProductId) { - scheduled.append(Call(gameDate: gameDate, productId: productId)) - } - - func cancelReminder() { - cancelCount += 1 - } -} - -private final class RecordingCalendar: GameCalendarServicing { - struct Event: Equatable { - let title: String - let startDate: Date - let endDate: Date - let remindBefore: TimeInterval? - } - - var writeAccess = true - var onRequestWriteAccess: (@MainActor () -> Void)? - private(set) var added: [Event] = [] - - func requestWriteAccess() async -> Bool { - await onRequestWriteAccess?() - return writeAccess - } - - func addEvent(for game: CalendarGameModel) throws { - added.append( - Event(title: game.title, startDate: game.startDate, endDate: game.endDate, remindBefore: game.remindBefore) - ) - } - - func savedReminder() -> GameCalendarReminder? { - nil - } - - func saveReminder(_: GameCalendarReminder) {} - - func clearReminder() {} -} diff --git a/hosts/ios/polkadot-appTests/Products/ProductGameReminderReportingTests.swift b/hosts/ios/polkadot-appTests/Products/ProductGameReminderReportingTests.swift new file mode 100644 index 0000000000..5e0ed3d5de --- /dev/null +++ b/hosts/ios/polkadot-appTests/Products/ProductGameReminderReportingTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Keystore_iOS +import Testing +@testable import polkadot_app + +@MainActor +@Suite("Product game reminder center reporting") +struct ProductGameReminderReportingTests { + private let productId = "game.dot" + private let other = "other.dot" + private let startsAt = Date(timeIntervalSince1970: 2_000_000_000) + + private func makeSUT( + now: Date, + alarmAvailable: Bool = true, + settings: SettingsManagerProtocol = InMemorySettingsManager() + ) -> (center: ProductGameReminderCenter, fakes: GameReminderCenterFakes) { + GameReminderCenterFakes.makeCenter(now: now, alarmAvailable: alarmAvailable, settings: settings) + } + + @Test("A product's cancel is reported with its start; a start it never held reports nothing") + func reportsCancel() async { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-3_600)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + center.cancel(productId: other) + center.cancel(productId: productId) + + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "cancelled", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(-3_600), + tags: ["host.app_state": "active"] + ) + ] + ) + } + + @Test("Opening at the start is reported as an auto-open with the app state") + func reportsAutoOpen() async { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + fakes.applicationState = .inactive + fakes.now = startsAt.addingTimeInterval(2) + center.refresh() + + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "product_opened", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(2), + tags: ["reminder.open_source": "auto_open", "host.app_state": "inactive"] + ) + ] + ) + } + + @Test("An auto-open that finds the product already on screen is not reported") + func autoOpenWithoutNavigationIsNotReported() async { + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60)) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + fakes.openNavigates = false + + fakes.now = startsAt.addingTimeInterval(2) + center.refresh() + + #expect(fakes.opened == [productId]) + #expect(fakes.recorder.events.isEmpty) + } + + @Test("A ringing alarm is reported once, for the product that holds it") + func reportsFiredAlarmOnce() async throws { + let settings = InMemorySettingsManager() + let alarmId = UUID() + let otherAlarmId = UUID() + settings.set(string: alarmId.uuidString, for: GameReminderStorageKeys.product(productId).alarmId) + settings.set(string: otherAlarmId.uuidString, for: GameReminderStorageKeys.product(other).alarmId) + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + await center.schedule(productId: other, startsAt: startsAt.addingTimeInterval(60), addCalendarEvent: false) + fakes.now = startsAt.addingTimeInterval(1) + + // The stream is read in order, so the last product's ring arriving proves the repeat was read first. + for alarmIds: Set in [[UUID()], [alarmId], [alarmId], [otherAlarmId]] { + fakes.alarmAlerts.yield(alarmIds) + } + try await waitUntil { fakes.recorder.events.count == 2 } + + #expect( + fakes.recorder.events == [ + expectedReminderHostEvent( + "fired", + productId: productId, + startsAt: startsAt, + occurredAt: startsAt.addingTimeInterval(1), + tags: ["reminder.delivery": "alarm", "host.app_state": "active"] + ), + expectedReminderHostEvent( + "fired", + productId: other, + startsAt: startsAt.addingTimeInterval(60), + occurredAt: startsAt.addingTimeInterval(1), + tags: ["reminder.delivery": "alarm", "host.app_state": "active"] + ) + ] + ) + } + + @Test("A ringing alarm is recorded before refresh drops its past slot") + func recordsFiredBeforeDroppingPastSlot() async { + let settings = InMemorySettingsManager() + let alarmId = UUID() + settings.set(string: alarmId.uuidString, for: GameReminderStorageKeys.product(productId).alarmId) + let (center, fakes) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + fakes.currentAlertingAlarms = [alarmId] + fakes.now = startsAt.addingTimeInterval(1) + + center.refresh() + + #expect(fakes.recorder.events.map(\.name) == ["host.reminder.fired", "host.reminder.product_opened"]) + #expect(center.slots.isEmpty) + } + + @Test("With no alarm ringing, a refresh does not look up the held reminders' alarms") + func refreshWithoutRingingAlarmReadsNoAlarmId() async { + let settings = ReadCountingSettingsManager() + settings.set(string: UUID().uuidString, for: GameReminderStorageKeys.product(productId).alarmId) + let (center, _) = makeSUT(now: startsAt.addingTimeInterval(-60), settings: settings) + await center.schedule(productId: productId, startsAt: startsAt, addCalendarEvent: false) + + center.refresh() + + #expect(settings.stringReads.isEmpty) + } +} From 2e7f6dcff9cbe16020d0e8b183268143db9d08c6 Mon Sep 17 00:00:00 2001 From: Ender <59183375+EnderOfWorlds007@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:53:53 +0100 Subject: [PATCH 22/22] fix(ios): correct the reminder start doc and match the sibling observer's self capture --- .../Common/Services/GameReminder/GameReminderEvent.swift | 2 +- .../Modules/Products/ProductGameReminderCenter.swift | 5 ++++- .../Products/GameReminderDeliveryDiagnosticsTests.swift | 3 +-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift index e3874fc199..18dbaf91c6 100644 --- a/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift +++ b/hosts/ios/polkadot-app/Common/Services/GameReminder/GameReminderEvent.swift @@ -51,7 +51,7 @@ struct GameReminderEvent: Equatable { let step: Step let productId: String - /// Nil only for an alarm booked before the start was attached to it. + /// Nil when the alarm's stored start is no longer available. let startsAt: Date? let occurredAt: Date let delivery: Delivery? diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift index 5942b01f83..b38ccc50e1 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductGameReminderCenter.swift @@ -308,7 +308,10 @@ private extension ProductGameReminderCenter { let alerts = services.alertingAlarms() alarmAlerts = Task { [weak self] in for await alarmIds in alerts { - self?.recordFired(alarmIds) + guard let self else { + return + } + recordFired(alarmIds) } } } diff --git a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift index 4b62178bd5..3b54f0adaa 100644 --- a/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift +++ b/hosts/ios/polkadot-appTests/Products/GameReminderDeliveryDiagnosticsTests.swift @@ -12,6 +12,7 @@ struct GameReminderDeliveryDiagnosticsTests { private let productId = "game.dot" private let keys = GameReminderStorageKeys.product("game.dot") + /// The reminder is held only weakly while its work is queued, so callers keep it in a `let`. @available(iOS 26.1, *) private func makeAlarm(_ manager: MockGameAlarmManager, _ recorder: RecordingHostEventRecorder) -> AlarmKitGameReminder { @@ -41,7 +42,6 @@ struct GameReminderDeliveryDiagnosticsTests { let recorder = RecordingHostEventRecorder() let startsAt = Date().addingTimeInterval(3_600) - // The reminder holds itself only weakly while its work is queued. let reminder = makeAlarm(manager, recorder) reminder.scheduleReminder(gameDate: startsAt, productId: productId) try await waitUntil { !recorder.events.isEmpty } @@ -72,7 +72,6 @@ struct GameReminderDeliveryDiagnosticsTests { manager.scheduleError = fails ? ScheduleFailed() : nil let recorder = RecordingHostEventRecorder() - // The reminder holds itself only weakly while its work is queued. let reminder = makeAlarm(manager, recorder) reminder.scheduleReminder(gameDate: Date().addingTimeInterval(secondsToStart), productId: productId) try await waitUntil { !recorder.events.isEmpty }