Skip to content

Commit 14716ea

Browse files
authored
Add FXIOS-13934 Add iOS support for Nimbus advanced targeting for ToU experience points (#30428)
* Add FXIOS-13934 Add iOS support for Nimbus advanced targeting for ToU experience points
1 parent 5dabfe9 commit 14716ea

File tree

8 files changed

+162
-2
lines changed

8 files changed

+162
-2
lines changed

firefox-ios/Client.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
032CE5F62EBA0C04007CCC0D /* ToUExperiencePointsCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 032CE5F52EBA0C04007CCC0D /* ToUExperiencePointsCalculatorTests.swift */; };
1011
033A5FCC2E5F001200A84E8A /* TermsOfUseTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A5FCB2E5F001200A84E8A /* TermsOfUseTelemetryTests.swift */; };
1112
033A62002E77FE9900A84E8A /* ResetTermsOfServiceAcceptancePage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A61FF2E77FE9900A84E8A /* ResetTermsOfServiceAcceptancePage.swift */; };
1213
033C94F72E2FBCDD00C2382F /* TermsOfUseMiddlewareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033C94F62E2FBCD300C2382F /* TermsOfUseMiddlewareTests.swift */; };
@@ -2546,6 +2547,7 @@
25462547
02C94BE0922C110AFF6944E3 /* bo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bo; path = bo.lproj/ClearPrivateDataConfirm.strings; sourceTree = "<group>"; };
25472548
02E547BF89DA742677E367E6 /* hi-IN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "hi-IN"; path = "hi-IN.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
25482549
02FC4A06A948593BE97BC508 /* zh-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-TW"; path = "zh-TW.lproj/LoginManager.strings"; sourceTree = "<group>"; };
2550+
032CE5F52EBA0C04007CCC0D /* ToUExperiencePointsCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToUExperiencePointsCalculatorTests.swift; sourceTree = "<group>"; };
25492551
033A5FCB2E5F001200A84E8A /* TermsOfUseTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfUseTelemetryTests.swift; sourceTree = "<group>"; };
25502552
033A61FF2E77FE9900A84E8A /* ResetTermsOfServiceAcceptancePage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetTermsOfServiceAcceptancePage.swift; sourceTree = "<group>"; };
25512553
033C94F62E2FBCD300C2382F /* TermsOfUseMiddlewareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsOfUseMiddlewareTests.swift; sourceTree = "<group>"; };
@@ -11407,6 +11409,7 @@
1140711409
033C94F32E2F839900C2382F /* TermsOfUse */ = {
1140811410
isa = PBXGroup;
1140911411
children = (
11412+
032CE5F52EBA0C04007CCC0D /* ToUExperiencePointsCalculatorTests.swift */,
1141011413
033A5FCB2E5F001200A84E8A /* TermsOfUseTelemetryTests.swift */,
1141111414
033C94F62E2FBCD300C2382F /* TermsOfUseMiddlewareTests.swift */,
1141211415
);
@@ -19775,6 +19778,7 @@
1977519778
E1463D042982D0240074E16E /* NotificationManagerTests.swift in Sources */,
1977619779
8AA0A6682CAC747500AC7EB3 /* HomepageDiffableDataSourceTests.swift in Sources */,
1977719780
C8E1BC0A28085AA700C62964 /* NimbusFeatureFlagLayerTests.swift in Sources */,
19781+
032CE5F62EBA0C04007CCC0D /* ToUExperiencePointsCalculatorTests.swift in Sources */,
1977819782
F80D53CF2A09A3350047ED14 /* RustSyncManagerTests.swift in Sources */,
1977919783
1D3C90882ACE1AF400304C87 /* RemoteTabPanelTests.swift in Sources */,
1978019784
43446CF02412DDBE00F5C643 /* UpdateViewModelTests.swift in Sources */,

firefox-ios/Client/Experiments/Experiments.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ enum Experiments {
207207
return prefsReader.hasAcceptedTermsOfUse()
208208
}
209209

210+
static func touExperiencePoints(region: String?) -> Int32 {
211+
let prefsReader = ProfilePrefsReader()
212+
return prefsReader.getTouExperiencePoints(region: region)
213+
}
214+
210215
private static func isAppleIntelligenceAvailable() -> Bool {
211216
guard #available(iOS 26, *) else { return false }
212217
#if canImport(FoundationModels)

firefox-ios/Client/Experiments/RecordedNimbusContext.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ final class RecordedNimbusContext: RecordedContext, @unchecked Sendable {
5454
var locale: String
5555
var daysSinceInstall: Int32?
5656
var daysSinceUpdate: Int32?
57+
var touExperiencePoints: Int32?
5758

5859
private var eventQueries: [String: String]
5960
private var eventQueryValues: [String: Double] = [:]
@@ -113,6 +114,7 @@ final class RecordedNimbusContext: RecordedContext, @unchecked Sendable {
113114
daysSinceUpdate = calculatedAttributes.daysSinceUpdate
114115
language = calculatedAttributes.language
115116
region = calculatedAttributes.region
117+
touExperiencePoints = Experiments.touExperiencePoints(region: region)
116118
self.logger.log("init end", level: .debug, category: .experiments)
117119
}
118120

@@ -154,7 +156,8 @@ final class RecordedNimbusContext: RecordedContext, @unchecked Sendable {
154156
hasEnabledTipsNotifications: hasEnabledTipsNotifications,
155157
hasAcceptedTermsOfUse: hasAcceptedTermsOfUse,
156158
isAppleIntelligenceAvailable: isAppleIntelligenceAvailable,
157-
cannotUseAppleIntelligence: cannotUseAppleIntelligence
159+
cannotUseAppleIntelligence: cannotUseAppleIntelligence,
160+
touExperiencePoints: touExperiencePoints.toInt64()
158161
)
159162
)
160163
GleanMetrics.Pings.shared.nimbus.submit()
@@ -199,7 +202,8 @@ final class RecordedNimbusContext: RecordedContext, @unchecked Sendable {
199202
"has_enabled_tips_notifications": hasEnabledTipsNotifications,
200203
"has_accepted_terms_of_use": hasAcceptedTermsOfUse,
201204
"is_apple_intelligence_available": isAppleIntelligenceAvailable,
202-
"cannot_use_apple_intelligence": cannotUseAppleIntelligence
205+
"cannot_use_apple_intelligence": cannotUseAppleIntelligence,
206+
"tou_experience_points": touExperiencePoints as Any
203207
]),
204208
let jsonString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as? String
205209
else {
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import Common
6+
import Shared
7+
8+
// Calculates ToU experience points (0-2) based on privacy settings
9+
// Used for Nimbus targeting: +1 for ETP Strict, +1 for disabling sponsored content
10+
struct ToUExperiencePointsCalculator {
11+
private let userDefaults: UserDefaultsInterface
12+
private let region: String?
13+
14+
// Countries that support sponsored content (determined by geolocation)
15+
// Source: https://mozilla-hub.atlassian.net/browse/FXIOS-13934
16+
private static let sponsoredContentSupportedCountries: Set<String> = [
17+
"AT", "BE", "BG", "CA", "CH", "CY", "CZ", "DE", "DK", "EE",
18+
"ES", "FI", "FR", "GB", "GR", "HR", "HU", "IE", "IS", "JP",
19+
"LT", "LV", "MT", "NL", "NO", "NZ", "PL", "PT", "RO", "SE",
20+
"SG", "SK", "US"
21+
]
22+
23+
init(userDefaults: UserDefaultsInterface, region: String?) {
24+
self.userDefaults = userDefaults
25+
self.region = region
26+
}
27+
28+
func calculatePoints() -> Int32 {
29+
var points: Int32 = 0
30+
if hasEnabledStrictTracking() {
31+
points += 1
32+
}
33+
if hasDisabledSponsoredContent() {
34+
points += 1
35+
}
36+
return points
37+
}
38+
39+
private func hasEnabledStrictTracking() -> Bool {
40+
let enabledKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.EnabledKey
41+
let strengthKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.StrengthKey
42+
43+
guard userDefaults.bool(forKey: enabledKey) else { return false }
44+
return userDefaults.string(forKey: strengthKey) == BlockingStrength.strict.rawValue
45+
}
46+
47+
private func hasDisabledSponsoredContent() -> Bool {
48+
// Only award points in countries where sponsored content is available
49+
guard let region = region,
50+
Self.sponsoredContentSupportedCountries.contains(region) else {
51+
return false
52+
}
53+
54+
let allShortcutsKey = ProfilePrefsReader.prefix + PrefsKeys.UserFeatureFlagPrefs.TopSiteSection
55+
let sponsoredKey = ProfilePrefsReader.prefix + PrefsKeys.FeatureFlags.SponsoredShortcuts
56+
57+
let allShortcutsEnabled = userDefaults.object(forKey: allShortcutsKey) as? Bool ?? true
58+
let sponsoredEnabled = userDefaults.object(forKey: sponsoredKey) as? Bool ?? true
59+
60+
// Award point if user disabled sponsored shortcuts specifically
61+
// OR if they disabled all shortcuts
62+
return !sponsoredEnabled || !allShortcutsEnabled
63+
}
64+
}

firefox-ios/Client/Glean/probes/metrics.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3588,6 +3588,8 @@ nimbus_system:
35883588
type: boolean
35893589
cannot_use_apple_intelligence:
35903590
type: boolean
3591+
tou_experience_points:
3592+
type: number
35913593
description: |
35923594
The Nimbus context object that is recorded to Glean
35933595
bugs:

firefox-ios/Client/ProfilePrefsReader.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,12 @@ struct ProfilePrefsReader {
5959

6060
return hasAcceptedToU || hasAcceptedToS
6161
}
62+
63+
/// Delegates calculation to ToUExperiencePointsCalculator
64+
/// Parameter region: The user's region code
65+
/// Returns: The calculated points (0, 1, or 2) based on user settings
66+
func getTouExperiencePoints(region: String?) -> Int32 {
67+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: region)
68+
return calculator.calculatePoints()
69+
}
6270
}

firefox-ios/firefox-ios-tests/Tests/ClientTests/Nimbus/RecordedNimbusContextTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ class RecordedNimbusContextTests: XCTestCase {
7676
json?.removeValue(forKey: "cannot_use_apple_intelligence") as? Bool,
7777
recordedContext.cannotUseAppleIntelligence
7878
)
79+
XCTAssertEqual(
80+
json?.removeValue(forKey: "tou_experience_points") as? Int32,
81+
recordedContext.touExperiencePoints
82+
)
7983

8084
var events = json?.removeValue(forKey: "events") as? [String: Double]
8185
XCTAssertNotNil(events)
@@ -127,6 +131,10 @@ class RecordedNimbusContextTests: XCTestCase {
127131
value?.hasAcceptedTermsOfUse,
128132
recordedContext.hasAcceptedTermsOfUse
129133
)
134+
XCTAssertEqual(
135+
value?.touExperiencePoints,
136+
recordedContext.touExperiencePoints.toInt64()
137+
)
130138

131139
XCTAssertNotNil(value?.eventQueryValues)
132140
XCTAssertEqual(value?.eventQueryValues?.daysOpenedInLast28, 1)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/
4+
5+
import XCTest
6+
import Shared
7+
@testable import Client
8+
9+
final class ToUExperiencePointsCalculatorTests: XCTestCase {
10+
var userDefaults: MockUserDefaults!
11+
12+
override func setUp() {
13+
super.setUp()
14+
userDefaults = MockUserDefaults()
15+
}
16+
17+
override func tearDown() {
18+
userDefaults = nil
19+
super.tearDown()
20+
}
21+
22+
func testCalculatePoints_ZeroPoints_WhenNoSettingsEnabled() {
23+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: "US")
24+
XCTAssertEqual(calculator.calculatePoints(), 0)
25+
}
26+
27+
func testCalculatePoints_ZeroPoints_WhenUnsupportedCountry() {
28+
let sponsoredKey = ProfilePrefsReader.prefix + PrefsKeys.FeatureFlags.SponsoredShortcuts
29+
userDefaults.set(false, forKey: sponsoredKey)
30+
31+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: "CN")
32+
XCTAssertEqual(calculator.calculatePoints(), 0)
33+
}
34+
35+
func testCalculatePoints_OnePoint_WhenETPStrict() {
36+
let enabledKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.EnabledKey
37+
let strengthKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.StrengthKey
38+
userDefaults.set(true, forKey: enabledKey)
39+
userDefaults.set(BlockingStrength.strict.rawValue, forKey: strengthKey)
40+
41+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: "US")
42+
XCTAssertEqual(calculator.calculatePoints(), 1)
43+
}
44+
45+
func testCalculatePoints_OnePoint_WhenSponsoredDisabled() {
46+
let sponsoredKey = ProfilePrefsReader.prefix + PrefsKeys.FeatureFlags.SponsoredShortcuts
47+
userDefaults.set(false, forKey: sponsoredKey)
48+
49+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: "US")
50+
XCTAssertEqual(calculator.calculatePoints(), 1)
51+
}
52+
53+
func testCalculatePoints_TwoPoints_WhenBothEnabled() {
54+
let enabledKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.EnabledKey
55+
let strengthKey = ProfilePrefsReader.prefix + ContentBlockingConfig.Prefs.StrengthKey
56+
let sponsoredKey = ProfilePrefsReader.prefix + PrefsKeys.FeatureFlags.SponsoredShortcuts
57+
58+
userDefaults.set(true, forKey: enabledKey)
59+
userDefaults.set(BlockingStrength.strict.rawValue, forKey: strengthKey)
60+
userDefaults.set(false, forKey: sponsoredKey)
61+
62+
let calculator = ToUExperiencePointsCalculator(userDefaults: userDefaults, region: "DE")
63+
XCTAssertEqual(calculator.calculatePoints(), 2)
64+
}
65+
}

0 commit comments

Comments
 (0)