From 5b7ec6c9e2f0e576987857bec90211f754c2fb6d Mon Sep 17 00:00:00 2001 From: Nicolas Mauri Date: Tue, 18 Apr 2023 09:33:32 +0200 Subject: [PATCH] Set up Analytics to track data per session (#780) --- Dangerfile.swift | 3 +- .../en.lproj/Localizable.strings | 16 ++ .../en.lproj/Untranslated.strings | 9 -- .../Sources/Application/AppCoordinator.swift | 19 ++- .../AppCoordinatorStateMachine.swift | 1 - .../Sources/Application/AppSettings.swift | 11 +- .../Sources/Application/ServiceLocator.swift | 12 ++ .../Generated/Strings+Untranslated.swift | 18 --- ElementX/Sources/Generated/Strings.swift | 46 ++++++ .../Mocks/Generated/GeneratedMocks.swift | 146 +++++++++++++++++ .../Analytics/ScreenTrackerViewModifier.swift | 36 +++++ .../AnalyticsPromptCoordinator.swift | 16 +- .../AnalyticsPromptModels.swift | 20 +-- .../AnalyticsPromptViewModel.swift | 8 +- .../View/AnalyticsPrompt.swift | 14 +- .../View/AnalyticsPromptCheckmarkItem.swift | 4 +- .../AnalyticsSettingsScreenCoordinator.swift | 30 ++++ .../AnalyticsSettingsScreenModels.swift | 46 ++++++ .../AnalyticsSettingsScreenViewModel.swift | 45 ++++++ ...yticsSettingsScreenViewModelProtocol.swift | 22 +++ .../View/AnalyticsSettingsScreen.swift | 56 +++++++ .../AuthenticationCoordinator.swift | 23 ++- .../Screens/HomeScreen/HomeScreenModels.swift | 3 - .../Screens/HomeScreen/View/HomeScreen.swift | 1 + .../Screens/RoomScreen/View/RoomScreen.swift | 1 + .../Settings/SettingsScreenCoordinator.swift | 15 +- .../Settings/SettingsScreenModels.swift | 4 +- .../Settings/SettingsScreenViewModel.swift | 4 +- .../Settings/View/SettingsScreen.swift | 10 +- .../Services/Analytics/Analytics.swift | 95 +++-------- .../Analytics/AnalyticsClientProtocol.swift | 9 +- .../Services/Analytics/AnalyticsScreen.swift | 150 ++++++++++++++++++ .../Services/Analytics/AnalyticsService.swift | 75 --------- .../Analytics/AnalyticsSettings.swift | 50 ------ .../Analytics/PostHogAnalyticsClient.swift | 12 +- .../Services/BugReport/BugReportService.swift | 43 +++-- .../BugReport/BugReportServiceProtocol.swift | 10 +- .../UITests/UITestsAppCoordinator.swift | 10 +- .../UITests/UITestsScreenIdentifier.swift | 1 + .../Sourcery/sourcery_automockable_config.yml | 2 +- .../AnalyticsSettingsScreenUITests.swift | 26 +++ ...GB-iPad-9th-generation.analyticsPrompt.png | 4 +- ...9th-generation.analyticsSettingsScreen.png | 3 + .../en-GB-iPad-9th-generation.settings.png | 4 +- .../en-GB-iPhone-14.analyticsPrompt.png | 4 +- ...n-GB-iPhone-14.analyticsSettingsScreen.png | 3 + .../Application/en-GB-iPhone-14.settings.png | 4 +- ...do-iPad-9th-generation.analyticsPrompt.png | 4 +- ...9th-generation.analyticsSettingsScreen.png | 3 + .../pseudo-iPad-9th-generation.settings.png | 4 +- .../pseudo-iPhone-14.analyticsPrompt.png | 4 +- ...eudo-iPhone-14.analyticsSettingsScreen.png | 3 + .../Application/pseudo-iPhone-14.settings.png | 4 +- ...nalyticsSettingsScreenViewModelTests.swift | 54 +++++++ UnitTests/Sources/AnalyticsTests.swift | 87 +++++++--- .../NotificationManagerTests.swift | 9 +- .../Sources/SettingsViewModelTests.swift | 11 ++ changelog.d/106.feature | 1 + 58 files changed, 953 insertions(+), 375 deletions(-) create mode 100644 ElementX/Sources/Other/Analytics/ScreenTrackerViewModifier.swift create mode 100644 ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenCoordinator.swift create mode 100644 ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenModels.swift create mode 100644 ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModel.swift create mode 100644 ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/AnalyticsSettings/View/AnalyticsSettingsScreen.swift create mode 100644 ElementX/Sources/Services/Analytics/AnalyticsScreen.swift delete mode 100644 ElementX/Sources/Services/Analytics/AnalyticsService.swift delete mode 100644 ElementX/Sources/Services/Analytics/AnalyticsSettings.swift create mode 100644 UITests/Sources/AnalyticsSettingsScreenUITests.swift create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsSettingsScreen.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsSettingsScreen.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsSettingsScreen.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsSettingsScreen.png create mode 100644 UnitTests/Sources/AnalyticsSettingsScreenViewModelTests.swift create mode 100644 changelog.d/106.feature diff --git a/Dangerfile.swift b/Dangerfile.swift index 85e4692fb..6fa7301f3 100644 --- a/Dangerfile.swift +++ b/Dangerfile.swift @@ -52,7 +52,8 @@ let allowList = ["stefanceriu", "aringenbach", "flescio", "Velin92", - "alfogrillo"] + "alfogrillo", + "nimau"] let requiresSignOff = !allowList.contains(where: { $0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 5a00e4990..404695a7c 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -53,6 +53,7 @@ "action_view_source" = "View Source"; "action_yes" = "Yes"; "common_about" = "About"; +"common_analytics" = "Analytics"; "common_audio" = "Audio"; "common_bubbles" = "Bubbles"; "common_creating_room" = "Creating room…"; @@ -161,6 +162,17 @@ "room_timeline_beginning_of_room" = "This is the beginning of %1$@."; "room_timeline_beginning_of_room_no_name" = "This is the beginning of this conversation."; "room_timeline_read_marker_title" = "New"; +"screen_analytics_help_us_improve" = "Help us identify issues and improve %1$@ by sharing anonymous usage data."; +"screen_analytics_prompt_data_usage" = "We don't record or profile any account data"; +"screen_analytics_prompt_help_us_improve" = "Help us identify issues and improve %1$@ by sharing anonymous usage data."; +"screen_analytics_prompt_read_terms" = "You can read all our terms %1$@."; +"screen_analytics_prompt_read_terms_content_link" = "here"; +"screen_analytics_prompt_settings" = "You can turn this off anytime in settings"; +"screen_analytics_prompt_third_party_sharing" = "We don't share information with third parties"; +"screen_analytics_prompt_title" = "Help improve %1$@"; +"screen_analytics_read_terms" = "You can read all our terms %1$@."; +"screen_analytics_read_terms_content_link" = "here"; +"screen_analytics_share_data" = "Share analytics data"; "screen_bug_report_attach_screenshot" = "Attach screenshot"; "screen_bug_report_contact_me" = "You may contact me if you have any follow up questions"; "screen_bug_report_edit_screenshot" = "Edit screenshot"; @@ -198,6 +210,10 @@ "screen_dm_details_unblock_alert_action" = "Unblock"; "screen_dm_details_unblock_alert_description" = "On unblocking the user, you will be able to see all messages by them again."; "screen_dm_details_unblock_user" = "Unblock user"; +"screen_invites_decline_chat_message" = "Are you sure you want to decline joining %1$@?"; +"screen_invites_decline_chat_title" = "Decline invite"; +"screen_invites_decline_direct_chat_message" = "Are you sure you want to decline to chat with %1$@?"; +"screen_invites_decline_direct_chat_title" = "Decline chat"; "screen_invites_empty_list" = "No Invites"; "screen_invites_invited_you" = "%1$@ invited you"; "screen_login_error_deactivated_account" = "This account has been deactivated."; diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 7b4d02329..6bfab1e5d 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -4,15 +4,6 @@ /* Used for testing */ "untranslated" = "Untranslated"; -// MARK: - Analytics - -"analytics_opt_in_title" = "Help improve %@"; -"analytics_opt_in_content" = "Help us identify issues and improve %@ by sharing anonymous usage data. To understand how people use multiple devices, we’ll generate a random identifier, shared by your devices.\n\nYou can read all our terms %@."; -"analytics_opt_in_content_link" = "here"; -"analytics_opt_in_list_item_1" = "We don\'t record or profile any account data"; -"analytics_opt_in_list_item_2" = "We don\'t share information with third parties"; -"analytics_opt_in_list_item_3" = "You can turn this off anytime in settings"; - // MARK: - Soft logout "soft_logout_forgot_password" = "Forgot password"; diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index f123a5d1b..f35a73a0c 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -42,7 +42,6 @@ class AppCoordinator: AppCoordinatorProtocol { private var userSessionFlowCoordinator: UserSessionFlowCoordinator? private var authenticationCoordinator: AuthenticationCoordinator? - private let bugReportService: BugReportServiceProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol private var userSessionCancellables = Set() @@ -56,11 +55,11 @@ class AppCoordinator: AppCoordinatorProtocol { Self.setupServiceLocator(navigationRootCoordinator: navigationRootCoordinator) Self.setupLogging() - - stateMachine = AppCoordinatorStateMachine() - - bugReportService = BugReportService(withBaseURL: ServiceLocator.shared.settings.bugReportServiceBaseURL, sentryURL: ServiceLocator.shared.settings.bugReportSentryURL) + ServiceLocator.shared.analytics.startIfEnabled() + + stateMachine = AppCoordinatorStateMachine() + navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator()) backgroundTaskService = UIKitBackgroundTaskService { @@ -84,7 +83,7 @@ class AppCoordinator: AppCoordinatorProtocol { wipeUserData(includingSettings: true) } ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description - + setupStateMachine() observeApplicationState() @@ -114,6 +113,9 @@ class AppCoordinator: AppCoordinatorProtocol { ServiceLocator.shared.register(userIndicatorController: UserIndicatorController(rootCoordinator: navigationRootCoordinator)) ServiceLocator.shared.register(appSettings: AppSettings()) ServiceLocator.shared.register(networkMonitor: NetworkMonitor()) + ServiceLocator.shared.register(bugReportService: BugReportService(withBaseURL: ServiceLocator.shared.settings.bugReportServiceBaseURL, + sentryURL: ServiceLocator.shared.settings.bugReportSentryURL)) + ServiceLocator.shared.register(analytics: Analytics(client: PostHogAnalyticsClient())) } private static func setupLogging() { @@ -248,7 +250,7 @@ class AppCoordinator: AppCoordinatorProtocol { let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator()) let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, navigationSplitCoordinator: navigationSplitCoordinator, - bugReportService: bugReportService, + bugReportService: ServiceLocator.shared.bugReportService, roomTimelineControllerFactory: RoomTimelineControllerFactory()) userSessionFlowCoordinator.callback = { [weak self] action in @@ -292,6 +294,9 @@ class AppCoordinator: AppCoordinatorProtocol { userSessionStore.logout(userSession: userSession) tearDownUserSession() + // reset analytics + ServiceLocator.shared.analytics.reset() + stateMachine.processEvent(.completedSigningOut(isSoft: isSoft)) } } diff --git a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift index 84a5fb08f..7183d14d5 100644 --- a/ElementX/Sources/Application/AppCoordinatorStateMachine.swift +++ b/ElementX/Sources/Application/AppCoordinatorStateMachine.swift @@ -68,7 +68,6 @@ class AppCoordinatorStateMachine { private func configure() { stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut]) stateMachine.addRoutes(event: .createdUserSession, transitions: [.signedOut => .signedIn]) - stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession]) stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn]) stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index c7b4d43e8..8fe678b30 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -23,7 +23,6 @@ final class AppSettings: ObservableObject { case lastVersionLaunched case timelineStyle case enableAnalytics - case isIdentifiedForAnalytics case enableInAppNotifications case pusherProfileTag case shouldCollapseRoomStateEvents @@ -104,7 +103,7 @@ final class AppSettings: ObservableObject { let bugReportUISIId = "element-auto-uisi" // MARK: - Analytics - + #if DEBUG /// The configuration to use for analytics during development. Set `isEnabled` to false to disable analytics in debug builds. /// **Note:** Analytics are disabled by default for forks. If you are maintaining a fork, set custom configurations. @@ -129,13 +128,7 @@ final class AppSettings: ObservableObject { /// `true` when the user has opted in to send analytics. @UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, persistIn: store) var enableAnalytics - - /// Indicates if the device has already called identify for this session to PostHog. - /// This is separate to `enableAnalytics` as logging out leaves analytics - /// enabled, but requires the next account to be identified separately. - @UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, persistIn: store) - var isIdentifiedForAnalytics - + // MARK: - Room Screen @UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, persistIn: store) diff --git a/ElementX/Sources/Application/ServiceLocator.swift b/ElementX/Sources/Application/ServiceLocator.swift index 2a674c62d..e31cdcb32 100644 --- a/ElementX/Sources/Application/ServiceLocator.swift +++ b/ElementX/Sources/Application/ServiceLocator.swift @@ -38,4 +38,16 @@ class ServiceLocator { func register(networkMonitor: NetworkMonitor) { self.networkMonitor = networkMonitor } + + private(set) var analytics: Analytics! + + func register(analytics: Analytics) { + self.analytics = analytics + } + + private(set) var bugReportService: BugReportServiceProtocol! + + func register(bugReportService: BugReportServiceProtocol) { + self.bugReportService = bugReportService + } } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 3d26c0517..ec4a4eca2 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -10,24 +10,6 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum UntranslatedL10n { - /// Help us identify issues and improve %@ by sharing anonymous usage data. To understand how people use multiple devices, we’ll generate a random identifier, shared by your devices. - /// - /// You can read all our terms %@. - public static func analyticsOptInContent(_ p1: Any, _ p2: Any) -> String { - return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_content", String(describing: p1), String(describing: p2)) - } - /// here - public static var analyticsOptInContentLink: String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_content_link") } - /// We don't record or profile any account data - public static var analyticsOptInListItem1: String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_list_item_1") } - /// We don't share information with third parties - public static var analyticsOptInListItem2: String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_list_item_2") } - /// You can turn this off anytime in settings - public static var analyticsOptInListItem3: String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_list_item_3") } - /// Help improve %@ - public static func analyticsOptInTitle(_ p1: Any) -> String { - return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_title", String(describing: p1)) - } /// Camera public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") } /// Document diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 063d0a6ad..f4639ed94 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -120,6 +120,8 @@ public enum L10n { public static var actionYes: String { return L10n.tr("Localizable", "action_yes") } /// About public static var commonAbout: String { return L10n.tr("Localizable", "common_about") } + /// Analytics + public static var commonAnalytics: String { return L10n.tr("Localizable", "common_analytics") } /// Audio public static var commonAudio: String { return L10n.tr("Localizable", "common_audio") } /// Bubbles @@ -390,6 +392,38 @@ public enum L10n { public static func roomTimelineStateChanges(_ p1: Int) -> String { return L10n.tr("Localizable", "room_timeline_state_changes", p1) } + /// Help us identify issues and improve %1$@ by sharing anonymous usage data. + public static func screenAnalyticsHelpUsImprove(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_analytics_help_us_improve", String(describing: p1)) + } + /// We don't record or profile any account data + public static var screenAnalyticsPromptDataUsage: String { return L10n.tr("Localizable", "screen_analytics_prompt_data_usage") } + /// Help us identify issues and improve %1$@ by sharing anonymous usage data. + public static func screenAnalyticsPromptHelpUsImprove(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_analytics_prompt_help_us_improve", String(describing: p1)) + } + /// You can read all our terms %1$@. + public static func screenAnalyticsPromptReadTerms(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_analytics_prompt_read_terms", String(describing: p1)) + } + /// here + public static var screenAnalyticsPromptReadTermsContentLink: String { return L10n.tr("Localizable", "screen_analytics_prompt_read_terms_content_link") } + /// You can turn this off anytime in settings + public static var screenAnalyticsPromptSettings: String { return L10n.tr("Localizable", "screen_analytics_prompt_settings") } + /// We don't share information with third parties + public static var screenAnalyticsPromptThirdPartySharing: String { return L10n.tr("Localizable", "screen_analytics_prompt_third_party_sharing") } + /// Help improve %1$@ + public static func screenAnalyticsPromptTitle(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_analytics_prompt_title", String(describing: p1)) + } + /// You can read all our terms %1$@. + public static func screenAnalyticsReadTerms(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_analytics_read_terms", String(describing: p1)) + } + /// here + public static var screenAnalyticsReadTermsContentLink: String { return L10n.tr("Localizable", "screen_analytics_read_terms_content_link") } + /// Share analytics data + public static var screenAnalyticsShareData: String { return L10n.tr("Localizable", "screen_analytics_share_data") } /// Attach screenshot public static var screenBugReportAttachScreenshot: String { return L10n.tr("Localizable", "screen_bug_report_attach_screenshot") } /// You may contact me if you have any follow up questions @@ -468,6 +502,18 @@ public enum L10n { public static var screenDmDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_description") } /// Unblock user public static var screenDmDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_dm_details_unblock_user") } + /// Are you sure you want to decline joining %1$@? + public static func screenInvitesDeclineChatMessage(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_invites_decline_chat_message", String(describing: p1)) + } + /// Decline invite + public static var screenInvitesDeclineChatTitle: String { return L10n.tr("Localizable", "screen_invites_decline_chat_title") } + /// Are you sure you want to decline to chat with %1$@? + public static func screenInvitesDeclineDirectChatMessage(_ p1: Any) -> String { + return L10n.tr("Localizable", "screen_invites_decline_direct_chat_message", String(describing: p1)) + } + /// Decline chat + public static var screenInvitesDeclineDirectChatTitle: String { return L10n.tr("Localizable", "screen_invites_decline_direct_chat_title") } /// No Invites public static var screenInvitesEmptyList: String { return L10n.tr("Localizable", "screen_invites_empty_list") } /// %1$@ invited you diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 2f1c08a56..6ff7f786c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -5,13 +5,159 @@ import Combine import Foundation import MatrixRustSDK +import AnalyticsEvents +class AnalyticsClientMock: AnalyticsClientProtocol { + var isRunning: Bool { + get { return underlyingIsRunning } + set(value) { underlyingIsRunning = value } + } + var underlyingIsRunning: Bool! + + //MARK: - start + + var startCallsCount = 0 + var startCalled: Bool { + return startCallsCount > 0 + } + var startClosure: (() -> Void)? + + func start() { + startCallsCount += 1 + startClosure?() + } + //MARK: - reset + + var resetCallsCount = 0 + var resetCalled: Bool { + return resetCallsCount > 0 + } + var resetClosure: (() -> Void)? + + func reset() { + resetCallsCount += 1 + resetClosure?() + } + //MARK: - stop + + var stopCallsCount = 0 + var stopCalled: Bool { + return stopCallsCount > 0 + } + var stopClosure: (() -> Void)? + + func stop() { + stopCallsCount += 1 + stopClosure?() + } + //MARK: - flush + + var flushCallsCount = 0 + var flushCalled: Bool { + return flushCallsCount > 0 + } + var flushClosure: (() -> Void)? + + func flush() { + flushCallsCount += 1 + flushClosure?() + } + //MARK: - capture + + var captureCallsCount = 0 + var captureCalled: Bool { + return captureCallsCount > 0 + } + var captureReceivedEvent: AnalyticsEventProtocol? + var captureReceivedInvocations: [AnalyticsEventProtocol] = [] + var captureClosure: ((AnalyticsEventProtocol) -> Void)? + + func capture(_ event: AnalyticsEventProtocol) { + captureCallsCount += 1 + captureReceivedEvent = event + captureReceivedInvocations.append(event) + captureClosure?(event) + } + //MARK: - screen + + var screenCallsCount = 0 + var screenCalled: Bool { + return screenCallsCount > 0 + } + var screenReceivedEvent: AnalyticsScreenProtocol? + var screenReceivedInvocations: [AnalyticsScreenProtocol] = [] + var screenClosure: ((AnalyticsScreenProtocol) -> Void)? + + func screen(_ event: AnalyticsScreenProtocol) { + screenCallsCount += 1 + screenReceivedEvent = event + screenReceivedInvocations.append(event) + screenClosure?(event) + } + //MARK: - updateUserProperties + + var updateUserPropertiesCallsCount = 0 + var updateUserPropertiesCalled: Bool { + return updateUserPropertiesCallsCount > 0 + } + var updateUserPropertiesReceivedUserProperties: AnalyticsEvent.UserProperties? + var updateUserPropertiesReceivedInvocations: [AnalyticsEvent.UserProperties] = [] + var updateUserPropertiesClosure: ((AnalyticsEvent.UserProperties) -> Void)? + + func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) { + updateUserPropertiesCallsCount += 1 + updateUserPropertiesReceivedUserProperties = userProperties + updateUserPropertiesReceivedInvocations.append(userProperties) + updateUserPropertiesClosure?(userProperties) + } +} class BugReportServiceMock: BugReportServiceProtocol { + var isRunning: Bool { + get { return underlyingIsRunning } + set(value) { underlyingIsRunning = value } + } + var underlyingIsRunning: Bool! var crashedLastRun: Bool { get { return underlyingCrashedLastRun } set(value) { underlyingCrashedLastRun = value } } var underlyingCrashedLastRun: Bool! + //MARK: - start + + var startCallsCount = 0 + var startCalled: Bool { + return startCallsCount > 0 + } + var startClosure: (() -> Void)? + + func start() { + startCallsCount += 1 + startClosure?() + } + //MARK: - stop + + var stopCallsCount = 0 + var stopCalled: Bool { + return stopCallsCount > 0 + } + var stopClosure: (() -> Void)? + + func stop() { + stopCallsCount += 1 + stopClosure?() + } + //MARK: - reset + + var resetCallsCount = 0 + var resetCalled: Bool { + return resetCallsCount > 0 + } + var resetClosure: (() -> Void)? + + func reset() { + resetCallsCount += 1 + resetClosure?() + } //MARK: - crash var crashCallsCount = 0 diff --git a/ElementX/Sources/Other/Analytics/ScreenTrackerViewModifier.swift b/ElementX/Sources/Other/Analytics/ScreenTrackerViewModifier.swift new file mode 100644 index 000000000..f23e49bdc --- /dev/null +++ b/ElementX/Sources/Other/Analytics/ScreenTrackerViewModifier.swift @@ -0,0 +1,36 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// `ScreenTrackerViewModifier` is a helper class used to track PostHog screen from SwiftUI screens. +struct ScreenTrackerViewModifier: ViewModifier { + let screen: AnalyticsScreen + + @ViewBuilder + func body(content: Content) -> some View { + content + .onAppear { + ServiceLocator.shared.analytics.track(screen: screen) + } + } +} + +extension View { + func track(screen: AnalyticsScreen) -> some View { + modifier(ScreenTrackerViewModifier(screen: screen)) + } +} diff --git a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptCoordinator.swift b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptCoordinator.swift index 9c7562468..b728a47a8 100644 --- a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptCoordinator.swift +++ b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptCoordinator.swift @@ -16,21 +16,13 @@ import SwiftUI -struct AnalyticsPromptCoordinatorParameters { - /// The user session to use if analytics are enabled. - let userSession: UserSessionProtocol -} - final class AnalyticsPromptCoordinator: CoordinatorProtocol { - private let parameters: AnalyticsPromptCoordinatorParameters private var viewModel: AnalyticsPromptViewModel var callback: (@MainActor () -> Void)? - init(parameters: AnalyticsPromptCoordinatorParameters) { - self.parameters = parameters - - viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) + init() { + viewModel = AnalyticsPromptViewModel() } // MARK: - Public @@ -42,11 +34,11 @@ final class AnalyticsPromptCoordinator: CoordinatorProtocol { switch result { case .enable: MXLog.info("Enable Analytics") - Analytics.shared.optIn(with: self.parameters.userSession) + ServiceLocator.shared.analytics.optIn() self.callback?() case .disable: MXLog.info("Disable Analytics") - Analytics.shared.optOut() + ServiceLocator.shared.analytics.optOut() self.callback?() } } diff --git a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptModels.swift b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptModels.swift index 982115645..cd709a9d1 100644 --- a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptModels.swift +++ b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptModels.swift @@ -32,22 +32,24 @@ enum AnalyticsPromptViewModelAction { struct AnalyticsPromptViewState: BindableState { /// Attributed strings created from localized HTML. - let strings = AnalyticsPromptStrings() + let strings: AnalyticsPromptStrings } /// A collection of strings for the UI that need to be parsed from HTML struct AnalyticsPromptStrings { let optInContent: AttributedString - let point1 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem1) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem1) - let point2 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem2) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem2) + let point1 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptDataUsage) ?? AttributedString(L10n.screenAnalyticsPromptDataUsage) + let point2 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptThirdPartySharing) ?? AttributedString(L10n.screenAnalyticsPromptThirdPartySharing) + let point3 = L10n.screenAnalyticsPromptSettings - init() { + init(termsURL: URL) { + let content = AttributedString(L10n.screenAnalyticsPromptHelpUsImprove(InfoPlistReader.main.bundleDisplayName)) // Create the opt in content with a placeholder. let linkPlaceholder = "{link}" - var optInContent = AttributedString(UntranslatedL10n.analyticsOptInContent(InfoPlistReader.main.bundleDisplayName, linkPlaceholder)) - optInContent.replace(linkPlaceholder, - with: UntranslatedL10n.analyticsOptInContentLink, - asLinkTo: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) - self.optInContent = optInContent + var readTerms = AttributedString(L10n.screenAnalyticsPromptReadTerms(linkPlaceholder)) + readTerms.replace(linkPlaceholder, + with: L10n.screenAnalyticsPromptReadTermsContentLink, + asLinkTo: termsURL) + optInContent = content + "\n\n" + readTerms } } diff --git a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptViewModel.swift b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptViewModel.swift index cf30d804a..4267de849 100644 --- a/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptViewModel.swift +++ b/ElementX/Sources/Screens/AnalyticsPrompt/AnalyticsPromptViewModel.swift @@ -20,14 +20,12 @@ import SwiftUI typealias AnalyticsPromptViewModelType = StateStoreViewModel class AnalyticsPromptViewModel: AnalyticsPromptViewModelType, AnalyticsPromptViewModelProtocol { - private let termsURL: URL - var callback: (@MainActor (AnalyticsPromptViewModelAction) -> Void)? /// Initialize a view model with the specified prompt type and app display name. - init(termsURL: URL) { - self.termsURL = termsURL - super.init(initialViewState: AnalyticsPromptViewState()) + init() { + let promptStrings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) + super.init(initialViewState: AnalyticsPromptViewState(strings: promptStrings)) } // MARK: - Public diff --git a/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPrompt.swift b/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPrompt.swift index 7646c9a3d..0079bbcb7 100644 --- a/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPrompt.swift +++ b/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPrompt.swift @@ -57,9 +57,9 @@ struct AnalyticsPrompt: View { private var mainContent: some View { VStack { Image(uiImage: Asset.Images.analyticsLogo.image) - .padding(.bottom, 25) + .padding(.bottom, 24) - Text(UntranslatedL10n.analyticsOptInTitle(InfoPlistReader.main.bundleDisplayName)) + Text(L10n.screenAnalyticsPromptTitle(InfoPlistReader.main.bundleDisplayName)) .font(.element.title2Bold) .multilineTextAlignment(.center) .foregroundColor(.element.primaryContent) @@ -73,7 +73,7 @@ struct AnalyticsPrompt: View { Divider() .background(Color.element.quinaryContent) - .padding(.vertical, 28) + .padding(.vertical, 20) checkmarkList } @@ -81,10 +81,10 @@ struct AnalyticsPrompt: View { /// The list of re-assurances about analytics. private var checkmarkList: some View { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point1) - AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point1) - AnalyticsPromptCheckmarkItem(string: UntranslatedL10n.analyticsOptInListItem3) + AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point2) + AnalyticsPromptCheckmarkItem(string: context.viewState.strings.point3) } .fixedSize(horizontal: false, vertical: true) .font(.element.body) @@ -113,7 +113,7 @@ struct AnalyticsPrompt: View { // MARK: - Previews struct AnalyticsPrompt_Previews: PreviewProvider { - static let viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) + static let viewModel = AnalyticsPromptViewModel() static var previews: some View { AnalyticsPrompt(context: viewModel.context) } diff --git a/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPromptCheckmarkItem.swift b/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPromptCheckmarkItem.swift index a124cf917..e90f06458 100644 --- a/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPromptCheckmarkItem.swift +++ b/ElementX/Sources/Screens/AnalyticsPrompt/View/AnalyticsPromptCheckmarkItem.swift @@ -29,7 +29,7 @@ struct AnalyticsPromptCheckmarkItem: View { var body: some View { Label { Text(attributedString) } icon: { - Image(uiImage: Asset.Images.analyticsCheckmark.image) + Image(systemName: "checkmark.circle") .foregroundColor(.element.accent) } } @@ -38,7 +38,7 @@ struct AnalyticsPromptCheckmarkItem: View { // MARK: - Previews struct AnalyticsPromptCheckmarkItem_Previews: PreviewProvider { - static let strings = AnalyticsPromptStrings() + static let strings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) static var previews: some View { VStack(alignment: .leading) { diff --git a/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenCoordinator.swift b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenCoordinator.swift new file mode 100644 index 000000000..a27dc0021 --- /dev/null +++ b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenCoordinator.swift @@ -0,0 +1,30 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +final class AnalyticsSettingsScreenCoordinator: CoordinatorProtocol { + private let viewModel: AnalyticsSettingsScreenViewModel + + init() { + viewModel = AnalyticsSettingsScreenViewModel() + } + + func toPresentable() -> AnyView { + AnyView(AnalyticsSettingsScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenModels.swift b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenModels.swift new file mode 100644 index 000000000..915a5642a --- /dev/null +++ b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenModels.swift @@ -0,0 +1,46 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct AnalyticsSettingsScreenViewState: BindableState { + /// Attributed strings created from localized HTML. + let strings: AnalyticsSettingsScreenStrings + var bindings: AnalyticsSettingsScreenViewStateBindings +} + +struct AnalyticsSettingsScreenViewStateBindings { + var enableAnalytics: Bool +} + +enum AnalyticsSettingsScreenViewAction { + case toggleAnalytics +} + +struct AnalyticsSettingsScreenStrings { + let sectionFooter: AttributedString + + init(termsURL: URL) { + let content = AttributedString(L10n.screenAnalyticsHelpUsImprove(InfoPlistReader.main.bundleDisplayName)) + // Create the 'read terms' with a placeholder. + let linkPlaceholder = "{link}" + var readTerms = AttributedString(L10n.screenAnalyticsReadTerms(linkPlaceholder)) + readTerms.replace(linkPlaceholder, + with: L10n.screenAnalyticsReadTermsContentLink, + asLinkTo: termsURL) + sectionFooter = content + "\n\n" + readTerms + } +} diff --git a/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModel.swift b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModel.swift new file mode 100644 index 000000000..d1a933ede --- /dev/null +++ b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModel.swift @@ -0,0 +1,45 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias AnalyticsSettingsScreenViewModelType = StateStoreViewModel + +class AnalyticsSettingsScreenViewModel: AnalyticsSettingsScreenViewModelType, AnalyticsSettingsScreenViewModelProtocol { + init() { + let strings = AnalyticsSettingsScreenStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) + let bindings = AnalyticsSettingsScreenViewStateBindings(enableAnalytics: ServiceLocator.shared.settings.enableAnalytics) + let state = AnalyticsSettingsScreenViewState(strings: strings, bindings: bindings) + + super.init(initialViewState: state) + + ServiceLocator.shared.settings.$enableAnalytics + .weakAssign(to: \.state.bindings.enableAnalytics, on: self) + .store(in: &cancellables) + } + + override func process(viewAction: AnalyticsSettingsScreenViewAction) { + switch viewAction { + case .toggleAnalytics: + if ServiceLocator.shared.settings.enableAnalytics { + ServiceLocator.shared.analytics.optOut() + } else { + ServiceLocator.shared.analytics.optIn() + } + } + } +} diff --git a/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModelProtocol.swift b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModelProtocol.swift new file mode 100644 index 000000000..e073e5424 --- /dev/null +++ b/ElementX/Sources/Screens/AnalyticsSettings/AnalyticsSettingsScreenViewModelProtocol.swift @@ -0,0 +1,22 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +@MainActor +protocol AnalyticsSettingsScreenViewModelProtocol { + var context: AnalyticsSettingsScreenViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/AnalyticsSettings/View/AnalyticsSettingsScreen.swift b/ElementX/Sources/Screens/AnalyticsSettings/View/AnalyticsSettingsScreen.swift new file mode 100644 index 000000000..f24ce3abc --- /dev/null +++ b/ElementX/Sources/Screens/AnalyticsSettings/View/AnalyticsSettingsScreen.swift @@ -0,0 +1,56 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct AnalyticsSettingsScreen: View { + @ObservedObject var context: AnalyticsSettingsScreenViewModel.Context + + var body: some View { + Form { + analyticsSection + } + .compoundForm() + .navigationTitle(L10n.commonAnalytics) + .navigationBarTitleDisplayMode(.inline) + } + + var analyticsSection: some View { + Section { + Toggle(isOn: $context.enableAnalytics) { + Label(L10n.screenAnalyticsShareData, systemImage: "chart.bar") + } + .toggleStyle(.compoundForm) + .onChange(of: context.enableAnalytics) { _ in + context.send(viewAction: .toggleAnalytics) + } + } footer: { + Text(context.viewState.strings.sectionFooter) + .compoundFormSectionFooter() + .tint(.compound.textLinkExternal) + } + .compoundFormSection() + } +} + +// MARK: - Previews + +struct AnalyticsSettingsScreen_Previews: PreviewProvider { + static var previews: some View { + let viewModel = AnalyticsSettingsScreenViewModel() + AnalyticsSettingsScreen(context: viewModel.context) + } +} diff --git a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift index a8a727d28..67386617c 100644 --- a/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Screens/Authentication/AuthenticationCoordinator.swift @@ -101,23 +101,30 @@ class AuthenticationCoordinator: CoordinatorProtocol { switch action { case .signedIn(let userSession): - self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) + self.userHasSignedIn(userSession: userSession) } } navigationStackCoordinator.push(coordinator) } - private func showAnalyticsPrompt(with userSession: UserSessionProtocol) { - let parameters = AnalyticsPromptCoordinatorParameters(userSession: userSession) - let coordinator = AnalyticsPromptCoordinator(parameters: parameters) - - coordinator.callback = { [weak self] in + private func userHasSignedIn(userSession: UserSessionProtocol) { + showAnalyticsPromptIfNeeded { [weak self] in guard let self else { return } self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) } - - navigationStackCoordinator.setRootCoordinator(coordinator) + } + + private func showAnalyticsPromptIfNeeded(completion: @escaping () -> Void) { + guard ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt else { + completion() + return + } + let coordinator = AnalyticsPromptCoordinator() + coordinator.callback = { + completion() + } + navigationStackCoordinator.push(coordinator) } static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading" diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift index 0e5a5d083..7bab99b08 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenModels.swift @@ -62,11 +62,8 @@ struct HomeScreenViewState: BindableState { let userID: String var userDisplayName: String? var userAvatarURL: URL? - var showSessionVerificationBanner = false - var rooms: [HomeScreenRoom] = [] - var roomListMode: HomeScreenRoomListMode = .skeletons /// The URL that will be shared when inviting friends to use the app. diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index c969bd62d..371102a0a 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -119,6 +119,7 @@ struct HomeScreen: View { } } .background(Color.element.background.ignoresSafeArea()) + .track(screen: .home) } @ViewBuilder diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 438fb75d8..eb7515f84 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -40,6 +40,7 @@ struct RoomScreen: View { .overlay { loadingIndicator } .alert(item: $context.alertInfo) { $0.alert } .sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) } + .track(screen: .room) .task(id: context.viewState.roomId) { // Give a couple of seconds for items to load and to see them. try? await Task.sleep(for: .seconds(2)) diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift index 9ea395b33..1f9f2aa50 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenCoordinator.swift @@ -46,8 +46,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { switch action { case .close: self.callback?(.dismiss) - case .toggleAnalytics: - self.toggleAnalytics() + case .analytics: + self.presentAnalyticsScreen() case .reportBug: self.presentBugReportScreen() case .sessionVerification: @@ -68,14 +68,11 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { // MARK: - Private - private func toggleAnalytics() { - if ServiceLocator.shared.settings.enableAnalytics { - Analytics.shared.optOut() - } else { - Analytics.shared.optIn(with: parameters.userSession) - } + private func presentAnalyticsScreen() { + let coordinator = AnalyticsSettingsScreenCoordinator() + parameters.navigationStackCoordinator?.push(coordinator) } - + private func presentBugReportScreen() { let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService, userID: parameters.userSession.userID, diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift index 43ad6ab4e..63f95a3f1 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenModels.swift @@ -19,7 +19,7 @@ import UIKit enum SettingsScreenViewModelAction { case close - case toggleAnalytics + case analytics case reportBug case sessionVerification case developerOptions @@ -42,7 +42,7 @@ struct SettingsScreenViewStateBindings { enum SettingsScreenViewAction { case close - case toggleAnalytics + case analytics case reportBug case sessionVerification case logout diff --git a/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift index 55f0fe403..913496515 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreenViewModel.swift @@ -77,8 +77,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo switch viewAction { case .close: callback?(.close) - case .toggleAnalytics: - callback?(.toggleAnalytics) + case .analytics: + callback?(.analytics) case .reportBug: callback?(.reportBug) case .logout: diff --git a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift index 1139eb154..445c80c41 100644 --- a/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift +++ b/ElementX/Sources/Screens/Settings/View/SettingsScreen.swift @@ -92,7 +92,7 @@ struct SettingsScreen: View { Label(L10n.commonDeveloperOptions, systemImage: "hammer.circle") } .buttonStyle(.compoundForm(accessory: .navigationLink)) - .accessibilityIdentifier("sessionVerificationButton") + .accessibilityIdentifier("developerOptionsButton") } .compoundFormSection() } @@ -113,6 +113,14 @@ struct SettingsScreen: View { context.send(viewAction: .changedTimelineStyle) } + // Analytics + Button { context.send(viewAction: .analytics) } label: { + Label(L10n.commonAnalytics, systemImage: "chart.bar") + } + .buttonStyle(.compoundForm(accessory: .navigationLink)) + .accessibilityIdentifier("analyticsButton") + + // Report Bug Button { context.send(viewAction: .reportBug) } label: { Label(L10n.actionReportBug, systemImage: "questionmark.circle") } diff --git a/ElementX/Sources/Services/Analytics/Analytics.swift b/ElementX/Sources/Services/Analytics/Analytics.swift index 16789b761..844dc75d7 100644 --- a/ElementX/Sources/Services/Analytics/Analytics.swift +++ b/ElementX/Sources/Services/Analytics/Analytics.swift @@ -31,18 +31,13 @@ import PostHog /// into `main`, update the AnalyticsEvents Swift package in `project.yml`. /// class Analytics { - /// The singleton instance to be used within the Riot target. - static let shared = Analytics() - /// The analytics client to send events with. - private var client: AnalyticsClientProtocol = PostHogAnalyticsClient() - -// /// The monitoring client to track crashes, issues and performance -// private var monitoringClient = SentryMonitoringClient() - - /// The service used to interact with account data settings. - private var service: AnalyticsService? - + private let client: AnalyticsClientProtocol + + init(client: AnalyticsClientProtocol) { + self.client = client + } + /// Whether or not the object is enabled and sending events to the server. var isRunning: Bool { client.isRunning } @@ -53,13 +48,9 @@ class Analytics { } /// Opts in to analytics tracking with the supplied user session. - /// - Parameter userSession: The user session to use to when reading/generating the analytics ID. - /// The session will be ignored if not running. - func optIn(with userSession: UserSessionProtocol) { + func optIn() { ServiceLocator.shared.settings.enableAnalytics = true startIfEnabled() - - Task { await useAnalyticsSettings(from: userSession) } } /// Stops analytics tracking and calls `reset` to clear any IDs and event queues. @@ -69,8 +60,7 @@ class Analytics { // The order is important here. PostHog ignores the reset if stopped. reset() client.stop() -// monitoringClient.stop() - + ServiceLocator.shared.bugReportService.stop() MXLog.info("Stopped.") } @@ -79,38 +69,12 @@ class Analytics { guard ServiceLocator.shared.settings.enableAnalytics, !isRunning else { return } client.start() -// monitoringClient.start() - + ServiceLocator.shared.bugReportService.start() + // Sanity check in case something went wrong. guard client.isRunning else { return } MXLog.info("Started.") - - // Catch and log crashes -// MXLogger.logCrashes(true) -// MXLogger.setBuildVersion(Bundle.bundleShortVersionString) - } - - /// Use the analytics settings from the supplied user session to configure analytics. - /// For now this is only used for (pseudonymous) identification. - /// - Parameter userSession: The user session to read analytics settings from. - func useAnalyticsSettings(from userSession: UserSessionProtocol) async { - guard - ServiceLocator.shared.settings.enableAnalytics, - !ServiceLocator.shared.settings.isIdentifiedForAnalytics - else { return } - - let service = AnalyticsService(userSession: userSession) - self.service = service - - switch await service.settings() { - case .success(let settings): - identify(with: settings) - self.service = nil - case .failure: - MXLog.error("Failed to use analytics settings. Will continue to run without analytics ID.") - self.service = nil - } } /// Resets the any IDs and event queues in the analytics client. This method should @@ -119,13 +83,8 @@ class Analytics { /// Note: **MUST** be called before stopping PostHog or the reset is ignored. func reset() { client.reset() -// monitoringClient.reset() - + ServiceLocator.shared.bugReportService.reset() MXLog.info("Reset.") - ServiceLocator.shared.settings.isIdentifiedForAnalytics = false - - // Stop collecting crash logs -// MXLogger.logCrashes(false) } /// Flushes the event queue in the analytics client, uploading all pending events. @@ -137,33 +96,23 @@ class Analytics { // MARK: - Private - /// Identify (pseudonymously) any future events with the ID from the analytics account data settings. - /// - Parameter settings: The settings to use for identification. The ID must be set *before* calling this method. - private func identify(with settings: AnalyticsSettings) { - guard let id = settings.id else { - MXLog.error("identify(with:) called before an ID has been generated.") - return - } - - client.identify(id: id) - MXLog.info("Identified.") - ServiceLocator.shared.settings.isIdentifiedForAnalytics = true - } - /// Capture an event in the `client`. /// - Parameter event: The event to capture. private func capture(event: AnalyticsEventProtocol) { + MXLog.debug("\(event)") client.capture(event) } } // MARK: - Public tracking methods -// The following methods are exposed for compatibility with Objective-C as -// the `capture` method and the generated events cannot be bridged from Swift. -extension Analytics { } - -// MARK: - MXAnalyticsDelegate - -// extension Analytics: MXAnalyticsDelegate { -// } +extension Analytics { + /// Track the presentation of a screen + /// - Parameter screen: The screen that was shown + /// - Parameter duration: An optional value representing how long the screen was shown for in milliseconds. + func track(screen: AnalyticsScreen, duration milliseconds: Int? = nil) { + MXLog.debug("\(screen)") + let event = AnalyticsEvent.MobileScreen(durationMs: milliseconds, screenName: screen.screenName) + client.screen(event) + } +} diff --git a/ElementX/Sources/Services/Analytics/AnalyticsClientProtocol.swift b/ElementX/Sources/Services/Analytics/AnalyticsClientProtocol.swift index f601a1095..a399e372c 100644 --- a/ElementX/Sources/Services/Analytics/AnalyticsClientProtocol.swift +++ b/ElementX/Sources/Services/Analytics/AnalyticsClientProtocol.swift @@ -23,11 +23,7 @@ protocol AnalyticsClientProtocol { /// Starts the analytics client reporting data. func start() - - /// Associate the client with an ID. This is persisted until `reset` is called. - /// - Parameter id: The ID to associate with the user. - func identify(id: String) - + /// Reset all stored properties and any event queues on the client. Note that /// the client will remain active, but in a fresh unidentified state. func reset() @@ -54,3 +50,6 @@ protocol AnalyticsClientProtocol { /// as part of the next event that gets captured. func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) } + +// sourcery: AutoMockable +extension AnalyticsClientProtocol { } diff --git a/ElementX/Sources/Services/Analytics/AnalyticsScreen.swift b/ElementX/Sources/Services/Analytics/AnalyticsScreen.swift new file mode 100644 index 000000000..174d4be40 --- /dev/null +++ b/ElementX/Sources/Services/Analytics/AnalyticsScreen.swift @@ -0,0 +1,150 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AnalyticsEvents +import Foundation + +enum AnalyticsScreen: Int { + case welcome + case login + case register + case forgotPassword + case sidebar + case home + case favourites + case people + case rooms + case searchRooms + case searchMessages + case searchPeople + case searchFiles + case room + case roomPreview + case roomDetails + case roomMembers + case user + case roomSearch + case roomUploads + case roomSettings + case roomNotifications + case roomDirectory + case switchDirectory + case startChat + case createRoom + case settings + case settingsSecurity + case settingsDefaultNotifications + case settingsMentionsAndKeywords + case settingsNotifications + case deactivateAccount + case inviteFriends + case threadList + case spaceMenu + case spaceMembers + case spaceExploreRooms + case dialpad + case spaceBottomSheet + case invites + case createSpace + + /// The screen name reported to the AnalyticsEvent. + var screenName: AnalyticsEvent.MobileScreen.ScreenName { + switch self { + case .welcome: + return .Welcome + case .login: + return .Login + case .register: + return .Register + case .forgotPassword: + return .ForgotPassword + case .sidebar: + return .Sidebar + case .home: + return .Home + case .favourites: + return .Favourites + case .people: + return .People + case .rooms: + return .Rooms + case .searchRooms: + return .SearchRooms + case .searchMessages: + return .SearchMessages + case .searchPeople: + return .SearchPeople + case .searchFiles: + return .SearchFiles + case .room: + return .Room + case .roomDetails: + return .RoomDetails + case .roomMembers: + return .RoomMembers + case .user: + return .User + case .roomPreview: + return .RoomPreview + case .roomSearch: + return .RoomSearch + case .roomUploads: + return .RoomUploads + case .roomSettings: + return .RoomSettings + case .roomNotifications: + return .RoomNotifications + case .roomDirectory: + return .RoomDirectory + case .switchDirectory: + return .SwitchDirectory + case .startChat: + return .StartChat + case .createRoom: + return .CreateRoom + case .settings: + return .Settings + case .settingsSecurity: + return .SettingsSecurity + case .settingsDefaultNotifications: + return .SettingsDefaultNotifications + case .settingsMentionsAndKeywords: + return .SettingsMentionsAndKeywords + case .settingsNotifications: + return .SettingsNotifications + case .deactivateAccount: + return .DeactivateAccount + case .inviteFriends: + return .InviteFriends + case .threadList: + return .ThreadList + case .spaceMenu: + return .SpaceMenu + case .spaceMembers: + return .SpaceMembers + case .spaceExploreRooms: + return .SpaceExploreRooms + case .dialpad: + return .Dialpad + case .spaceBottomSheet: + return .SpaceBottomSheet + case .invites: + return .Invites + case .createSpace: + return .CreateSpace + } + } +} diff --git a/ElementX/Sources/Services/Analytics/AnalyticsService.swift b/ElementX/Sources/Services/Analytics/AnalyticsService.swift deleted file mode 100644 index ce1e9b826..000000000 --- a/ElementX/Sources/Services/Analytics/AnalyticsService.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -enum AnalyticsServiceError: Error { - /// The user session supplied to the service does not have a state of `MXSessionStateRunning`. - case sessionIsNotRunning - /// The service failed to get or update the analytics settings event from the user's account data. - case accountDataFailure -} - -/// A service responsible for handling the `im.vector.analytics` event from the user's account data. -class AnalyticsService { - let userSession: UserSessionProtocol - - /// Creates an analytics service with the supplied user session. - /// - Parameter userSession: The user session to use when reading analytics settings from account data. - init(userSession: UserSessionProtocol) { - self.userSession = userSession - } - - /// The analytics settings for the current user. Calling this method will check whether the settings already - /// contain an `id` property and if not, will add one to the account data before calling the completion. - /// - Parameter completion: A completion handler that will be called when the request completes. - /// - /// The request will fail if the service's session does not have the `MXSessionStateRunning` state. - func settings() async -> Result { - // Only use the session if it is running otherwise we could wipe out an existing analytics ID. - fatalWithoutUnreachableCodeWarning() -// guard userSession.state == .running else { -// MXLog.warning("Aborting attempt to read analytics settings. The session may not be up-to-date.") -// return .failure(.sessionIsNotRunning) -// } - - let result: Result = await userSession.clientProxy.accountDataEvent(type: AnalyticsSettings.eventType) - switch result { - case .failure: - return .failure(.accountDataFailure) - case .success(let settings): - // The id has already be set so we are done here. - if let settings, settings.id != nil { - return .success(settings) - } - - let newSettings = AnalyticsSettings.new(currentEvent: settings) - switch await userSession.clientProxy.setAccountData(content: newSettings, type: AnalyticsSettings.eventType) { - case .failure: - MXLog.error("Failed to update analytics settings.") - return .failure(.accountDataFailure) - case .success: - MXLog.debug("Successfully updated analytics settings in account data.") - return .success(newSettings) - } - } - } - - /// Silences a warning on some intentionally unreachable code. - func fatalWithoutUnreachableCodeWarning() { - fatalError("Missing running state detection.") - } -} diff --git a/ElementX/Sources/Services/Analytics/AnalyticsSettings.swift b/ElementX/Sources/Services/Analytics/AnalyticsSettings.swift deleted file mode 100644 index 96305223c..000000000 --- a/ElementX/Sources/Services/Analytics/AnalyticsSettings.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// Copyright 2021 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -/// An analytics settings event from the user's account data. -struct AnalyticsSettings: Codable { - static let eventType = "im.vector.analytics" - - /// A randomly generated analytics token for this user. - /// This is suggested to be a UUID string. - let id: String? - - /// Whether the user has opted in on web or not. This is unused on iOS but necessary - /// to store here so that it's value is preserved when updating the account data if we - /// generated an ID on iOS. - /// - /// `true` if opted in on web, `false` if opted out on web and `nil` if the web prompt is not yet seen. - private let webOptIn: Bool? - - enum CodingKeys: String, CodingKey { - case id - case webOptIn = "pseudonymousAnalyticsOptIn" - } -} - -extension AnalyticsSettings { - /// Generates a new AnalyticsSettings value (inc an ID if necessary) based upon an - /// existing value. This is the only way the type should be created so as to avoid wiping - /// out the `webOptIn` value that the user may already have set. - /// - /// **Note:** Please don't pass a `nil` literal to this method. - static func new(currentEvent: AnalyticsSettings?) -> AnalyticsSettings { - AnalyticsSettings(id: currentEvent?.id ?? UUID().uuidString, - webOptIn: currentEvent?.webOptIn) - } -} diff --git a/ElementX/Sources/Services/Analytics/PostHogAnalyticsClient.swift b/ElementX/Sources/Services/Analytics/PostHogAnalyticsClient.swift index 6a2f2381f..7f6e3cd56 100644 --- a/ElementX/Sources/Services/Analytics/PostHogAnalyticsClient.swift +++ b/ElementX/Sources/Services/Analytics/PostHogAnalyticsClient.swift @@ -38,16 +38,6 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { postHog?.enable() } - func identify(id: String) { - if let userProperties = pendingUserProperties { - // As user properties overwrite old ones, compactMap the dictionary to avoid resetting any missing properties - postHog?.identify(id, properties: userProperties.properties.compactMapValues { $0 }) - pendingUserProperties = nil - } else { - postHog?.identify(id) - } - } - func reset() { postHog?.reset() pendingUserProperties = nil @@ -65,10 +55,12 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol { } func capture(_ event: AnalyticsEventProtocol) { + guard isRunning else { return } postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties)) } func screen(_ event: AnalyticsScreenProtocol) { + guard isRunning else { return } postHog?.screen(event.screenName.rawValue, properties: attachUserProperties(to: event.properties)) } diff --git a/ElementX/Sources/Services/BugReport/BugReportService.swift b/ElementX/Sources/Services/BugReport/BugReportService.swift index 7e08e07ef..b7b3e5aa6 100644 --- a/ElementX/Sources/Services/BugReport/BugReportService.swift +++ b/ElementX/Sources/Services/BugReport/BugReportService.swift @@ -39,13 +39,27 @@ class BugReportService: NSObject, BugReportServiceProtocol { self.session = session super.init() - // enable SentrySDK + // set build version for logger + MXLogger.buildVersion = InfoPlistReader.main.bundleShortVersionString + } + + // MARK: - BugReportServiceProtocol + + var isRunning: Bool { + SentrySDK.isEnabled + } + + var crashedLastRun: Bool { + SentrySDK.crashedLastRun + } + + func start() { + guard !isRunning else { return } SentrySDK.start { options in #if DEBUG options.enabled = false #endif - - options.dsn = sentryURL.absoluteString + options.dsn = self.sentryURL.absoluteString // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // We recommend adjusting this value in production. @@ -61,19 +75,22 @@ class BugReportService: NSObject, BugReportServiceProtocol { self?.lastCrashEventId = event.eventId.sentryIdString } } - - // also enable logging crashes, to send them with bug reports MXLogger.logCrashes(true) - // set build version for logger - MXLogger.buildVersion = InfoPlistReader.main.bundleShortVersionString + MXLog.info("Started.") } - - // MARK: - BugReportServiceProtocol - - var crashedLastRun: Bool { - SentrySDK.crashedLastRun + + func stop() { + guard isRunning else { return } + SentrySDK.close() + MXLogger.logCrashes(false) + MXLog.info("Stopped.") } - + + func reset() { + lastCrashEventId = nil + MXLog.info("Reset.") + } + func crash() { SentrySDK.crash() } diff --git a/ElementX/Sources/Services/BugReport/BugReportServiceProtocol.swift b/ElementX/Sources/Services/BugReport/BugReportServiceProtocol.swift index d217523d3..445d5e09b 100644 --- a/ElementX/Sources/Services/BugReport/BugReportServiceProtocol.swift +++ b/ElementX/Sources/Services/BugReport/BugReportServiceProtocol.swift @@ -33,10 +33,18 @@ struct SubmitBugReportResponse: Decodable { // sourcery: AutoMockable protocol BugReportServiceProtocol { + var isRunning: Bool { get } + var crashedLastRun: Bool { get } + + func start() + + func stop() + + func reset() func crash() - + func submitBugReport(_ bugReport: BugReport, progressListener: ProgressListener?) async throws -> SubmitBugReportResponse } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 3ed498f80..aebec2764 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -31,6 +31,8 @@ class UITestsAppCoordinator: AppCoordinatorProtocol { AppSettings.configureWithSuiteName("io.element.elementx.uitests") AppSettings.reset() ServiceLocator.shared.register(appSettings: AppSettings()) + ServiceLocator.shared.register(bugReportService: BugReportServiceMock()) + ServiceLocator.shared.register(analytics: Analytics(client: AnalyticsClientMock())) } func start() { @@ -76,8 +78,12 @@ class MockScreen: Identifiable { userIndicatorController: MockUserIndicatorController(), isModallyPresented: false)) case .analyticsPrompt: - return AnalyticsPromptCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), - mediaProvider: MockMediaProvider()))) + return AnalyticsPromptCoordinator() + case .analyticsSettingsScreen: + let navigationStackCoordinator = NavigationStackCoordinator() + let coordinator = AnalyticsSettingsScreenCoordinator() + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator case .authenticationFlow: let navigationStackCoordinator = NavigationStackCoordinator() let coordinator = AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(), diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index f44d74d72..1d8f2b8d3 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -23,6 +23,7 @@ enum UITestsScreenIdentifier: String { case authenticationFlow case softLogout case analyticsPrompt + case analyticsSettingsScreen case simpleRegular case simpleUpgrade case home diff --git a/Tools/Sourcery/sourcery_automockable_config.yml b/Tools/Sourcery/sourcery_automockable_config.yml index 5bfc137c1..37752243e 100644 --- a/Tools/Sourcery/sourcery_automockable_config.yml +++ b/Tools/Sourcery/sourcery_automockable_config.yml @@ -6,4 +6,4 @@ output: ../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift args: automMockableTestableImports: [] - autoMockableImports: [Combine, Foundation, MatrixRustSDK] \ No newline at end of file + autoMockableImports: [Combine, Foundation, MatrixRustSDK, AnalyticsEvents] diff --git a/UITests/Sources/AnalyticsSettingsScreenUITests.swift b/UITests/Sources/AnalyticsSettingsScreenUITests.swift new file mode 100644 index 000000000..58b37e642 --- /dev/null +++ b/UITests/Sources/AnalyticsSettingsScreenUITests.swift @@ -0,0 +1,26 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import ElementX +import XCTest + +class AnalyticsSettingsScreenUITests: XCTestCase { + /// Verify that the analytics option screen is displayed correctly. + func testAnalyticsSettingsScreen() { + let app = Application.launch(.analyticsSettingsScreen) + app.assertScreenshot(.analyticsSettingsScreen) + } +} diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsPrompt.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsPrompt.png index f82677162..14aeeb5b8 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsPrompt.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsPrompt.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:97e77a883d7803bf18ace6204966badb5f76f382f6b8900b7aee9f8d1fcf8d7b -size 135715 +oid sha256:aa03abfa975225c44719b6ccf91f2a36c15e36706c4a8e7dd3ab8ceba1394290 +size 122070 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsSettingsScreen.png new file mode 100644 index 000000000..ebab2b2e8 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.analyticsSettingsScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:788037ab6790666b292a8e985b36157ba66fbfd1acb10a2b6bbe3435ff42c474 +size 80927 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.settings.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.settings.png index 80495b8cd..c1bccb936 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.settings.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:13570c5df20909284b7184702f60fd49be43160ebf62dd05f4dd57560e067106 -size 102087 +oid sha256:818f6749b666dcdcc96a4fc4feeadd7cc6fc96e92ff6e1a5bd6a640e28584bc0 +size 106446 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsPrompt.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsPrompt.png index eda741887..1f8114234 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsPrompt.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsPrompt.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ede0eb647b8e1f39777c7f5fc8a798f0c4399d95e22fc9bbace7baeffd1a1ce8 -size 181782 +oid sha256:84fba7b597dfa6bad58275a2cff3e8ff9e9ef4a0ba2ca6966868bc1275f44c01 +size 155925 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsSettingsScreen.png new file mode 100644 index 000000000..0fb3e6b1c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.analyticsSettingsScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:736f751f1c5e635626d8465825ae0e2e6e4408b9c141e655496758ccdf2d647f +size 91031 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.settings.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.settings.png index 4de8d8fa8..5c51582e9 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.settings.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:17b7a7c648de5de3b1fe9bff7cd6d77b1d5202f985b2e7377e0a2f9e1a4249ef -size 123926 +oid sha256:1476d5003aa60c399587615e757e93bee462a5d073afcfe2ac4e28b0ac6de3db +size 132960 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsPrompt.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsPrompt.png index e2b5c26b7..157d9a9ea 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsPrompt.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsPrompt.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d0ff8a9c07666a38040d2171778a1d9604791780dcb41802a47511a02fb45e86 -size 167573 +oid sha256:27351f2174b636b50875ecccdd6fbaaf7f90841e4650653492ab05524f815c22 +size 151969 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsSettingsScreen.png new file mode 100644 index 000000000..ec3c07f6e --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.analyticsSettingsScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:294c5e6ac8ba878099d63ea282c65f2fc5e9de3c746414b452ce19cb6ff16e49 +size 93352 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.settings.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.settings.png index a4de72b48..d20f5aa38 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.settings.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7eef8072fd55252bb8bafb94b9805e02d63ca117b60cfd2b613a759a1c8a0812 -size 108309 +oid sha256:868c2151283b6b357071f548fda277142783e615ca18061acc86c8f9ab4ee539 +size 112696 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsPrompt.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsPrompt.png index 5ad508ffb..b3a2eb9d1 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsPrompt.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsPrompt.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4deddcc486c84e9b0bcbd92276ceb265497adb5e17aa841b471b35882ef40015 -size 195899 +oid sha256:ef70a25c245d517be3d86f3e79086f37f7d69d65658ffa1b7ce3ac82939c3710 +size 201812 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsSettingsScreen.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsSettingsScreen.png new file mode 100644 index 000000000..9abcd456e --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.analyticsSettingsScreen.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4904c2275060e2c004f131a2ae8f77318bab86f9dd29427684813ef236d794e +size 115503 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.settings.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.settings.png index 40a0748bf..c5812c592 100644 --- a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.settings.png +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.settings.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62b0c33b5f7888a7608adba56e9c85c1eda74f77104e43def618b6def39fc3fe -size 143600 +oid sha256:98592869daf50a05bf16c6543b36642cd3434771d4e80d2d1f7e7e55f4feeac3 +size 156907 diff --git a/UnitTests/Sources/AnalyticsSettingsScreenViewModelTests.swift b/UnitTests/Sources/AnalyticsSettingsScreenViewModelTests.swift new file mode 100644 index 000000000..b576b9cc8 --- /dev/null +++ b/UnitTests/Sources/AnalyticsSettingsScreenViewModelTests.swift @@ -0,0 +1,54 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class AnalyticsSettingsScreenViewModelTests: XCTestCase { + private var applicationSettings: AppSettings! + private var viewModel: AnalyticsSettingsScreenViewModelProtocol! + private var context: AnalyticsSettingsScreenViewModelType.Context! + + @MainActor override func setUpWithError() throws { + AppSettings.configureWithSuiteName("io.element.elementx.unitests") + AppSettings.reset() + applicationSettings = AppSettings() + ServiceLocator.shared.register(appSettings: applicationSettings) + let analyticsClient = AnalyticsClientMock() + analyticsClient.isRunning = false + ServiceLocator.shared.register(analytics: Analytics(client: analyticsClient)) + + viewModel = AnalyticsSettingsScreenViewModel() + context = viewModel.context + } + + func testInitialState() { + XCTAssertFalse(context.enableAnalytics) + } + + func testOptIn() { + context.send(viewAction: .toggleAnalytics) + XCTAssertTrue(context.enableAnalytics) + } + + func testOptOut() { + applicationSettings.enableAnalytics = true + context.send(viewAction: .toggleAnalytics) + XCTAssertFalse(context.enableAnalytics) + } +} diff --git a/UnitTests/Sources/AnalyticsTests.swift b/UnitTests/Sources/AnalyticsTests.swift index ae7ffa558..bb2f67a06 100644 --- a/UnitTests/Sources/AnalyticsTests.swift +++ b/UnitTests/Sources/AnalyticsTests.swift @@ -20,17 +20,26 @@ import XCTest class AnalyticsTests: XCTestCase { private var applicationSettings: AppSettings! + private var analyticsClient: AnalyticsClientMock! + private var bugReportService: BugReportServiceMock! override func setUp() { AppSettings.configureWithSuiteName("io.element.elementx.unitests") AppSettings.reset() applicationSettings = AppSettings() + ServiceLocator.shared.register(appSettings: applicationSettings) + bugReportService = BugReportServiceMock() + bugReportService.isRunning = false + ServiceLocator.shared.register(bugReportService: bugReportService) + analyticsClient = AnalyticsClientMock() + analyticsClient.isRunning = false + ServiceLocator.shared.register(analytics: Analytics(client: analyticsClient)) } func testAnalyticsPromptNewUser() { // Given a fresh install of the app (without PostHog analytics having been set). // When the user is prompted for analytics. - let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt + let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt // Then the prompt should be shown. XCTAssertTrue(showPrompt, "A prompt should be shown for a new user.") @@ -41,7 +50,7 @@ class AnalyticsTests: XCTestCase { applicationSettings.enableAnalytics = false // When the user is prompted for analytics - let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt + let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt // Then no prompt should be shown. XCTAssertFalse(showPrompt, "A prompt should not be shown any more.") @@ -52,12 +61,65 @@ class AnalyticsTests: XCTestCase { applicationSettings.enableAnalytics = true // When the user is prompted for analytics - let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt + let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt // Then no prompt should be shown. XCTAssertFalse(showPrompt, "A prompt should not be shown any more.") } + func testAnalyticsPromptNotDisplayed() { + // Given a fresh install of the app both Analytics and BugReportService should be disabled + XCTAssertFalse(ServiceLocator.shared.settings.enableAnalytics) + XCTAssertFalse(ServiceLocator.shared.analytics.isRunning) + XCTAssertFalse(analyticsClient.startCalled) + XCTAssertFalse(bugReportService.startCalled) + } + + func testAnalyticsOptOut() { + // Given a fresh install of the app (without PostHog analytics having been set). + // When analytics is opt-out + ServiceLocator.shared.analytics.optOut() + // Then analytics should be disabled + XCTAssertFalse(applicationSettings.enableAnalytics) + XCTAssertFalse(ServiceLocator.shared.analytics.isRunning) + XCTAssertFalse(analyticsClient.isRunning) + XCTAssertFalse(bugReportService.isRunning) + // Analytics client and the bug report service should have been stopped + XCTAssertTrue(analyticsClient.stopCalled) + XCTAssertTrue(bugReportService.stopCalled) + } + + func testAnalyticsOptIn() { + // Given a fresh install of the app (without PostHog analytics having been set). + // When analytics is opt-in + ServiceLocator.shared.analytics.optIn() + // The analytics should be enabled + XCTAssertTrue(applicationSettings.enableAnalytics) + // Analytics client and the bug report service should have been started + XCTAssertTrue(analyticsClient.startCalled) + XCTAssertTrue(bugReportService.startCalled) + } + + func testAnalyticsStartIfNotEnabled() { + // Given an existing install of the app where the user previously declined the tracking + applicationSettings.enableAnalytics = false + // Analytics should not start + ServiceLocator.shared.analytics.startIfEnabled() + XCTAssertFalse(ServiceLocator.shared.settings.enableAnalytics) + XCTAssertFalse(analyticsClient.startCalled) + XCTAssertFalse(bugReportService.startCalled) + } + + func testAnalyticsStartIfEnabled() { + // Given an existing install of the app where the user previously accpeted the tracking + applicationSettings.enableAnalytics = true + // Analytics should start + ServiceLocator.shared.analytics.startIfEnabled() + XCTAssertTrue(ServiceLocator.shared.settings.enableAnalytics) + XCTAssertTrue(analyticsClient.startCalled) + XCTAssertTrue(bugReportService.startCalled) + } + func testAddingUserProperties() { // Given a client with no user properties set let client = PostHogAnalyticsClient() @@ -120,23 +182,4 @@ class AnalyticsTests: XCTestCase { // Then the properties should be cleared XCTAssertNil(client.pendingUserProperties, "The user properties should be cleared.") } - - func testSendingUserPropertiesWithIdentify() { - // Given a client with user properties set - let client = PostHogAnalyticsClient() - client.updateUserProperties(AnalyticsEvent.UserProperties(ftueUseCaseSelection: .PersonalMessaging, - numFavouriteRooms: nil, - numSpaces: nil, - allChatsActiveFilter: nil)) - client.start() - - XCTAssertNotNil(client.pendingUserProperties, "The user properties should be cached.") - XCTAssertEqual(client.pendingUserProperties?.ftueUseCaseSelection, .PersonalMessaging, "The use case selection should match.") - - // When calling identify (tests run under Debug configuration so this is sent to the development instance) - client.identify(id: UUID().uuidString) - - // Then the properties should be cleared - XCTAssertNil(client.pendingUserProperties, "The user properties should be cleared.") - } } diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index 5e0067e5e..dd74f9771 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -27,9 +27,14 @@ final class NotificationManagerTests: XCTestCase { private var shouldDisplayInAppNotificationReturnValue = false private var handleInlineReplyDelegateCalled = false private var notificationTappedDelegateCalled = false - private let settings = ServiceLocator.shared.settings + private var settings: AppSettings! override func setUp() { + AppSettings.configureWithSuiteName("io.element.elementx.unitests") + AppSettings.reset() + settings = AppSettings() + ServiceLocator.shared.register(appSettings: settings) + notificationManager = NotificationManager(notificationCenter: notificationCenter) notificationManager.start() notificationManager.setClientProxy(clientProxy) @@ -114,7 +119,7 @@ final class NotificationManagerTests: XCTestCase { func test_whenStart_requestAuthorizationCalledWithCorrectParams() async throws { notificationManager.requestAuthorization() - await Task.yield() + try await Task.sleep(for: .milliseconds(100)) XCTAssertEqual(notificationCenter.requestAuthorizationOptions, [.alert, .sound, .badge]) } diff --git a/UnitTests/Sources/SettingsViewModelTests.swift b/UnitTests/Sources/SettingsViewModelTests.swift index c84190647..9a75f1fae 100644 --- a/UnitTests/Sources/SettingsViewModelTests.swift +++ b/UnitTests/Sources/SettingsViewModelTests.swift @@ -56,4 +56,15 @@ class SettingsScreenViewModelTests: XCTestCase { await Task.yield() XCTAssert(correctResult) } + + func testAnalytics() async throws { + var correctResult = false + viewModel.callback = { result in + correctResult = result == .analytics + } + + context.send(viewAction: .analytics) + await Task.yield() + XCTAssert(correctResult) + } } diff --git a/changelog.d/106.feature b/changelog.d/106.feature new file mode 100644 index 000000000..987fbdca3 --- /dev/null +++ b/changelog.d/106.feature @@ -0,0 +1 @@ +Set up Analytics to track data. \ No newline at end of file