diff --git a/App/iOS/Delegates/AppState.swift b/App/iOS/Delegates/AppState.swift index eade1547a26..c2094e98b8d 100644 --- a/App/iOS/Delegates/AppState.swift +++ b/App/iOS/Delegates/AppState.swift @@ -33,6 +33,7 @@ public class AppState { public let profile: Profile public let rewards: Brave.BraveRewards public let newsFeedDataSource: FeedDataSource + public let uptimeMonitor = UptimeMonitor() private var didBecomeActive = false public var state: State = .launching(options: [:], active: false) { diff --git a/App/iOS/Delegates/SceneDelegate.swift b/App/iOS/Delegates/SceneDelegate.swift index 237f17d7765..755824de6d5 100644 --- a/App/iOS/Delegates/SceneDelegate.swift +++ b/App/iOS/Delegates/SceneDelegate.swift @@ -218,6 +218,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { Preferences.AppState.backgroundedCleanly.value = false AppState.shared.profile.reopen() + AppState.shared.uptimeMonitor.beginMonitoring() appDelegate.receivedURLs = nil UIApplication.shared.applicationIconBadgeNumber = 0 @@ -248,6 +249,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillResignActive(_ scene: UIScene) { Preferences.AppState.backgroundedCleanly.value = true scene.userActivity?.resignCurrent() + AppState.shared.uptimeMonitor.pauseMonitoring() } func sceneWillEnterForeground(_ scene: UIScene) { diff --git a/Sources/Growth/GrowthPreferences.swift b/Sources/Growth/GrowthPreferences.swift index 04853bd9ed7..91d0d88bd59 100644 --- a/Sources/Growth/GrowthPreferences.swift +++ b/Sources/Growth/GrowthPreferences.swift @@ -46,4 +46,9 @@ extension Preferences { /// The date when the rating card in news feed is shown public static let newsCardShownDate = Option(key: "review.news-card", default: nil) } + + final class UptimeMonitor { + static let startTime: Option = .init(key: "uptime-monitor-start-time", default: nil) + static let uptimeSum: Option = .init(key: "uptime-monitor-uptime-sum", default: 0) + } } diff --git a/Sources/Growth/UptimeMonitor.swift b/Sources/Growth/UptimeMonitor.swift new file mode 100644 index 00000000000..07e097ae403 --- /dev/null +++ b/Sources/Growth/UptimeMonitor.swift @@ -0,0 +1,92 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import Preferences + +/// Monitors how long the browser is foregrounded to answer the `Brave.Uptime.BrowserOpenTime` P3A question +public class UptimeMonitor { + private var timer: Timer? + + private(set) static var usageInterval: TimeInterval = 15 + private(set) static var now: () -> Date = { .now } + private(set) static var calendar: Calendar = .current + + public init() { + if Preferences.UptimeMonitor.startTime.value == nil { + // If today is the first time monitoring uptime, set the frame start time to now. + resetPrefs() + } + recordP3A() + } + + deinit { + timer?.invalidate() + } + + // For testing + var didRecordP3A: ((_ durationInMinutes: Int) -> Void)? + + public var isMonitoring: Bool { + timer != nil + } + + /// Begins a timer to monitor uptime + public func beginMonitoring() { + if isMonitoring { + return + } + timer = Timer.scheduledTimer(withTimeInterval: Self.usageInterval, repeats: true, block: { [weak self] _ in + guard let self else { return } + Preferences.UptimeMonitor.uptimeSum.value += Self.usageInterval + self.recordP3A() + }) + } + /// Pauses the timer to monitor uptime + public func pauseMonitoring() { + timer?.invalidate() + timer = nil + } + + private func recordP3A() { + guard let startTime = Preferences.UptimeMonitor.startTime.value, + !Self.calendar.isDate(startTime, inSameDayAs: Self.now()) else { + // Do not report, since 1 day has not passed. + return + } + let buckets: [Bucket] = [ + .r(0...30), + .r(31...60), + .r(61...120), // 1-2 hours + .r(121...180), // 2-3 hours + .r(181...300), // 3-5 hours + .r(301...420), // 5-7 hours + .r(421...600), // 7-10 hours + .r(601...) // 10+ hours + ] + let durationInMinutes = Int(Preferences.UptimeMonitor.uptimeSum.value / 60.0) + UmaHistogramRecordValueToBucket("Brave.Uptime.BrowserOpenMinutes", buckets: buckets, value: durationInMinutes) + resetPrefs() + didRecordP3A?(durationInMinutes) + } + + private func resetPrefs() { + Preferences.UptimeMonitor.startTime.value = Self.now() + Preferences.UptimeMonitor.uptimeSum.value = 0 + } + + static func setUsageIntervalForTesting(_ usageInterval: TimeInterval) { + Self.usageInterval = usageInterval + } + + static func setNowForTesting(_ now: @escaping () -> Date) { + Self.now = now + } + + static func setCalendarForTesting(_ calendar: Calendar) { + Self.calendar = calendar + } +} + diff --git a/Tests/GrowthTests/UptimeMonitorTests.swift b/Tests/GrowthTests/UptimeMonitorTests.swift new file mode 100644 index 00000000000..146d3c72e47 --- /dev/null +++ b/Tests/GrowthTests/UptimeMonitorTests.swift @@ -0,0 +1,76 @@ +// Copyright 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +import XCTest +import Preferences +@testable import Growth + +class UptimeMonitorTests: XCTestCase { + + // How much a single second is actually accounted for during the test + static let testSecond: TimeInterval = 0.01 + + override func setUp() { + super.setUp() + + Preferences.UptimeMonitor.startTime.reset() + Preferences.UptimeMonitor.uptimeSum.reset() + + var testCalendar = Calendar(identifier: .gregorian) + testCalendar.timeZone = .init(abbreviation: "GMT")! + testCalendar.locale = .init(identifier: "en_US_POSIX") + UptimeMonitor.setCalendarForTesting(testCalendar) + UptimeMonitor.setNowForTesting({ .now }) + UptimeMonitor.setUsageIntervalForTesting(Self.testSecond) + } + + func testNoStartTimeInit() { + XCTAssertNil(Preferences.UptimeMonitor.startTime.value) + let um = UptimeMonitor() + XCTAssertNotNil(Preferences.UptimeMonitor.startTime.value) + XCTAssertFalse(um.isMonitoring) + } + + func testRecordAfterDay() { + let um = UptimeMonitor() + let e = expectation(description: "recorded") + let now = Date() + UptimeMonitor.setNowForTesting({ now }) + Preferences.UptimeMonitor.startTime.value = .now.addingTimeInterval(-60*60*24) + Preferences.UptimeMonitor.uptimeSum.value = 60 + um.didRecordP3A = { minutes in + XCTAssertEqual(minutes, 1) + e.fulfill() + } + um.beginMonitoring() + wait(for: [e], timeout: Self.testSecond * 2) + um.pauseMonitoring() + + // Ensure prefs are reset + XCTAssertEqual(Preferences.UptimeMonitor.startTime.value, now) + XCTAssertEqual(Preferences.UptimeMonitor.uptimeSum.value, 0) + } + + func testNoRecordBeforeOneDay() { + let um = UptimeMonitor() + let now = Date() + UptimeMonitor.setNowForTesting({ now }) + let e = expectation(description: "not-recorded") + e.isInverted = true + Preferences.UptimeMonitor.startTime.value = now + Preferences.UptimeMonitor.uptimeSum.value = 60 + um.didRecordP3A = { _ in + XCTFail("Should not record any data before a day has passed") + } + um.beginMonitoring() + wait(for: [e], timeout: Self.testSecond * 2) + um.pauseMonitoring() + + // Ensure prefs are not reset + XCTAssertEqual(Preferences.UptimeMonitor.startTime.value, now) + XCTAssertNotEqual(Preferences.UptimeMonitor.uptimeSum.value, 0) + } +}