mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-05-15 09:50:39 +00:00
Add feature flag to user attribution service
This commit is contained in:
@@ -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()
|
||||
),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user