diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 51a9e80069..03ce9afa1d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D63DEA426E950D400F46FA5 /* SupportManager.swift */; }; 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */; }; 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */; }; + A7E1C0B22F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7E1C0B12F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift */; }; 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; @@ -635,7 +636,9 @@ C1ED6C7E2E7C811A002F91C2 /* SetPresetUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */; }; C1ED6C802E7C9C86002F91C2 /* PendingPresetReminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */; }; C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; + B5C0FFEE2F8A4D2E00A1B2C2 /* WatchUsageSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C0FFEE2F8A4D2E00A1B2C1 /* WatchUsageSummary.swift */; }; C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */; }; + B5C0FFEE2F8A4D2E00A1B2C3 /* WatchUsageSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C0FFEE2F8A4D2E00A1B2C1 /* WatchUsageSummary.swift */; }; C1EE9E812A38D0FB0064784A /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = C1EE9E802A38D0FB0064784A /* BuildDetails.plist */; }; C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */; }; C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; @@ -898,6 +901,7 @@ 1D63DEA426E950D400F46FA5 /* SupportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManager.swift; sourceTree = ""; }; 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerTests.swift; sourceTree = ""; }; + A7E1C0B12F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsServicesManagerTests.swift; sourceTree = ""; }; 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedTimeChecker.swift; sourceTree = ""; }; 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModelTests.swift; sourceTree = ""; }; @@ -1463,6 +1467,7 @@ C1ED6C7C2E7C8113002F91C2 /* SetPresetUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetPresetUserInfo.swift; sourceTree = ""; }; C1ED6C7F2E7C9C7A002F91C2 /* PendingPresetReminder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingPresetReminder.swift; sourceTree = ""; }; C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgeAlertUserInfo.swift; sourceTree = ""; }; + B5C0FFEE2F8A4D2E00A1B2C1 /* WatchUsageSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchUsageSummary.swift; sourceTree = ""; }; C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; @@ -1746,6 +1751,7 @@ E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, + A7E1C0B12F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift */, C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -2875,6 +2881,7 @@ children = ( C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */, C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */, + B5C0FFEE2F8A4D2E00A1B2C1 /* WatchUsageSummary.swift */, A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, @@ -3994,6 +4001,7 @@ buildActionMask = 2147483647; files = ( C1ED6C822E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, + B5C0FFEE2F8A4D2E00A1B2C2 /* WatchUsageSummary.swift in Sources */, E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, C1ED6C6D2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, C1ED6C772E7C6F58002F91C2 /* WatchPredictedGlucose.swift in Sources */, @@ -4030,6 +4038,7 @@ buildActionMask = 2147483647; files = ( C1ED6C832E7D8E3D002F91C2 /* AcknowledgeAlertUserInfo.swift in Sources */, + B5C0FFEE2F8A4D2E00A1B2C3 /* WatchUsageSummary.swift in Sources */, C1ED6C652E7C6E2F002F91C2 /* CarbBackfillRequestUserInfo.swift in Sources */, C1ED6C6C2E7C6EC1002F91C2 /* NotificationActionSelection.swift in Sources */, E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, @@ -4083,6 +4092,7 @@ 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */, C1DA43532B19310A00CBD33F /* LoopControlMock.swift in Sources */, 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */, + A7E1C0B22F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift in Sources */, E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 216f3451dc..21c8a5d0ee 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -358,6 +358,7 @@ extension AlertManager: AlertManagerResponder { func acknowledgeAlert(identifier: Alert.Identifier) async throws { self.log.default("acknowledgeAlert called for identifier %{public}@", String(describing: identifier)) + analyticsServicesManager.didAcknowledgeAlert() if let responder = responders[identifier.managerIdentifier]?.value { do { try await responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index f24b3aa8d6..bfa9392e67 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -84,20 +84,92 @@ final class AnalyticsServicesManager { // MARK: - Screens + @MainActor func didDisplayBolusScreen() { logEvent("Bolus Screen") + updateDailySummary { $0.usage.screenCounts["bolus", default: 0] += 1 } } + @MainActor func didDisplayCarbEntryScreen() { logEvent("Carb Entry Screen") + updateDailySummary { $0.usage.screenCounts["carbEntry", default: 0] += 1 } } + @MainActor func didDisplaySettingsScreen() { logEvent("Settings Screen") + updateDailySummary { $0.usage.screenCounts["settings", default: 0] += 1 } } + @MainActor func didDisplayStatusScreen() { logEvent("Status Screen") + updateDailySummary { $0.usage.screenCounts["status", default: 0] += 1 } + } + + // MARK: - Foreground Use + + private var foregroundStart: Date? + + private var foregroundTouchCount = 0 + + @MainActor + func didEnterForeground(at date: Date = Date()) { + foregroundStart = date + foregroundTouchCount = 0 + } + + @MainActor + func didEnterBackground(at date: Date = Date()) { + guard let foregroundStart else { + return + } + self.foregroundStart = nil + let duration = max(0, date.timeIntervalSince(foregroundStart)) + let touchCount = foregroundTouchCount + updateDailySummary({ $0.addForegroundSession(duration: duration, touchCount: touchCount) }, now: date) + } + + @MainActor + func didReceiveTouch() { + foregroundTouchCount += 1 + } + + @MainActor + func didOpenFromNotification() { + updateDailySummary { $0.usage.notificationOpenCount += 1 } + } + + @MainActor + func didOpenFromLink() { + updateDailySummary { $0.usage.linkOpenCount += 1 } + } + + @MainActor + func didAcknowledgeAlert() { + updateDailySummary { $0.usage.alertAcknowledgementCount += 1 } + } + + // MARK: - Watch Use + + @MainActor + func didRequestWatchContext() { + updateDailySummary { $0.usage.watchContextRequestCount += 1 } + } + + @MainActor + func didAcknowledgeAlertOnWatch() { + updateDailySummary { $0.usage.watchAlertAcknowledgementCount += 1 } + } + + @MainActor + func didStartPresetOnWatch() { + updateDailySummary { $0.usage.watchPresetCount += 1 } + } + + func didReceiveWatchUsage(_ summary: WatchUsageSummary) { + logEvent("Watch daily summary", withProperties: summary.properties, outOfSession: true) } // MARK: - Config Events @@ -182,16 +254,20 @@ final class AnalyticsServicesManager { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } + @MainActor func didAddCarbs(source: String, isFavoriteFood: Bool = false) { logEvent("Carb entry created", withProperties: ["source" : source, "isFavoriteFood": isFavoriteFood], outOfSession: source != "Phone") + updateDailySummary { $0.usage.carbEntryCounts[source, default: 0] += 1 } } func didRetryBolus() { logEvent("Bolus Retry") } + @MainActor func didBolus(source: String) { logEvent("Bolus set", withProperties: ["source" : source], outOfSession: source != "Phone") + updateDailySummary { $0.usage.bolusCounts[source, default: 0] += 1 } } // MARK: - Daily Summary @@ -284,6 +360,7 @@ final class AnalyticsServicesManager { logEvent("Alert Displayed", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue, "scheduled": scheduled], outOfSession: scheduled) } + @MainActor func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration, activityType: String? = nil) { var properties: [String: Any] = ["kind": kind] @@ -297,6 +374,7 @@ final class AnalyticsServicesManager { properties["activityType"] = activityType logEvent("Override Enacted", withProperties: properties) + updateDailySummary { $0.usage.overrideCounts[kind, default: 0] += 1 } } func didEndOverride(kind: String) { @@ -369,6 +447,21 @@ struct AnalyticsDailySummary: Codable, Equatable { var backfillCount = 0 var maxBackfillAge: TimeInterval = 0 var state: AnalyticsState? + private var storedUsage: AnalyticsUsage? + + init(day: Date) { + self.day = day + } + + var usage: AnalyticsUsage { + get { storedUsage ?? AnalyticsUsage() } + set { storedUsage = newValue } + } + + mutating func addForegroundSession(duration: TimeInterval, touchCount: Int) { + usage.foreground.add(duration: duration) + usage.touchCount += touchCount + } var properties: [AnyHashable: Any] { var properties: [AnyHashable: Any] = [ @@ -390,6 +483,45 @@ struct AnalyticsDailySummary: Codable, Equatable { properties["glucoseBasedPartialApplication"] = state.glucoseBasedPartialApplication properties["guardAndRunway"] = state.guardAndRunway } + properties.merge(usage.properties) { current, _ in current } + return properties + } +} + +struct AnalyticsUsage: Codable, Equatable { + var foreground = ForegroundSessions() + var touchCount = 0 + var notificationOpenCount = 0 + var linkOpenCount = 0 + var alertAcknowledgementCount = 0 + var screenCounts: [String: Int] = [:] + var bolusCounts: [String: Int] = [:] + var carbEntryCounts: [String: Int] = [:] + var overrideCounts: [String: Int] = [:] + var watchContextRequestCount = 0 + var watchAlertAcknowledgementCount = 0 + var watchPresetCount = 0 + + var properties: [AnyHashable: Any] { + var properties: [AnyHashable: Any] = [ + "touchCount": touchCount, + "notificationOpenCount": notificationOpenCount, + "linkOpenCount": linkOpenCount, + "alertAcknowledgementCount": alertAcknowledgementCount, + "screenCounts": screenCounts, + "bolusCount": bolusCounts.values.reduce(0, +), + "bolusCounts": bolusCounts, + "carbEntryCount": carbEntryCounts.values.reduce(0, +), + "carbEntryCounts": carbEntryCounts, + "overrideCount": overrideCounts.values.reduce(0, +), + "overrideCounts": overrideCounts, + "watchContextRequestCount": watchContextRequestCount, + "watchAlertAcknowledgementCount": watchAlertAcknowledgementCount, + "watchPresetCount": watchPresetCount + ] + for (key, value) in foreground.properties(prefix: "foreground") { + properties[key] = value + } return properties } } diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 02023d5bb5..57d121a979 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -713,6 +713,18 @@ class LoopAppManager: NSObject { WidgetCenter.shared.reloadAllTimelines() } + func willEnterForeground() { + analyticsServicesManager.didEnterForeground() + } + + func didEnterBackground() { + analyticsServicesManager.didEnterBackground() + } + + func didReceiveTouch() { + analyticsServicesManager.didReceiveTouch() + } + // MARK: - Remote Notification func remoteNotificationRegistrationDidFinish(_ result: Swift.Result) { @@ -741,7 +753,9 @@ class LoopAppManager: NSObject { guard let deeplink = Deeplink(url: url) else { return false } - + + analyticsServicesManager.didOpenFromLink() + switch deeplink { case let .carbEntry(carbEntryLink): if let carbEntryLink { @@ -981,6 +995,7 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { } case UNNotificationDefaultActionIdentifier: + analyticsServicesManager.didOpenFromNotification() guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { break } diff --git a/Loop/Managers/TemporaryPresetsManager.swift b/Loop/Managers/TemporaryPresetsManager.swift index 084628f8de..0b71d95dc3 100644 --- a/Loop/Managers/TemporaryPresetsManager.swift +++ b/Loop/Managers/TemporaryPresetsManager.swift @@ -12,6 +12,7 @@ import os.log import LoopCore import LoopAlgorithm +@MainActor protocol PresetActivationObserver: AnyObject { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) func presetDeactivated(context: TemporaryScheduleOverride.Context) diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 4636902065..77d42ba3dd 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -482,6 +482,7 @@ final class WatchDataManager: NSObject { if let userInfo = SetPresetUserInfo(rawValue: message) { if let presetIdentifier = userInfo.presetIdentifier { temporaryPresetsManager.startPreset(withIdentifier: presetIdentifier) + analyticsServicesManager?.didStartPresetOnWatch() } else { temporaryPresetsManager.clearOverride() } @@ -502,6 +503,7 @@ final class WatchDataManager: NSObject { log.default("Acknowledge alert from watch: %{public}@", String(describing: message)) if let userInfo = AcknowledgeAlertUserInfo(rawValue: message) { let id = Alert.Identifier(managerIdentifier: userInfo.managerIdentifier, alertIdentifier: userInfo.alertIdentifier) + analyticsServicesManager?.didAcknowledgeAlertOnWatch() try? await alertManager.acknowledgeAlert(identifier: id) } return [:] @@ -530,6 +532,7 @@ final class WatchDataManager: NSObject { return [:] } case WatchContextRequestUserInfo.name?: + analyticsServicesManager?.didRequestWatchContext() return await createWatchContext(reason: "contextRequest").rawValue case NotificationActionSelection.name?: if let selection = NotificationActionSelection(rawValue: message) { @@ -561,7 +564,16 @@ extension WatchDataManager: WCSessionDelegate { } nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { - assertionFailure("We currently don't expect any userInfo messages transferred from the watch side") + guard let summary = WatchUsageSummaryUserInfo(rawValue: userInfo)?.summary else { + let name = String(describing: userInfo["name"]) + Task { @MainActor in + self.log.error("Unexpected userInfo from watch: %{public}@", name) + } + return + } + Task { @MainActor in + self.analyticsServicesManager?.didReceiveWatchUsage(summary) + } } nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { diff --git a/Loop/SceneDelegate.swift b/Loop/SceneDelegate.swift index 31b9e154d1..8eca355da6 100644 --- a/Loop/SceneDelegate.swift +++ b/Loop/SceneDelegate.swift @@ -33,6 +33,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, WindowProvider { return } + window?.addGestureRecognizer(TouchCountingGestureRecognizer { [weak loopAppManager] in + loopAppManager?.didReceiveTouch() + }) + loopAppManager.initialize(windowProvider: self, launchOptions: appDelegate.launchOptions) loopAppManager.launch() @@ -59,6 +63,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, WindowProvider { func sceneWillEnterForeground(_ scene: UIScene) { log.default(#function) + loopAppManager?.willEnterForeground() + guard let loopAppManager, loopAppManager.isLaunchComplete else { return } @@ -67,6 +73,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, WindowProvider { func sceneDidEnterBackground(_ scene: UIScene) { log.default(#function) + + loopAppManager?.didEnterBackground() } // MARK: - UIWindowSceneDelegate - Deeplinking @@ -86,3 +94,28 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate, WindowProvider { _ = loopAppManager?.userActivity(userActivity, restorationHandler: { _ in }) } } + +/// Counts each touch sequence without taking part in gesture recognition. +private final class TouchCountingGestureRecognizer: UIGestureRecognizer { + + private let onTouch: () -> Void + + init(onTouch: @escaping () -> Void) { + self.onTouch = onTouch + super.init(target: nil, action: nil) + cancelsTouchesInView = false + delaysTouchesBegan = false + delaysTouchesEnded = false + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + if state == .possible { + onTouch() + } + state = .failed + } + + override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { + false + } +} diff --git a/LoopCore/Models/WatchUsageSummary.swift b/LoopCore/Models/WatchUsageSummary.swift new file mode 100644 index 0000000000..466428a9a4 --- /dev/null +++ b/LoopCore/Models/WatchUsageSummary.swift @@ -0,0 +1,184 @@ +// +// WatchUsageSummary.swift +// LoopCore +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct ForegroundSessions: Codable, Equatable, Sendable { + public var count = 0 + public var duration: TimeInterval = 0 + public var maxDuration: TimeInterval = 0 + public var under10sCount = 0 + public var from10To60sCount = 0 + public var from1To5MinCount = 0 + public var over5MinCount = 0 + + public init() {} + + public mutating func add(duration: TimeInterval) { + count += 1 + self.duration += duration + maxDuration = max(maxDuration, duration) + switch duration { + case ..<10: + under10sCount += 1 + case ..<60: + from10To60sCount += 1 + case ..<300: + from1To5MinCount += 1 + default: + over5MinCount += 1 + } + } + + /// Keys are `Count`, `Seconds`, `maxSeconds` and `Under10sCount` etc. + public func properties(prefix: String) -> [String: Any] { + let capitalized = prefix.prefix(1).uppercased() + prefix.dropFirst() + return [ + "\(prefix)Count": count, + "\(prefix)Seconds": Int(duration), + "max\(capitalized)Seconds": Int(maxDuration), + "\(prefix)Under10sCount": under10sCount, + "\(prefix)10To60sCount": from10To60sCount, + "\(prefix)1To5MinCount": from1To5MinCount, + "\(prefix)Over5MinCount": over5MinCount + ] + } +} + +public struct WatchUsageSummary: Codable, Equatable, Sendable { + public var day: Date + public var utcOffset = TimeZone.current.secondsFromGMT() + public var foreground = ForegroundSessions() + public var complicationLaunchCount = 0 + public var notificationResponseCount = 0 + public var screenCounts: [String: Int] = [:] + + public init(day: Date) { + self.day = day + } + + public var properties: [String: Any] { + var properties = foreground.properties(prefix: "watchForeground") + properties["day"] = ISO8601DateFormatter.string(from: day, timeZone: TimeZone(secondsFromGMT: utcOffset) ?? .current, formatOptions: .withFullDate) + properties["utcOffset"] = utcOffset + properties["watchComplicationLaunchCount"] = complicationLaunchCount + properties["watchNotificationResponseCount"] = notificationResponseCount + properties["watchScreenCounts"] = screenCounts + return properties + } +} + +public struct WatchUsageSummaryUserInfo { + let version = 1 + public let summary: WatchUsageSummary + + public init(summary: WatchUsageSummary) { + self.summary = summary + } +} + +extension WatchUsageSummaryUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "WatchUsageSummaryUserInfo" + + public init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + let data = rawValue["s"] as? Data, + let summary = try? JSONDecoder().decode(WatchUsageSummary.self, from: data) + else { + return nil + } + self.summary = summary + } + + public var rawValue: RawValue { + return [ + "v": version, + "name": WatchUsageSummaryUserInfo.name, + "s": (try? JSONEncoder().encode(summary)) ?? Data() + ] + } +} + +/// Keeps a daily summary of watch app use and hands each completed day to `sendSummary`, which returns false to retry later. +@MainActor +public final class WatchUsageRecorder { + + private let defaults: UserDefaults + private let sendSummary: (WatchUsageSummary) -> Bool + + private var activeStart: Date? + + public init(defaults: UserDefaults = .standard, sendSummary: @escaping (WatchUsageSummary) -> Bool) { + self.defaults = defaults + self.sendSummary = sendSummary + } + + public func didBecomeActive(at date: Date = Date()) { + activeStart = date + update(now: date) { _ in } + } + + public func willResignActive(at date: Date = Date()) { + guard let activeStart else { + return + } + self.activeStart = nil + let duration = max(0, date.timeIntervalSince(activeStart)) + update(now: date) { $0.foreground.add(duration: duration) } + } + + public func didLaunchFromComplication() { + update { $0.complicationLaunchCount += 1 } + } + + public func didRespondToNotification() { + update { $0.notificationResponseCount += 1 } + } + + public func didDisplayScreen(_ name: String) { + update { $0.screenCounts[name, default: 0] += 1 } + } + + public func sendCompletedDays() { + var pending = pendingSummaries + while let summary = pending.first, sendSummary(summary) { + pending.removeFirst() + } + pendingSummaries = pending + } + + private func update(now: Date = Date(), _ change: (inout WatchUsageSummary) -> Void) { + let day = Calendar.current.startOfDay(for: now) + var summary = currentSummary ?? WatchUsageSummary(day: day) + + if summary.day != day { + pendingSummaries = Array((pendingSummaries + [summary]).suffix(Self.maxPendingDays)) + summary = WatchUsageSummary(day: day) + } + + change(&summary) + currentSummary = summary + sendCompletedDays() + } + + private static let maxPendingDays = 14 + private static let currentSummaryKey = "com.loopkit.Loop.WatchUsageSummary" + private static let pendingSummariesKey = "com.loopkit.Loop.WatchUsagePendingSummaries" + + public private(set) var currentSummary: WatchUsageSummary? { + get { defaults.data(forKey: Self.currentSummaryKey).flatMap { try? JSONDecoder().decode(WatchUsageSummary.self, from: $0) } } + set { defaults.set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: Self.currentSummaryKey) } + } + + public private(set) var pendingSummaries: [WatchUsageSummary] { + get { defaults.data(forKey: Self.pendingSummariesKey).flatMap { try? JSONDecoder().decode([WatchUsageSummary].self, from: $0) } ?? [] } + set { defaults.set(try? JSONEncoder().encode(newValue), forKey: Self.pendingSummariesKey) } + } +} diff --git a/LoopTests/Managers/AnalyticsServicesManagerTests.swift b/LoopTests/Managers/AnalyticsServicesManagerTests.swift new file mode 100644 index 0000000000..76708b90d9 --- /dev/null +++ b/LoopTests/Managers/AnalyticsServicesManagerTests.swift @@ -0,0 +1,188 @@ +// +// AnalyticsServicesManagerTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopCore +@testable import Loop + +@MainActor +final class AnalyticsServicesManagerTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.analyticsDailySummary = nil + } + + override func tearDown() { + UserDefaults.standard.analyticsDailySummary = nil + super.tearDown() + } + + func testForegroundSessionsAreBucketedByLength() { + var summary = AnalyticsDailySummary(day: Date()) + summary.addForegroundSession(duration: 4, touchCount: 0) + summary.addForegroundSession(duration: 45, touchCount: 6) + summary.addForegroundSession(duration: 120, touchCount: 20) + summary.addForegroundSession(duration: 600, touchCount: 80) + + let usage = summary.usage + XCTAssertEqual(usage.foreground.count, 4) + XCTAssertEqual(usage.foreground.duration, 769) + XCTAssertEqual(usage.foreground.maxDuration, 600) + XCTAssertEqual(usage.touchCount, 106) + XCTAssertEqual(usage.foreground.under10sCount, 1) + XCTAssertEqual(usage.foreground.from10To60sCount, 1) + XCTAssertEqual(usage.foreground.from1To5MinCount, 1) + XCTAssertEqual(usage.foreground.over5MinCount, 1) + XCTAssertEqual(summary.properties["foregroundSeconds"] as? Int, 769) + XCTAssertEqual(summary.properties["touchCount"] as? Int, 106) + } + + func testSummaryStoredBeforeUsageCountsStillDecodes() throws { + let day = Calendar.current.startOfDay(for: Date()) + var legacy = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(AnalyticsDailySummary(day: day))) as? [String: Any]) + legacy["loopSuccessCount"] = 12 + legacy.removeValue(forKey: "storedUsage") + let data = try JSONSerialization.data(withJSONObject: legacy) + + let summary = try JSONDecoder().decode(AnalyticsDailySummary.self, from: data) + XCTAssertEqual(summary.loopSuccessCount, 12) + XCTAssertEqual(summary.usage, AnalyticsUsage()) + } + + func testForegroundSessionRecordsDurationAndTouches() throws { + let manager = AnalyticsServicesManager() + let end = Date() + manager.didEnterForeground(at: end.addingTimeInterval(-30)) + manager.didReceiveTouch() + manager.didReceiveTouch() + manager.didReceiveTouch() + manager.didEnterBackground(at: end) + + let usage = try XCTUnwrap(UserDefaults.standard.analyticsDailySummary).usage + XCTAssertEqual(usage.foreground.count, 1) + XCTAssertEqual(usage.foreground.duration, 30) + XCTAssertEqual(usage.touchCount, 3) + XCTAssertEqual(usage.foreground.from10To60sCount, 1) + } + + func testBackgroundWithoutForegroundIsIgnored() { + let manager = AnalyticsServicesManager() + manager.didEnterBackground() + XCTAssertNil(UserDefaults.standard.analyticsDailySummary) + } + + func testActionsAreCountedBySource() throws { + let manager = AnalyticsServicesManager() + manager.didBolus(source: "Phone") + manager.didBolus(source: "Watch") + manager.didBolus(source: "Phone") + manager.didAddCarbs(source: "Phone") + manager.didDisplayBolusScreen() + manager.didOpenFromNotification() + manager.didAcknowledgeAlert() + + let summary = try XCTUnwrap(UserDefaults.standard.analyticsDailySummary) + XCTAssertEqual(summary.usage.bolusCounts, ["Phone": 2, "Watch": 1]) + XCTAssertEqual(summary.usage.carbEntryCounts, ["Phone": 1]) + XCTAssertEqual(summary.usage.screenCounts, ["bolus": 1]) + XCTAssertEqual(summary.usage.notificationOpenCount, 1) + XCTAssertEqual(summary.usage.alertAcknowledgementCount, 1) + XCTAssertEqual(summary.properties["bolusCount"] as? Int, 3) + } + + func testWatchActionsAreCounted() throws { + let manager = AnalyticsServicesManager() + manager.didRequestWatchContext() + manager.didRequestWatchContext() + manager.didAcknowledgeAlertOnWatch() + manager.didStartPresetOnWatch() + + let summary = try XCTUnwrap(UserDefaults.standard.analyticsDailySummary) + XCTAssertEqual(summary.properties["watchContextRequestCount"] as? Int, 2) + XCTAssertEqual(summary.properties["watchAlertAcknowledgementCount"] as? Int, 1) + XCTAssertEqual(summary.properties["watchPresetCount"] as? Int, 1) + } +} + +@MainActor +final class WatchUsageRecorderTests: XCTestCase { + + private var defaults: UserDefaults! + private var sent: [WatchUsageSummary] = [] + private var canSend = true + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: "WatchUsageRecorderTests") + defaults.removePersistentDomain(forName: "WatchUsageRecorderTests") + sent = [] + canSend = true + } + + private func makeRecorder() -> WatchUsageRecorder { + WatchUsageRecorder(defaults: defaults) { [unowned self] summary in + guard canSend else { + return false + } + sent.append(summary) + return true + } + } + + func testCompletedDayIsSentWhenTheNextDayStarts() throws { + let recorder = makeRecorder() + let today = Calendar.current.startOfDay(for: Date()) + let yesterdayNoon = Calendar.current.date(byAdding: .hour, value: -12, to: today)! + + recorder.didBecomeActive(at: yesterdayNoon) + recorder.willResignActive(at: yesterdayNoon.addingTimeInterval(8)) + XCTAssertTrue(sent.isEmpty) + + recorder.didBecomeActive(at: today.addingTimeInterval(3600)) + recorder.didDisplayScreen("chart") + + let summary = try XCTUnwrap(sent.first) + XCTAssertEqual(sent.count, 1) + XCTAssertEqual(summary.day, Calendar.current.startOfDay(for: yesterdayNoon)) + XCTAssertEqual(summary.foreground.count, 1) + XCTAssertEqual(summary.foreground.under10sCount, 1) + XCTAssertEqual(recorder.currentSummary?.day, today) + XCTAssertEqual(recorder.currentSummary?.screenCounts, ["chart": 1]) + XCTAssertTrue(recorder.pendingSummaries.isEmpty) + } + + func testUnsentDaysAreKeptUntilTheSessionIsAvailable() { + canSend = false + let recorder = makeRecorder() + let today = Calendar.current.startOfDay(for: Date()) + let twoDaysAgo = Calendar.current.date(byAdding: .day, value: -2, to: today)!.addingTimeInterval(3600) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!.addingTimeInterval(3600) + + recorder.didBecomeActive(at: twoDaysAgo) + recorder.didBecomeActive(at: yesterday) + recorder.didBecomeActive(at: today.addingTimeInterval(3600)) + XCTAssertEqual(recorder.pendingSummaries.count, 2) + + canSend = true + recorder.sendCompletedDays() + XCTAssertEqual(sent.map(\.day), [Calendar.current.startOfDay(for: twoDaysAgo), Calendar.current.startOfDay(for: yesterday)]) + XCTAssertTrue(recorder.pendingSummaries.isEmpty) + } + + func testUserInfoRoundTrips() throws { + var summary = WatchUsageSummary(day: Calendar.current.startOfDay(for: Date())) + summary.foreground.add(duration: 75) + summary.complicationLaunchCount = 3 + summary.screenCounts = ["bolus": 2] + + let decoded = try XCTUnwrap(WatchUsageSummaryUserInfo(rawValue: WatchUsageSummaryUserInfo(summary: summary).rawValue)) + XCTAssertEqual(decoded.summary, summary) + XCTAssertEqual(summary.properties["watchForeground1To5MinCount"] as? Int, 1) + XCTAssertEqual(summary.properties["maxWatchForegroundSeconds"] as? Int, 75) + } +} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 01c253e99f..dfd51a8510 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -31,6 +31,14 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { let loopManager = LoopDataManager.shared + let usageRecorder = WatchUsageRecorder { summary in + guard WCSession.default.activationState == .activated else { + return false + } + WCSession.default.transferUserInfo(WatchUsageSummaryUserInfo(summary: summary).rawValue) + return true + } + override init() { super.init() @@ -85,6 +93,8 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { } func applicationDidBecomeActive() { + usageRecorder.didBecomeActive() + if WCSession.default.activationState != .activated { WCSession.default.activate() } @@ -100,6 +110,13 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { } func applicationWillResignActive() { + usageRecorder.willResignActive() + } + + func handleUserActivity(_ userInfo: [AnyHashable: Any]?) { + if userInfo?[CLKLaunchedTimelineEntryDateKey] != nil { + usageRecorder.didLaunchFromComplication() + } } func handle(_ backgroundTasks: Set) { @@ -205,6 +222,7 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate { func handle(_ userActivity: NSUserActivity) { switch userActivity.activityType { case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: + usageRecorder.didDisplayScreen("carbEntry") loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) default: break @@ -270,6 +288,9 @@ extension ExtensionDelegate: WCSessionDelegate { if activationState == .activated { updateContext(session.receivedApplicationContext) + Task { @MainActor in + self.usageRecorder.sendCompletedDays() + } Task { await loopManager.requestSettingsUpdate() } @@ -320,6 +341,8 @@ extension ExtensionDelegate: UNUserNotificationCenterDelegate { log.default("UNNotificationResponse rootInterfaceController = %{public}@", String(describing: WKApplication.shared().rootInterfaceController)) log.default("UNNotificationResponse visibleInterfaceController = %{public}@", String(describing: WKApplication.shared().visibleInterfaceController)) + usageRecorder.didRespondToNotification() + switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { diff --git a/WatchApp Extension/Views/ChartPageView.swift b/WatchApp Extension/Views/ChartPageView.swift index 01c7dad590..5833781ef5 100644 --- a/WatchApp Extension/Views/ChartPageView.swift +++ b/WatchApp Extension/Views/ChartPageView.swift @@ -193,6 +193,7 @@ struct ChartPageView: View { Text(activeCarbohydrates ?? "-") } .onTapGesture { + ExtensionDelegate.shared().usageRecorder.didDisplayScreen("carbList") isShowingCarbList = true } Divider() diff --git a/WatchApp Extension/Views/WatchActionsView.swift b/WatchApp Extension/Views/WatchActionsView.swift index 369389af6a..b4a920335f 100644 --- a/WatchApp Extension/Views/WatchActionsView.swift +++ b/WatchApp Extension/Views/WatchActionsView.swift @@ -32,6 +32,7 @@ struct WatchActionsView: View { foregroundTint: .carbs, backgroundTint: .darkCarbs ) { + ExtensionDelegate.shared().usageRecorder.didDisplayScreen("carbEntry") loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .carbEntry(nil)) } CircleTintedButton( @@ -40,6 +41,7 @@ struct WatchActionsView: View { foregroundTint: .insulin, backgroundTint: .darkInsulin ) { + ExtensionDelegate.shared().usageRecorder.didDisplayScreen("bolus") loopManager.bolusViewModel = CarbAndBolusFlowViewModel(configuration: .manualBolus) } } @@ -51,6 +53,7 @@ struct WatchActionsView: View { foregroundTint: overrideActive ? .darkPresets : .presets, backgroundTint: overrideActive ? .presets : .darkPresets ) { + ExtensionDelegate.shared().usageRecorder.didDisplayScreen("presets") if overrideActive { overrideToShow = loopManager.watchInfo.scheduleOverride } else { diff --git a/WatchApp/ContentView.swift b/WatchApp/ContentView.swift index 114ba9490d..d29e12ccfb 100644 --- a/WatchApp/ContentView.swift +++ b/WatchApp/ContentView.swift @@ -53,6 +53,7 @@ struct ContentView: View { } .onChange(of: selectedPage, { oldValue, newValue in UserDefaults.standard.startOnChartPage = selectedPage == 1 + ExtensionDelegate.shared().usageRecorder.didDisplayScreen(newValue == 0 ? "actions" : newValue == 1 ? "chart" : "sensor") }) } }