Add feature flag to user attribution service

This commit is contained in:
Maciej Gomółka
2026-02-24 12:02:29 +01:00
parent 6eeff4f485
commit 985a05468e
17 changed files with 79 additions and 38 deletions
+19 -2
View File
@@ -17,6 +17,7 @@
import AccountManager
import Combine
import InboxAttribution
import InboxCore
import InboxCoreUI
import InboxDesignSystem
@@ -33,6 +34,7 @@ struct ProtonMailApp: App {
private let legacyMigrationStateStore: LegacyMigrationStateStore
private let refreshToolbarNotifier = RefreshToolbarNotifier()
private let toastStateStore = ToastStateStore(initialState: .initial)
private let userAttributionService: UserAttributionService
@StateObject var appAppearanceStore = AppAppearanceStore.shared
var body: some Scene {
@@ -46,11 +48,13 @@ struct ProtonMailApp: App {
.environmentObject(toastStateStore)
.environmentObject(appAppearanceStore)
.environmentObject(analytics)
.environmentObject(userAttributionService)
}
.task {
async let analytics: Void = configureAnalyticsIfNeeded(analytics: analytics)
async let updateColorScheme: Void = appAppearanceStore.updateColorScheme()
_ = await (analytics, updateColorScheme)
async let appRunEvent: Void = sendAttributionEvent()
_ = await (analytics, updateColorScheme, appRunEvent)
}
.preferredColorScheme(appAppearanceStore.colorScheme)
}
@@ -58,6 +62,12 @@ struct ProtonMailApp: App {
init() {
legacyMigrationStateStore = .init(toastStateStore: toastStateStore)
userAttributionService = .init(
isFeatureEnabled: {
try await AppContext.shared.mailSession.isFeatureEnabled(featureId: FeatureFlag.mmp).get()
},
userDefaults: AppContext.shared.userDefaults
)
DynamicFontSize.capSupportedSizeCategories()
}
@@ -66,6 +76,11 @@ struct ProtonMailApp: App {
await analytics.enable(configuration: .default)
}
}
func sendAttributionEvent() async {
try? await Task.sleep(for: .seconds(3))
await userAttributionService.handle(event: .appRun)
}
}
private struct RootView: View {
@@ -73,6 +88,7 @@ private struct RootView: View {
@EnvironmentObject private var legacyMigrationStateStore: LegacyMigrationStateStore
@EnvironmentObject private var toastStateStore: ToastStateStore
@EnvironmentObject private var analytics: Analytics
@EnvironmentObject private var userAttributionService: UserAttributionService
// The route determines the screen that will be rendered
@ObservedObject private var appContext: AppContext
@@ -125,7 +141,8 @@ private struct RootView: View {
appContext: appContext,
userSession: activeUserSession,
toastStateStore: toastStateStore,
analytics: analytics
analytics: analytics,
userAttributionService: userAttributionService
)
.id(activeUserSession.userId()) // Forces the child view to be recreated when the user account changes
@@ -46,6 +46,7 @@ struct HomeScreen: View {
@EnvironmentObject private var appUIStateStore: AppUIStateStore
@EnvironmentObject private var toastStateStore: ToastStateStore
@EnvironmentObject private var userAttributionService: UserAttributionService
@Environment(\.mainWindowSize) var mainWindowSize
@StateObject private var appRoute: AppRouteState
@StateObject private var composerCoordinator: ComposerCoordinator
@@ -53,7 +54,6 @@ struct HomeScreen: View {
@State private var messageQuickLook = MessageQuickLook()
@State private var modalState: ModalState?
@State private var isNotificationPromptPresented = false
@StateObject private var userAttributionService: UserAttributionService
@StateObject private var eventLoopErrorCoordinator: EventLoopErrorCoordinator
@StateObject private var upsellCoordinator: UpsellCoordinator
@StateObject private var userAnalyticsConfigurator: UserAnalyticsConfigurator
@@ -69,7 +69,8 @@ struct HomeScreen: View {
appContext: AppContext,
userSession: MailUserSession,
toastStateStore: ToastStateStore,
analytics: Analytics
analytics: Analytics,
userAttributionService: UserAttributionService
) {
_appRoute = .init(wrappedValue: .initialState)
_composerCoordinator = .init(wrappedValue: .init(userSession: userSession, toastStateStore: toastStateStore))
@@ -90,12 +91,6 @@ struct HomeScreen: View {
wrappedValue: EventLoopErrorCoordinator(userSession: userSession, toastStateStore: toastStateStore)
)
let userAttributionService = UserAttributionService(
userSettingsProvider: { try await userSession.userSettings().get() },
userDefaults: appContext.userDefaults
)
self._userAttributionService = .init(wrappedValue: userAttributionService)
let newUpsellCoordinator = UpsellCoordinator(
mailUserSession: userSession,
userAttributionService: userAttributionService,
@@ -330,7 +330,7 @@ private extension SettingsPreference {
upsellCoordinator: .init(
mailUserSession: .dummy,
userAttributionService: .init(
userSettingsProvider: { .mock() },
isFeatureEnabled: { false },
userDefaults: UserDefaults()
),
configuration: .mail
@@ -117,7 +117,7 @@ extension UpsellCoordinator {
UpsellCoordinator(
mailUserSession: .dummy,
userAttributionService: .init(
userSettingsProvider: { .mock() },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: ConversionTrackerDummy()
),
@@ -137,7 +137,7 @@ class ConversionTrackerDummy: ConversionTracker {
extension UserAttributionService {
static var dummy: UserAttributionService {
UserAttributionService(
userSettingsProvider: { .mock() },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: ConversionTrackerDummy()
)
@@ -35,6 +35,8 @@ public actor AdAttributionService {
let newFlags: ConversionValue =
switch event {
case .appRun:
[.appRun]
case .signedIn:
[.signedIn]
case .firstActionPerformed:
@@ -16,6 +16,7 @@
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
public enum ConversionEvent {
case appRun
case signedIn
case firstActionPerformed
case subscribed(metadata: SubscriptionPlanMetadata)
@@ -18,6 +18,9 @@
struct ConversionValue: OptionSet, Equatable {
let rawValue: UInt8
// Bit none: App run
static let appRun: ConversionValue = []
// Bit 0: Sign-in / account created
static let signedIn = ConversionValue(rawValue: 1 << 0)
@@ -20,31 +20,26 @@ import InboxCore
import proton_app_uniffi
public final class UserAttributionService: ObservableObject, Sendable {
private let userSettingsProvider: @Sendable () async throws -> UserSettings
private let isFeatureEnabled: @Sendable () async throws -> Bool?
private let adAttributionService: AdAttributionService
public init(
userSettingsProvider: @Sendable @escaping () async throws -> UserSettings,
isFeatureEnabled: @Sendable @escaping () async throws -> Bool?,
userDefaults: UserDefaults,
conversionTracker: ConversionTracker = ConversionTrackerFactory.make()
) {
self.userSettingsProvider = userSettingsProvider
self.isFeatureEnabled = isFeatureEnabled
self.adAttributionService = .init(conversionTracker: conversionTracker, userDefaults: userDefaults)
}
public func handle(event: ConversionEvent) async {
guard await isTelemetryEnabled() else { return }
await adAttributionService.handle(event: event)
}
// MARK: - Private
private func isTelemetryEnabled() async -> Bool {
do {
return try await userSettingsProvider().telemetry
guard try await isFeatureEnabled() == true else {
return
}
await adAttributionService.handle(event: event)
} catch {
AppLogger.log(error: error)
return false
AppLogger.log(error: error, category: .adAttribution)
}
}
}
@@ -43,6 +43,10 @@ struct AdAttributionServiceTests {
@Test(
arguments: [
ConversionTestCase(
events: [.appRun],
expectedFinalValue: .init(fineConversionValue: 0, coarseConversionValue: .low, lockPostback: false)
),
ConversionTestCase(
events: [.signedIn],
expectedFinalValue: .init(fineConversionValue: 1, coarseConversionValue: .low, lockPostback: false)
@@ -25,18 +25,18 @@ import proton_app_uniffi
class UserAttributionServiceTests {
var conversionTrackerSpy: ConversionTrackerSpy!
func makeSut(telemetryEnabled: Bool) -> UserAttributionService {
func makeSut(isFeatureEnabled: Bool) -> UserAttributionService {
conversionTrackerSpy = ConversionTrackerSpy()
return UserAttributionService(
userSettingsProvider: { .settings(crashReports: false, telemetry: telemetryEnabled) },
isFeatureEnabled: { isFeatureEnabled },
userDefaults: UserDefaults(suiteName: UUID().uuidString)!,
conversionTracker: conversionTrackerSpy
)
}
@Test
func telemetryEnabled_EventIsForwardedToAdAttributionService() async {
let sut = makeSut(telemetryEnabled: true)
func featureFlagIsEnabled_EventIsForwardedToAdAttributionService() async {
let sut = makeSut(isFeatureEnabled: true)
await sut.handle(event: .signedIn)
@@ -44,8 +44,8 @@ class UserAttributionServiceTests {
}
@Test
func telemetryDisabled_EventIsNotForwarded() async throws {
let sut = makeSut(telemetryEnabled: false)
func featureFlagIsDisabled_EventIsNotForwarded() async throws {
let sut = makeSut(isFeatureEnabled: false)
await sut.handle(event: .signedIn)
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import proton_app_uniffi
public enum FeatureFlag {
public static let mmp = "MailiOSMMP"
}
@@ -105,6 +105,6 @@ extension PurchaseActionPerformer {
eventLoopPolling: DummyEventLoopPolling(),
planPurchasing: DummyPlanPurchasing(),
telemetryReporting: DummyTelemetryReporting(),
userAttributionService: .init(userSettingsProvider: { .mock() }, userDefaults: UserDefaults())
userAttributionService: .init(isFeatureEnabled: { false }, userDefaults: UserDefaults())
)
}
@@ -22,7 +22,7 @@ import InboxAttribution
extension UserAttributionService {
static var dummy: UserAttributionService {
UserAttributionService(
userSettingsProvider: { .mock() },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: ConversionTrackerSpy()
)
@@ -39,7 +39,7 @@ final class UpsellCoordinatorTests {
sessionForking: DummySessionForking(),
telemetryReporting: telemetryReporting,
userAttributionService: .init(
userSettingsProvider: { .mock() },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: ConversionTrackerSpy()
),
@@ -36,7 +36,7 @@ final class OnboardingUpsellScreenModelTests {
planPurchasing: planPurchasing,
telemetryReporting: DummyTelemetryReporting(),
userAttributionService: .init(
userSettingsProvider: { .mock() },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: ConversionTrackerSpy()
)
@@ -40,7 +40,7 @@ final class PurchaseActionPerformerTests {
planPurchasing: planPurchasing,
telemetryReporting: telemetryReporting,
userAttributionService: .init(
userSettingsProvider: { .settings(crashReports: false, telemetry: true) },
isFeatureEnabled: { true },
userDefaults: UserDefaults(),
conversionTracker: conversionTrackerSpy
)
@@ -100,7 +100,9 @@ public final class ShareScreenModel: ObservableObject {
let upsellCoordinator = UpsellCoordinator(
mailUserSession: userSession,
userAttributionService: .init(
userSettingsProvider: { try await userSession.userSettings().get() },
isFeatureEnabled: {
try await userSession.isFeatureEnabled(featureId: FeatureFlag.mmp).get()
},
userDefaults: .standard
),
configuration: upsellConfiguration