diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 6ac3ba920611..4673b656bf09 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -669,6 +669,8 @@ 7BEB64441C7345600092C02E /* L10nSuite2SnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3632D31C2983F000D12AF9 /* L10nSuite2SnapshotTests.swift */; }; 7BEB64451C7345600092C02E /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B60B0071BDE3AE10090C984 /* SnapshotHelper.swift */; }; 7BEFC6801BFF68C30059C952 /* QuickActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BEFC67F1BFF68C30059C952 /* QuickActions.swift */; }; + 8015C8B52D0A31FE0093AED0 /* RecordedNimbusContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8015C8B42D0A31FE0093AED0 /* RecordedNimbusContextTests.swift */; }; + 80967F802D0A36890057F0C7 /* RecordedNimbusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80967F7F2D0A36890057F0C7 /* RecordedNimbusContext.swift */; }; 81020C922BB5AFA2007B8481 /* OnboardingMultipleChoiceButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81020C912BB5AFA2007B8481 /* OnboardingMultipleChoiceButtonView.swift */; }; 81020C942BB5B026007B8481 /* OnboardingMultipleChoiceButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81020C932BB5B026007B8481 /* OnboardingMultipleChoiceButtonViewModel.swift */; }; 81055B562BAB7CE200E166B3 /* OnboardingMultipeChoiceButtonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81055B552BAB7CE200E166B3 /* OnboardingMultipeChoiceButtonModel.swift */; }; @@ -7167,7 +7169,9 @@ 7F9D4F52B86570F837E0E9A6 /* ml */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ml; path = ml.lproj/ClearPrivateData.strings; sourceTree = ""; }; 7FAB476EB035F799E1BF1267 /* ka */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ka; path = ka.lproj/Intro.strings; sourceTree = ""; }; 7FCA45BDADEE92EF815319D3 /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/ErrorPages.strings; sourceTree = ""; }; + 8015C8B42D0A31FE0093AED0 /* RecordedNimbusContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedNimbusContextTests.swift; sourceTree = ""; }; 80574E82B96F93CDE08B966B /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Intro.strings; sourceTree = ""; }; + 80967F7F2D0A36890057F0C7 /* RecordedNimbusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedNimbusContext.swift; sourceTree = ""; }; 809E4D488E089D47E5B2B7ED /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Storage.strings; sourceTree = ""; }; 80A64F9E9304EFF710BFF857 /* es-CL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-CL"; path = "es-CL.lproj/Localizable.strings"; sourceTree = ""; }; 80B54D93BC553D6E6CDFAA0A /* cy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cy; path = cy.lproj/AuthenticationManager.strings; sourceTree = ""; }; @@ -10505,6 +10509,7 @@ 9636D92627F5D71A00771F5E /* Messaging */, D51EA5B82640697100334331 /* Settings */, C80685D026A0C93900DCD895 /* UserResearch.swift */, + 80967F7F2D0A36890057F0C7 /* RecordedNimbusContext.swift */, ); path = Experiments; sourceTree = ""; @@ -12856,6 +12861,7 @@ children = ( C8E1BC0928085AA700C62964 /* NimbusFeatureFlagLayerTests.swift */, 39C137962655798A003DC662 /* NimbusIntegrationTests.swift */, + 8015C8B42D0A31FE0093AED0 /* RecordedNimbusContextTests.swift */, ); path = Nimbus; sourceTree = ""; @@ -15516,7 +15522,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "OUTPUT_DIR=\"${SRCROOT}/Client/Generated/Metrics/\"\n# remove old Metrics file if present\nrm \"${SRCROOT}/Client/Generated/Metrics.swift\"\nbash $PWD/bin/sdk_generator.sh -g Glean -o $OUTPUT_DIR\n"; + shellScript = "OUTPUT_DIR=\"${SRCROOT}/Client/Generated/Metrics/\"\n# remove old Metrics file if present\nrm \"${SRCROOT}/Client/Generated/Metrics/Metrics.swift\"\nbash $PWD/bin/sdk_generator.sh -g Glean -o $OUTPUT_DIR\n"; }; 5FA2232C27F6FA69005B3D87 /* Nimbus Feature Manifest Generator Script */ = { isa = PBXShellScriptBuildPhase; @@ -16094,6 +16100,7 @@ 745DAB301CDAAFAA00D44181 /* RecentlyClosedTabsPanel.swift in Sources */, D0B9483D22A18B78002F4AA1 /* TextFieldTableViewCell.swift in Sources */, 8A454D322CB8170D009436D9 /* PocketManager.swift in Sources */, + 80967F802D0A36890057F0C7 /* RecordedNimbusContext.swift in Sources */, 8AF99B4F29EF1BA700108DEC /* BrowserDelegate.swift in Sources */, C87D8B802818333F00A6307D /* NimbusManager.swift in Sources */, 8A4AC0EB28C929D700439F83 /* URLSessionDataTaskProtocol.swift in Sources */, @@ -16944,6 +16951,7 @@ 2F697F7E1A9FD22D009E03AE /* SearchEnginesManagerTests.swift in Sources */, 2197DF8A287624BF00215624 /* LibraryViewModelTests.swift in Sources */, 5A3A7DDC2889EC5D0065F81A /* BookmarksHandlerMock.swift in Sources */, + 8015C8B52D0A31FE0093AED0 /* RecordedNimbusContextTests.swift in Sources */, 2F44FA1B1A9D426A00FD20CC /* TestHashExtensions.swift in Sources */, 5AF6254928A58BB400A90253 /* MockHistoryHighlightsDelegate.swift in Sources */, 8A04136B2825ABEA00D20B10 /* SponsoredTileTelemetryTests.swift in Sources */, diff --git a/firefox-ios/Client/Application/AppLaunchUtil.swift b/firefox-ios/Client/Application/AppLaunchUtil.swift index 7d885c66659d..5df9b7a04b33 100644 --- a/firefox-ios/Client/Application/AppLaunchUtil.swift +++ b/firefox-ios/Client/Application/AppLaunchUtil.swift @@ -146,7 +146,7 @@ class AppLaunchUtil { } private func initializeExperiments() { - Experiments.intialize() + Experiments.initialize() } private func updateSessionCount() { diff --git a/firefox-ios/Client/Experiments/Experiments.swift b/firefox-ios/Client/Experiments/Experiments.swift index e3b189eb36d0..e42e37d12b7b 100644 --- a/firefox-ios/Client/Experiments/Experiments.swift +++ b/firefox-ios/Client/Experiments/Experiments.swift @@ -6,13 +6,13 @@ import Common import Foundation import Shared +import struct MozillaAppServices.NimbusAppSettings import class MozillaAppServices.NimbusBuilder import class MozillaAppServices.NimbusDisabled +import typealias MozillaAppServices.NimbusErrorReporter import protocol MozillaAppServices.NimbusEventStore import protocol MozillaAppServices.NimbusInterface import protocol MozillaAppServices.NimbusMessagingHelperProtocol -import struct MozillaAppServices.NimbusAppSettings -import typealias MozillaAppServices.NimbusErrorReporter private let nimbusAppName = "firefox_ios" private let NIMBUS_URL_KEY = "NimbusURL" @@ -78,6 +78,7 @@ enum Experiments { shared.globalUserParticipation = studiesSetting && telemetrySetting } } + static func setLocalExperimentData(payload: String?, storage: UserDefaults = .standard) { guard let payload = payload else { storage.removeObject(forKey: NIMBUS_LOCAL_DATA_KEY) @@ -166,7 +167,7 @@ enum Experiments { private static func getAppSettings(isFirstRun: Bool) -> NimbusAppSettings { let isPhone = UIDevice.current.userInterfaceIdiom == .phone - let customTargetingAttributes: [String: Any] = [ + let customTargetingAttributes: [String: Any] = [ "isFirstRun": "\(isFirstRun)", "is_first_run": isFirstRun, "is_phone": isPhone, @@ -203,9 +204,13 @@ enum Experiments { let bundles = [ Bundle.main, Strings.bundle, - Strings.bundle.fallbackTranslationBundle(language: "en-US") + Strings.bundle.fallbackTranslationBundle(language: "en-US"), ].compactMap { $0 } + let nimbusRecordedContext = RecordedNimbusContext(isFirstRun: isFirstRun, + isReviewCheckerEnabled: isReviewCheckerEnabled(), + isDefaultBrowser: true) // TODO: Update after https://github.com/mozilla-mobile/firefox-ios/pull/23754 merges + return NimbusBuilder(dbPath: dbPath) .with(url: remoteSettingsURL) .using(previewCollection: usePreviewCollection()) @@ -215,6 +220,7 @@ enum Experiments { .with(bundles: bundles) .with(featureManifest: FxNimbus.shared) .with(commandLineArgs: CommandLine.arguments) + .with(recordedContext: nimbusRecordedContext) .build(appInfo: getAppSettings(isFirstRun: isFirstRun)) } @@ -228,7 +234,7 @@ enum Experiments { /// - Parameters: /// - fireURL: an optional file URL that stores the initial experiments document. /// - firstRun: a flag indicating that this is the first time that the app has been run. - static func intialize() { + static func initialize() { // Getting the singleton first time initializes it. let nimbus = Experiments.shared @@ -249,13 +255,9 @@ extension Experiments { return try? sdk.createMessageHelper(additionalContext: context) } - public static var messaging: GleanPlumbMessageManagerProtocol = { - GleanPlumbMessageManager() - }() + public static var messaging: GleanPlumbMessageManagerProtocol = GleanPlumbMessageManager() - public static var events: NimbusEventStore = { - sdk.events - }() + public static var events: NimbusEventStore = sdk.events public static var sdk: NimbusInterface = shared } diff --git a/firefox-ios/Client/Experiments/RecordedNimbusContext.swift b/firefox-ios/Client/Experiments/RecordedNimbusContext.swift new file mode 100644 index 000000000000..9c38ef97867c --- /dev/null +++ b/firefox-ios/Client/Experiments/RecordedNimbusContext.swift @@ -0,0 +1,191 @@ +// 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 http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Glean +import Shared + +import func MozillaAppServices.getCalculatedAttributes +import func MozillaAppServices.getLocaleTag +import struct MozillaAppServices.JsonObject +import protocol MozillaAppServices.RecordedContext +import MozillaRustComponents + +private extension Double? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +private extension Int32? { + func toInt64() -> Int64? { + guard let self = self else { return nil } + return Int64(self) + } +} + +class RecordedNimbusContext: RecordedContext { + /** + * The following constants are string constants of the keys that appear in the [EVENT_QUERIES] map. + */ + static let DAYS_OPENED_IN_LAST_28: String = "days_opened_in_last_28" + + /** + * [EVENT_QUERIES] is a map of keys to Nimbus SDK EventStore queries. + */ + static let EVENT_QUERIES = [ + DAYS_OPENED_IN_LAST_28: "'events.app_opened'|eventCountNonZero('Days', 28, 0)", + ] + + var isFirstRun: Bool + var isPhone: Bool + var isReviewCheckerEnabled: Bool + var isDefaultBrowser: Bool + var appVersion: String? + var region: String? + var language: String? + var locale: String + var daysSinceInstall: Int32? + var daysSinceUpdate: Int32? + + private var eventQueries: [String: String] + private var eventQueryValues: [String: Double] = [:] + + private var logger: Logger + + init(isFirstRun: Bool, + isReviewCheckerEnabled: Bool, + isDefaultBrowser: Bool, + eventQueries: [String: String] = RecordedNimbusContext.EVENT_QUERIES, + isPhone: Bool = UIDevice.current.userInterfaceIdiom == .phone, + bundle: Bundle = Bundle.main, + logger: Logger = DefaultLogger.shared) { + self.logger = logger + logger.log("init start", level: .debug, category: .experiments) + self.eventQueries = eventQueries + + self.isFirstRun = isFirstRun + self.isPhone = isPhone + self.isReviewCheckerEnabled = isReviewCheckerEnabled + self.isDefaultBrowser = isDefaultBrowser + + let info = bundle.infoDictionary ?? [:] + appVersion = info["CFBundleShortVersionString"] as? String + + locale = getLocaleTag() + var inferredDateInstalledOn: Date? { + guard + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, + let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path) + else { return nil } + return attributes[.creationDate] as? Date + } + let installationDateSinceEpoch = inferredDateInstalledOn.map { + Int64(($0.timeIntervalSince1970 * 1000).rounded()) + } + guard let dbPath = Experiments.dbPath else { + self.logger.log("Unable to obtain dbPath, skipping calculating attributes", + level: .warning, + category: .experiments) + return + } + guard let calculatedAttributes = try? getCalculatedAttributes(installationDate: installationDateSinceEpoch, + dbPath: dbPath, + locale: locale) + else { return } + + daysSinceInstall = calculatedAttributes.daysSinceInstall + daysSinceUpdate = calculatedAttributes.daysSinceUpdate + language = calculatedAttributes.language + region = calculatedAttributes.region + self.logger.log("init end", level: .debug, category: .experiments) + } + + /** + * [getEventQueries] is called by the Nimbus SDK Rust code to retrieve the map of event + * queries. The are then executed against the Nimbus SDK's EventStore to retrieve their values. + * + * @return Map + */ + func getEventQueries() -> [String: String] { + logger.log("getEventQueries", level: .debug, category: .experiments) + return eventQueries + } + + /** + * [record] is called when experiment enrollments are evolved. It should apply the + * [RecordedNimbusContext]'s values to a [NimbusSystem.RecordedNimbusContextObject] instance, + * and use that instance to record the values to Glean. + */ + func record() { + logger.log("record start", level: .debug, category: .experiments) + let eventQueryValuesObject = GleanMetrics.NimbusSystem.RecordedNimbusContextObjectItemEventQueryValuesObject( + daysOpenedInLast28: eventQueryValues[RecordedNimbusContext.DAYS_OPENED_IN_LAST_28].toInt64() + ) + + GleanMetrics.NimbusSystem.recordedNimbusContext.set( + GleanMetrics.NimbusSystem.RecordedNimbusContextObject( + isFirstRun: isFirstRun, + eventQueryValues: eventQueryValuesObject, + isReviewCheckerEnabled: isReviewCheckerEnabled, + isPhone: isPhone, + appVersion: appVersion, + locale: locale, + daysSinceInstall: daysSinceInstall.toInt64(), + daysSinceUpdate: daysSinceUpdate.toInt64(), + language: language, + region: region, + isDefaultBrowser: isDefaultBrowser + ) + ) + logger.log("record end", level: .debug, category: .experiments) + } + + /** + * [setEventQueryValues] is called by the Nimbus SDK Rust code after the event queries have been + * executed. The [eventQueryValues] should be written back to the Kotlin object. + * + * @param [eventQueryValues] The values for each query after they have been executed in the + * Nimbus SDK Rust environment. + */ + func setEventQueryValues(eventQueryValues: [String: Double]) { + logger.log("setEventQueryValues", level: .debug, category: .experiments) + self.eventQueryValues = eventQueryValues + } + + /** + * [toJson] is called by the Nimbus SDK Rust code after the event queries have been executed, + * and before experiment enrollments have been evolved. The value returned from this method + * will be applied directly to the Nimbus targeting context, and its keys/values take + * precedence over those in the main Nimbus targeting context. + * + * @return JsonObject + */ + func toJson() -> JsonObject { + logger.log("toJson start", level: .debug, category: .experiments) + guard let data = try? JSONSerialization.data(withJSONObject: [ + "is_first_run": isFirstRun, + "isFirstRun": "\(isFirstRun)", + "is_phone": isPhone, + "is_review_checker_enabled": isReviewCheckerEnabled, + "events": eventQueryValues, + "app_version": appVersion as Any, + "region": region as Any, + "language": language as Any, + "locale": locale as Any, + "days_since_install": daysSinceInstall as Any, + "days_since_update": daysSinceUpdate as Any, + "is_default_browser": isDefaultBrowser, + ]), + let jsonString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String + else { + logger.log("toJson error thrown while creating JSON string", level: .warning, category: .experiments) + return "{}" + } + logger.log("toJson end", level: .debug, category: .experiments, extra: ["json": jsonString]) + return jsonString + } +} diff --git a/firefox-ios/Client/metrics.yaml b/firefox-ios/Client/metrics.yaml index b270efb79bfc..15e5c93b29ed 100755 --- a/firefox-ios/Client/metrics.yaml +++ b/firefox-ios/Client/metrics.yaml @@ -6121,3 +6121,47 @@ server_knobs: - fx-ios-data-stewards@mozilla.com - brosa@mozilla.com expires: "2025-07-01" + +nimbus_system: + recorded_nimbus_context: + type: object + structure: + type: object + properties: + is_first_run: + type: boolean + event_query_values: + type: object + properties: + days_opened_in_last_28: + type: number + is_review_checker_enabled: + type: boolean + is_phone: + type: boolean + app_version: + type: string + locale: + type: string + days_since_install: + type: number + days_since_update: + type: number + language: + type: string + region: + type: string + is_default_browser: + type: boolean + description: | + The Nimbus context object that is recorded to Glean + bugs: + - 'https://github.com/mozilla-mobile/firefox-ios/issues/23736' + data_reviews: + - 'https://github.com/mozilla-mobile/firefox-ios/pull/23737#issuecomment-2541639916' + data_sensitivity: + - interaction + notification_emails: + - chumphreys@mozilla.com + - project-nimbus@mozilla.com + expires: never diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Nimbus/RecordedNimbusContextTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Nimbus/RecordedNimbusContextTests.swift new file mode 100644 index 000000000000..dc4c89f71706 --- /dev/null +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Nimbus/RecordedNimbusContextTests.swift @@ -0,0 +1,82 @@ +// 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 http://mozilla.org/MPL/2.0/ + +import Foundation +import Glean +import MozillaAppServices +import XCTest + +@testable import Client +@testable import Shared + +class RecordedNimbusContextTests: XCTestCase { + override func setUp() { + Glean.shared.enableTestingMode() + Glean.shared.resetGlean(clearStores: true) + } + + /** + * This test should not be modified. It will fail if any of the eventQueries are invalid. + */ + func testValidateEventQueries() throws { + let recordedContext = RecordedNimbusContext(isFirstRun: true, isReviewCheckerEnabled: true, isDefaultBrowser: true) + try validateEventQueries(recordedContext: recordedContext) + } + + func testToJsonReturnsExpected() throws { + let recordedContext = RecordedNimbusContext(isFirstRun: true, isReviewCheckerEnabled: true, isDefaultBrowser: true) + recordedContext.setEventQueryValues(eventQueryValues: [RecordedNimbusContext.DAYS_OPENED_IN_LAST_28: 1.5]) + let jsonString = recordedContext.toJson() + + var json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!) as? [String: Any] + XCTAssertNotNil(json) + XCTAssertEqual(json?.removeValue(forKey: "isFirstRun") as? String, "true") + XCTAssertEqual(json?.removeValue(forKey: "is_first_run") as? Bool, true) + XCTAssertEqual(json?.removeValue(forKey: "is_phone") as? Bool, recordedContext.isPhone) + XCTAssertEqual(json?.removeValue(forKey: "is_review_checker_enabled") as? Bool, true) + XCTAssertEqual(json?.removeValue(forKey: "app_version") as? String, recordedContext.appVersion) + XCTAssertEqual(json?.removeValue(forKey: "locale") as? String, recordedContext.locale) + XCTAssertEqual(json?.removeValue(forKey: "language") as? String, recordedContext.language) + XCTAssertEqual(json?.removeValue(forKey: "region") as? String, recordedContext.region) + XCTAssertEqual(json?.removeValue(forKey: "days_since_install") as? Int32, recordedContext.daysSinceInstall) + XCTAssertEqual(json?.removeValue(forKey: "days_since_update") as? Int32, recordedContext.daysSinceUpdate) + XCTAssertEqual(json?.removeValue(forKey: "is_default_browser") as? Bool, recordedContext.isDefaultBrowser) + + var events = json?.removeValue(forKey: "events") as? [String: Double] + XCTAssertNotNil(events) + XCTAssertEqual(events?.removeValue(forKey: RecordedNimbusContext.DAYS_OPENED_IN_LAST_28), 1.5) + XCTAssertEqual(events?.count, 0) + + XCTAssertEqual(json?.count, 0) + } + + func testObjectRecordedToGleanMatchesExpected() throws { + let recordedContext = RecordedNimbusContext(isFirstRun: true, isReviewCheckerEnabled: true, isDefaultBrowser: true) + recordedContext.setEventQueryValues(eventQueryValues: [RecordedNimbusContext.DAYS_OPENED_IN_LAST_28: 1.5]) + recordedContext.record() + let value = GleanMetrics.NimbusSystem.recordedNimbusContext.testGetValue() + + XCTAssertNotNil(value) + XCTAssertEqual(value?.appVersion, recordedContext.appVersion) + XCTAssertEqual(value?.isFirstRun, recordedContext.isFirstRun) + XCTAssertEqual(value?.isPhone, recordedContext.isPhone) + XCTAssertEqual(value?.isReviewCheckerEnabled, recordedContext.isReviewCheckerEnabled) + XCTAssertEqual(value?.locale, recordedContext.locale) + XCTAssertEqual(value?.region, recordedContext.region) + XCTAssertEqual(value?.language, recordedContext.language) + XCTAssertEqual(Int(value!.daysSinceInstall!), Int(recordedContext.daysSinceInstall!)) + XCTAssertEqual(Int(value!.daysSinceUpdate!), Int(recordedContext.daysSinceUpdate!)) + XCTAssertEqual(value?.isDefaultBrowser, recordedContext.isDefaultBrowser) + + XCTAssertNotNil(value?.eventQueryValues) + XCTAssertEqual(value?.eventQueryValues?.daysOpenedInLast28, 1) + } + + func testGetEventQueries() throws { + let recordedContext = RecordedNimbusContext(isFirstRun: true, isReviewCheckerEnabled: true, isDefaultBrowser: true) + let eventQueries = recordedContext.getEventQueries() + + XCTAssertEqual(eventQueries, RecordedNimbusContext.EVENT_QUERIES) + } +}