From 7d290afd52581d017171de69c9c9dc0fe20eb4c8 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 Oct 2026 12:14:34 -0500 Subject: [PATCH 1/2] Count app use in the daily analytics summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds management-burden counts to the daily summary so time spent in the app and how often it has to be opened can be measured, using the same once-a-day, out-of-session event as the existing loop and glucose counts: - foregroundCount, foregroundSeconds, maxForegroundSeconds, and session lengths bucketed under 10 s, 10–60 s, 1–5 min and over 5 min, measured from scene foreground to background - touchCount: touch sequences in the app's window, from a gesture recognizer that fails immediately and never cancels or delays touches - notificationOpenCount, linkOpenCount (widget and Live Activity deep links) and alertAcknowledgementCount - screenCounts for the status, bolus, carb entry and settings screens - bolusCounts and carbEntryCounts by source (Phone, Watch, Remote) and overrideCounts by kind, with totals The new counts are stored as an optional nested value so a summary saved by an earlier build still decodes and the day in progress is kept. PresetActivationObserver is now @MainActor; AnalyticsServicesManager is its only conformer and the presets manager already calls it on the main actor. --- Loop.xcodeproj/project.pbxproj | 4 + Loop/Managers/Alerts/AlertManager.swift | 1 + Loop/Managers/AnalyticsServicesManager.swift | 126 ++++++++++++++++++ Loop/Managers/LoopAppManager.swift | 17 ++- Loop/Managers/TemporaryPresetsManager.swift | 1 + Loop/SceneDelegate.swift | 33 +++++ .../AnalyticsServicesManagerTests.swift | 96 +++++++++++++ 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 LoopTests/Managers/AnalyticsServicesManagerTests.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 51a9e80069..3f121020ce 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 */; }; @@ -898,6 +899,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 = ""; }; @@ -1746,6 +1748,7 @@ E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, C1DA43562B1A70BE00CBD33F /* SettingsManagerTests.swift */, 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, + A7E1C0B12F8A4D2E00C0FFEE /* AnalyticsServicesManagerTests.swift */, C129BF492B2791EE00DF15CB /* TemporaryPresetsManagerTests.swift */, A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, ); @@ -4083,6 +4086,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..b719a8eb90 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -84,20 +84,71 @@ 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: - Config Events @@ -182,16 +233,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 +339,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 +353,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 +426,33 @@ 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.foregroundCount += 1 + usage.foregroundDuration += duration + usage.maxForegroundDuration = max(usage.maxForegroundDuration, duration) + usage.touchCount += touchCount + switch duration { + case ..<10: + usage.foregroundUnder10sCount += 1 + case ..<60: + usage.foreground10To60sCount += 1 + case ..<300: + usage.foreground1To5MinCount += 1 + default: + usage.foregroundOver5MinCount += 1 + } + } var properties: [AnyHashable: Any] { var properties: [AnyHashable: Any] = [ @@ -390,10 +474,52 @@ 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 foregroundCount = 0 + var foregroundDuration: TimeInterval = 0 + var maxForegroundDuration: TimeInterval = 0 + var foregroundUnder10sCount = 0 + var foreground10To60sCount = 0 + var foreground1To5MinCount = 0 + var foregroundOver5MinCount = 0 + 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 properties: [AnyHashable: Any] { + [ + "foregroundCount": foregroundCount, + "foregroundSeconds": Int(foregroundDuration), + "maxForegroundSeconds": Int(maxForegroundDuration), + "foregroundUnder10sCount": foregroundUnder10sCount, + "foreground10To60sCount": foreground10To60sCount, + "foreground1To5MinCount": foreground1To5MinCount, + "foregroundOver5MinCount": foregroundOver5MinCount, + "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 + ] + } +} + extension UserDefaults { private static let analyticsDailySummaryKey = "com.loopkit.Loop.AnalyticsDailySummary" private static let scheduledAlertsReportedThroughKey = "com.loopkit.Loop.AnalyticsScheduledAlertsReportedThrough" 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/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/LoopTests/Managers/AnalyticsServicesManagerTests.swift b/LoopTests/Managers/AnalyticsServicesManagerTests.swift new file mode 100644 index 0000000000..63b9b58297 --- /dev/null +++ b/LoopTests/Managers/AnalyticsServicesManagerTests.swift @@ -0,0 +1,96 @@ +// +// AnalyticsServicesManagerTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@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.foregroundCount, 4) + XCTAssertEqual(usage.foregroundDuration, 769) + XCTAssertEqual(usage.maxForegroundDuration, 600) + XCTAssertEqual(usage.touchCount, 106) + XCTAssertEqual(usage.foregroundUnder10sCount, 1) + XCTAssertEqual(usage.foreground10To60sCount, 1) + XCTAssertEqual(usage.foreground1To5MinCount, 1) + XCTAssertEqual(usage.foregroundOver5MinCount, 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.foregroundCount, 1) + XCTAssertEqual(usage.foregroundDuration, 30) + XCTAssertEqual(usage.touchCount, 3) + XCTAssertEqual(usage.foreground10To60sCount, 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) + } +} From b935d736e691f0867e0aedf66741cc7b32dc8bbd Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 29 Oct 2026 13:43:54 -0500 Subject: [PATCH 2/2] Count watch use in the daily analytics summaries The watch app now keeps its own daily usage summary and sends each completed day to the phone with transferUserInfo, which queues while the phone is out of reach; up to 14 unsent days are kept. The phone logs it as a "Watch daily summary" event (out of session) carrying the watch's own day: - watchForegroundCount, watchForegroundSeconds, maxWatchForegroundSeconds and the same session-length buckets as the phone, from applicationDidBecomeActive to applicationWillResignActive (wrist down ends a session) - watchComplicationLaunchCount: launches whose userInfo carries CLKLaunchedTimelineEntryDateKey - watchNotificationResponseCount - watchScreenCounts: carb entry, bolus, presets, carb list, and swipes to the actions, chart and sensor pages The phone's own daily summary adds watchContextRequestCount (the watch asks for context on activation and when each page first appears, so this runs a little above the number of opens), watchAlertAcknowledgementCount and watchPresetCount. The session-length bucketing moves into LoopCore as ForegroundSessions so the phone and the watch share it; the phone's summary keys are unchanged. The phone no longer asserts on userInfo transfers from the watch. --- Loop.xcodeproj/project.pbxproj | 6 + Loop/Managers/AnalyticsServicesManager.swift | 64 +++--- Loop/Managers/WatchDataManager.swift | 14 +- LoopCore/Models/WatchUsageSummary.swift | 184 ++++++++++++++++++ .../AnalyticsServicesManagerTests.swift | 112 ++++++++++- WatchApp Extension/ExtensionDelegate.swift | 23 +++ WatchApp Extension/Views/ChartPageView.swift | 1 + .../Views/WatchActionsView.swift | 3 + WatchApp/ContentView.swift | 1 + 9 files changed, 368 insertions(+), 40 deletions(-) create mode 100644 LoopCore/Models/WatchUsageSummary.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 3f121020ce..03ce9afa1d 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -636,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 */; }; @@ -1465,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 = ""; }; @@ -2878,6 +2881,7 @@ children = ( C1DCEDF32E999D5A001A7BB0 /* LastManualBolus.swift */, C1ED6C812E7D8E36002F91C2 /* AcknowledgeAlertUserInfo.swift */, + B5C0FFEE2F8A4D2E00A1B2C1 /* WatchUsageSummary.swift */, A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, @@ -3997,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 */, @@ -4033,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 */, diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index b719a8eb90..bfa9392e67 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -151,6 +151,27 @@ final class AnalyticsServicesManager { 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 func transmitterTimeDidDrift(_ drift: TimeInterval) { @@ -438,20 +459,8 @@ struct AnalyticsDailySummary: Codable, Equatable { } mutating func addForegroundSession(duration: TimeInterval, touchCount: Int) { - usage.foregroundCount += 1 - usage.foregroundDuration += duration - usage.maxForegroundDuration = max(usage.maxForegroundDuration, duration) + usage.foreground.add(duration: duration) usage.touchCount += touchCount - switch duration { - case ..<10: - usage.foregroundUnder10sCount += 1 - case ..<60: - usage.foreground10To60sCount += 1 - case ..<300: - usage.foreground1To5MinCount += 1 - default: - usage.foregroundOver5MinCount += 1 - } } var properties: [AnyHashable: Any] { @@ -480,13 +489,7 @@ struct AnalyticsDailySummary: Codable, Equatable { } struct AnalyticsUsage: Codable, Equatable { - var foregroundCount = 0 - var foregroundDuration: TimeInterval = 0 - var maxForegroundDuration: TimeInterval = 0 - var foregroundUnder10sCount = 0 - var foreground10To60sCount = 0 - var foreground1To5MinCount = 0 - var foregroundOver5MinCount = 0 + var foreground = ForegroundSessions() var touchCount = 0 var notificationOpenCount = 0 var linkOpenCount = 0 @@ -495,16 +498,12 @@ struct AnalyticsUsage: Codable, Equatable { 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] { - [ - "foregroundCount": foregroundCount, - "foregroundSeconds": Int(foregroundDuration), - "maxForegroundSeconds": Int(maxForegroundDuration), - "foregroundUnder10sCount": foregroundUnder10sCount, - "foreground10To60sCount": foreground10To60sCount, - "foreground1To5MinCount": foreground1To5MinCount, - "foregroundOver5MinCount": foregroundOver5MinCount, + var properties: [AnyHashable: Any] = [ "touchCount": touchCount, "notificationOpenCount": notificationOpenCount, "linkOpenCount": linkOpenCount, @@ -515,8 +514,15 @@ struct AnalyticsUsage: Codable, Equatable { "carbEntryCount": carbEntryCounts.values.reduce(0, +), "carbEntryCounts": carbEntryCounts, "overrideCount": overrideCounts.values.reduce(0, +), - "overrideCounts": overrideCounts + "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/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/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 index 63b9b58297..76708b90d9 100644 --- a/LoopTests/Managers/AnalyticsServicesManagerTests.swift +++ b/LoopTests/Managers/AnalyticsServicesManagerTests.swift @@ -6,6 +6,7 @@ // import XCTest +import LoopCore @testable import Loop @MainActor @@ -29,14 +30,14 @@ final class AnalyticsServicesManagerTests: XCTestCase { summary.addForegroundSession(duration: 600, touchCount: 80) let usage = summary.usage - XCTAssertEqual(usage.foregroundCount, 4) - XCTAssertEqual(usage.foregroundDuration, 769) - XCTAssertEqual(usage.maxForegroundDuration, 600) + XCTAssertEqual(usage.foreground.count, 4) + XCTAssertEqual(usage.foreground.duration, 769) + XCTAssertEqual(usage.foreground.maxDuration, 600) XCTAssertEqual(usage.touchCount, 106) - XCTAssertEqual(usage.foregroundUnder10sCount, 1) - XCTAssertEqual(usage.foreground10To60sCount, 1) - XCTAssertEqual(usage.foreground1To5MinCount, 1) - XCTAssertEqual(usage.foregroundOver5MinCount, 1) + 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) } @@ -63,10 +64,10 @@ final class AnalyticsServicesManagerTests: XCTestCase { manager.didEnterBackground(at: end) let usage = try XCTUnwrap(UserDefaults.standard.analyticsDailySummary).usage - XCTAssertEqual(usage.foregroundCount, 1) - XCTAssertEqual(usage.foregroundDuration, 30) + XCTAssertEqual(usage.foreground.count, 1) + XCTAssertEqual(usage.foreground.duration, 30) XCTAssertEqual(usage.touchCount, 3) - XCTAssertEqual(usage.foreground10To60sCount, 1) + XCTAssertEqual(usage.foreground.from10To60sCount, 1) } func testBackgroundWithoutForegroundIsIgnored() { @@ -93,4 +94,95 @@ final class AnalyticsServicesManagerTests: XCTestCase { 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") }) } }