From 985a05468e9d5ed3aa675e205d41d477eef84272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Gomo=CC=81=C5=82ka?= Date: Tue, 24 Feb 2026 12:02:29 +0100 Subject: [PATCH] Add feature flag to user attribution service --- Modules/App/Sources/App/ProtonMailApp.swift | 21 ++++++++++++++++-- .../Sources/UI/Screens/Home/HomeScreen.swift | 11 +++------- .../UI/Screens/Settings/SettingsScreen.swift | 2 +- .../UI/Snooze/SnoozeViewSnapshotTests.swift | 4 ++-- .../Sources/AdAttributionService.swift | 2 ++ .../Sources/ConversionEvent.swift | 1 + .../Sources/ConversionValue.swift | 3 +++ .../Sources/UserAttributionService.swift | 21 +++++++----------- .../Tests/AdAttributionServiceTests.swift | 4 ++++ .../Tests/UserAttributionServiceTests.swift | 12 +++++----- Modules/InboxCore/Sources/FeatureFlag.swift | 22 +++++++++++++++++++ .../PurchaseActionPerformer.swift | 2 +- .../UserAttributionService+Dummy.swift | 2 +- .../Tests/UpsellCoordinatorTests.swift | 2 +- .../OnboardingUpsellScreenModelTests.swift | 2 +- .../PurchaseActionPerformerTests.swift | 2 +- .../UI/ShareScreen/ShareScreenModel.swift | 4 +++- 17 files changed, 79 insertions(+), 38 deletions(-) create mode 100644 Modules/InboxCore/Sources/FeatureFlag.swift diff --git a/Modules/App/Sources/App/ProtonMailApp.swift b/Modules/App/Sources/App/ProtonMailApp.swift index 7963b91ed9..9f5c4c0bfe 100644 --- a/Modules/App/Sources/App/ProtonMailApp.swift +++ b/Modules/App/Sources/App/ProtonMailApp.swift @@ -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 diff --git a/Modules/App/Sources/UI/Screens/Home/HomeScreen.swift b/Modules/App/Sources/UI/Screens/Home/HomeScreen.swift index 7dfeb990fb..4874c868a4 100644 --- a/Modules/App/Sources/UI/Screens/Home/HomeScreen.swift +++ b/Modules/App/Sources/UI/Screens/Home/HomeScreen.swift @@ -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, diff --git a/Modules/App/Sources/UI/Screens/Settings/SettingsScreen.swift b/Modules/App/Sources/UI/Screens/Settings/SettingsScreen.swift index 413fd48ce4..e39a0033ba 100644 --- a/Modules/App/Sources/UI/Screens/Settings/SettingsScreen.swift +++ b/Modules/App/Sources/UI/Screens/Settings/SettingsScreen.swift @@ -330,7 +330,7 @@ private extension SettingsPreference { upsellCoordinator: .init( mailUserSession: .dummy, userAttributionService: .init( - userSettingsProvider: { .mock() }, + isFeatureEnabled: { false }, userDefaults: UserDefaults() ), configuration: .mail diff --git a/Modules/App/Tests/Tests/Snapshots/UI/Snooze/SnoozeViewSnapshotTests.swift b/Modules/App/Tests/Tests/Snapshots/UI/Snooze/SnoozeViewSnapshotTests.swift index 607a08a97f..851eaa769e 100644 --- a/Modules/App/Tests/Tests/Snapshots/UI/Snooze/SnoozeViewSnapshotTests.swift +++ b/Modules/App/Tests/Tests/Snapshots/UI/Snooze/SnoozeViewSnapshotTests.swift @@ -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() ) diff --git a/Modules/InboxAttribution/Sources/AdAttributionService.swift b/Modules/InboxAttribution/Sources/AdAttributionService.swift index a6044269c1..8a69f5e94a 100644 --- a/Modules/InboxAttribution/Sources/AdAttributionService.swift +++ b/Modules/InboxAttribution/Sources/AdAttributionService.swift @@ -35,6 +35,8 @@ public actor AdAttributionService { let newFlags: ConversionValue = switch event { + case .appRun: + [.appRun] case .signedIn: [.signedIn] case .firstActionPerformed: diff --git a/Modules/InboxAttribution/Sources/ConversionEvent.swift b/Modules/InboxAttribution/Sources/ConversionEvent.swift index 06901b6f52..e185e731e9 100644 --- a/Modules/InboxAttribution/Sources/ConversionEvent.swift +++ b/Modules/InboxAttribution/Sources/ConversionEvent.swift @@ -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) diff --git a/Modules/InboxAttribution/Sources/ConversionValue.swift b/Modules/InboxAttribution/Sources/ConversionValue.swift index 7e6e278181..ef1466ebd7 100644 --- a/Modules/InboxAttribution/Sources/ConversionValue.swift +++ b/Modules/InboxAttribution/Sources/ConversionValue.swift @@ -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) diff --git a/Modules/InboxAttribution/Sources/UserAttributionService.swift b/Modules/InboxAttribution/Sources/UserAttributionService.swift index fadb1051cf..01e39fc94f 100644 --- a/Modules/InboxAttribution/Sources/UserAttributionService.swift +++ b/Modules/InboxAttribution/Sources/UserAttributionService.swift @@ -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) } } } diff --git a/Modules/InboxAttribution/Tests/AdAttributionServiceTests.swift b/Modules/InboxAttribution/Tests/AdAttributionServiceTests.swift index 81a6332502..1503537c18 100644 --- a/Modules/InboxAttribution/Tests/AdAttributionServiceTests.swift +++ b/Modules/InboxAttribution/Tests/AdAttributionServiceTests.swift @@ -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) diff --git a/Modules/InboxAttribution/Tests/UserAttributionServiceTests.swift b/Modules/InboxAttribution/Tests/UserAttributionServiceTests.swift index d750e37cc1..91ce384468 100644 --- a/Modules/InboxAttribution/Tests/UserAttributionServiceTests.swift +++ b/Modules/InboxAttribution/Tests/UserAttributionServiceTests.swift @@ -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) diff --git a/Modules/InboxCore/Sources/FeatureFlag.swift b/Modules/InboxCore/Sources/FeatureFlag.swift new file mode 100644 index 0000000000..c016ba1614 --- /dev/null +++ b/Modules/InboxCore/Sources/FeatureFlag.swift @@ -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" +} diff --git a/Modules/InboxIAP/Sources/UpsellScreen/PurchaseActionPerformer.swift b/Modules/InboxIAP/Sources/UpsellScreen/PurchaseActionPerformer.swift index 0005e9ab63..b438c925db 100644 --- a/Modules/InboxIAP/Sources/UpsellScreen/PurchaseActionPerformer.swift +++ b/Modules/InboxIAP/Sources/UpsellScreen/PurchaseActionPerformer.swift @@ -105,6 +105,6 @@ extension PurchaseActionPerformer { eventLoopPolling: DummyEventLoopPolling(), planPurchasing: DummyPlanPurchasing(), telemetryReporting: DummyTelemetryReporting(), - userAttributionService: .init(userSettingsProvider: { .mock() }, userDefaults: UserDefaults()) + userAttributionService: .init(isFeatureEnabled: { false }, userDefaults: UserDefaults()) ) } diff --git a/Modules/InboxIAP/Tests/Doubles/UserAttributionService+Dummy.swift b/Modules/InboxIAP/Tests/Doubles/UserAttributionService+Dummy.swift index 2f7541c2fe..51661548c6 100644 --- a/Modules/InboxIAP/Tests/Doubles/UserAttributionService+Dummy.swift +++ b/Modules/InboxIAP/Tests/Doubles/UserAttributionService+Dummy.swift @@ -22,7 +22,7 @@ import InboxAttribution extension UserAttributionService { static var dummy: UserAttributionService { UserAttributionService( - userSettingsProvider: { .mock() }, + isFeatureEnabled: { true }, userDefaults: UserDefaults(), conversionTracker: ConversionTrackerSpy() ) diff --git a/Modules/InboxIAP/Tests/UpsellCoordinatorTests.swift b/Modules/InboxIAP/Tests/UpsellCoordinatorTests.swift index ac18fa8f8f..3dd38393b9 100644 --- a/Modules/InboxIAP/Tests/UpsellCoordinatorTests.swift +++ b/Modules/InboxIAP/Tests/UpsellCoordinatorTests.swift @@ -39,7 +39,7 @@ final class UpsellCoordinatorTests { sessionForking: DummySessionForking(), telemetryReporting: telemetryReporting, userAttributionService: .init( - userSettingsProvider: { .mock() }, + isFeatureEnabled: { true }, userDefaults: UserDefaults(), conversionTracker: ConversionTrackerSpy() ), diff --git a/Modules/InboxIAP/Tests/UpsellScreen/Onboarding/OnboardingUpsellScreenModelTests.swift b/Modules/InboxIAP/Tests/UpsellScreen/Onboarding/OnboardingUpsellScreenModelTests.swift index 25005c6914..bf901d19f5 100644 --- a/Modules/InboxIAP/Tests/UpsellScreen/Onboarding/OnboardingUpsellScreenModelTests.swift +++ b/Modules/InboxIAP/Tests/UpsellScreen/Onboarding/OnboardingUpsellScreenModelTests.swift @@ -36,7 +36,7 @@ final class OnboardingUpsellScreenModelTests { planPurchasing: planPurchasing, telemetryReporting: DummyTelemetryReporting(), userAttributionService: .init( - userSettingsProvider: { .mock() }, + isFeatureEnabled: { true }, userDefaults: UserDefaults(), conversionTracker: ConversionTrackerSpy() ) diff --git a/Modules/InboxIAP/Tests/UpsellScreen/PurchaseActionPerformerTests.swift b/Modules/InboxIAP/Tests/UpsellScreen/PurchaseActionPerformerTests.swift index 8ea32f68f5..2cbc23eaee 100644 --- a/Modules/InboxIAP/Tests/UpsellScreen/PurchaseActionPerformerTests.swift +++ b/Modules/InboxIAP/Tests/UpsellScreen/PurchaseActionPerformerTests.swift @@ -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 ) diff --git a/Modules/TestableShareExtension/Sources/UI/ShareScreen/ShareScreenModel.swift b/Modules/TestableShareExtension/Sources/UI/ShareScreen/ShareScreenModel.swift index 16034a99c8..c316bca458 100644 --- a/Modules/TestableShareExtension/Sources/UI/ShareScreen/ShareScreenModel.swift +++ b/Modules/TestableShareExtension/Sources/UI/ShareScreen/ShareScreenModel.swift @@ -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