Set up Analytics to track data per session (#780)

This commit is contained in:
Nicolas Mauri
2023-04-18 09:33:32 +02:00
committed by GitHub
parent e333eff731
commit 1bc05a2ffe
58 changed files with 953 additions and 375 deletions

View File

@@ -52,7 +52,8 @@ let allowList = ["stefanceriu",
"aringenbach", "aringenbach",
"flescio", "flescio",
"Velin92", "Velin92",
"alfogrillo"] "alfogrillo",
"nimau"]
let requiresSignOff = !allowList.contains(where: { let requiresSignOff = !allowList.contains(where: {
$0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame $0.caseInsensitiveCompare(danger.github.pullRequest.user.login) == .orderedSame

View File

@@ -53,6 +53,7 @@
"action_view_source" = "View Source"; "action_view_source" = "View Source";
"action_yes" = "Yes"; "action_yes" = "Yes";
"common_about" = "About"; "common_about" = "About";
"common_analytics" = "Analytics";
"common_audio" = "Audio"; "common_audio" = "Audio";
"common_bubbles" = "Bubbles"; "common_bubbles" = "Bubbles";
"common_creating_room" = "Creating room…"; "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" = "This is the beginning of %1$@.";
"room_timeline_beginning_of_room_no_name" = "This is the beginning of this conversation."; "room_timeline_beginning_of_room_no_name" = "This is the beginning of this conversation.";
"room_timeline_read_marker_title" = "New"; "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 <b>don't</b> 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 <b>don't</b> 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_attach_screenshot" = "Attach screenshot";
"screen_bug_report_contact_me" = "You may contact me if you have any follow up questions"; "screen_bug_report_contact_me" = "You may contact me if you have any follow up questions";
"screen_bug_report_edit_screenshot" = "Edit screenshot"; "screen_bug_report_edit_screenshot" = "Edit screenshot";
@@ -198,6 +210,10 @@
"screen_dm_details_unblock_alert_action" = "Unblock"; "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_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_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_empty_list" = "No Invites";
"screen_invites_invited_you" = "%1$@ invited you"; "screen_invites_invited_you" = "%1$@ invited you";
"screen_login_error_deactivated_account" = "This account has been deactivated."; "screen_login_error_deactivated_account" = "This account has been deactivated.";

View File

@@ -4,15 +4,6 @@
/* Used for testing */ /* Used for testing */
"untranslated" = "Untranslated"; "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, well 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 <b>don\'t</b> record or profile any account data";
"analytics_opt_in_list_item_2" = "We <b>don\'t</b> share information with third parties";
"analytics_opt_in_list_item_3" = "You can turn this off anytime in settings";
// MARK: - Soft logout // MARK: - Soft logout
"soft_logout_forgot_password" = "Forgot password"; "soft_logout_forgot_password" = "Forgot password";

View File

@@ -42,7 +42,6 @@ class AppCoordinator: AppCoordinatorProtocol {
private var userSessionFlowCoordinator: UserSessionFlowCoordinator? private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var authenticationCoordinator: AuthenticationCoordinator? private var authenticationCoordinator: AuthenticationCoordinator?
private let bugReportService: BugReportServiceProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol private let backgroundTaskService: BackgroundTaskServiceProtocol
private var userSessionCancellables = Set<AnyCancellable>() private var userSessionCancellables = Set<AnyCancellable>()
@@ -56,11 +55,11 @@ class AppCoordinator: AppCoordinatorProtocol {
Self.setupServiceLocator(navigationRootCoordinator: navigationRootCoordinator) Self.setupServiceLocator(navigationRootCoordinator: navigationRootCoordinator)
Self.setupLogging() 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()) navigationRootCoordinator.setRootCoordinator(SplashScreenCoordinator())
backgroundTaskService = UIKitBackgroundTaskService { backgroundTaskService = UIKitBackgroundTaskService {
@@ -84,7 +83,7 @@ class AppCoordinator: AppCoordinatorProtocol {
wipeUserData(includingSettings: true) wipeUserData(includingSettings: true)
} }
ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description
setupStateMachine() setupStateMachine()
observeApplicationState() observeApplicationState()
@@ -114,6 +113,9 @@ class AppCoordinator: AppCoordinatorProtocol {
ServiceLocator.shared.register(userIndicatorController: UserIndicatorController(rootCoordinator: navigationRootCoordinator)) ServiceLocator.shared.register(userIndicatorController: UserIndicatorController(rootCoordinator: navigationRootCoordinator))
ServiceLocator.shared.register(appSettings: AppSettings()) ServiceLocator.shared.register(appSettings: AppSettings())
ServiceLocator.shared.register(networkMonitor: NetworkMonitor()) 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() { private static func setupLogging() {
@@ -248,7 +250,7 @@ class AppCoordinator: AppCoordinatorProtocol {
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator()) let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SplashScreenCoordinator())
let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession, let userSessionFlowCoordinator = UserSessionFlowCoordinator(userSession: userSession,
navigationSplitCoordinator: navigationSplitCoordinator, navigationSplitCoordinator: navigationSplitCoordinator,
bugReportService: bugReportService, bugReportService: ServiceLocator.shared.bugReportService,
roomTimelineControllerFactory: RoomTimelineControllerFactory()) roomTimelineControllerFactory: RoomTimelineControllerFactory())
userSessionFlowCoordinator.callback = { [weak self] action in userSessionFlowCoordinator.callback = { [weak self] action in
@@ -292,6 +294,9 @@ class AppCoordinator: AppCoordinatorProtocol {
userSessionStore.logout(userSession: userSession) userSessionStore.logout(userSession: userSession)
tearDownUserSession() tearDownUserSession()
// reset analytics
ServiceLocator.shared.analytics.reset()
stateMachine.processEvent(.completedSigningOut(isSoft: isSoft)) stateMachine.processEvent(.completedSigningOut(isSoft: isSoft))
} }
} }

View File

@@ -68,7 +68,6 @@ class AppCoordinatorStateMachine {
private func configure() { private func configure() {
stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut]) stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut])
stateMachine.addRoutes(event: .createdUserSession, transitions: [.signedOut => .signedIn]) stateMachine.addRoutes(event: .createdUserSession, transitions: [.signedOut => .signedIn])
stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession]) stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession])
stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn]) stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn])
stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut]) stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])

View File

@@ -23,7 +23,6 @@ final class AppSettings: ObservableObject {
case lastVersionLaunched case lastVersionLaunched
case timelineStyle case timelineStyle
case enableAnalytics case enableAnalytics
case isIdentifiedForAnalytics
case enableInAppNotifications case enableInAppNotifications
case pusherProfileTag case pusherProfileTag
case shouldCollapseRoomStateEvents case shouldCollapseRoomStateEvents
@@ -104,7 +103,7 @@ final class AppSettings: ObservableObject {
let bugReportUISIId = "element-auto-uisi" let bugReportUISIId = "element-auto-uisi"
// MARK: - Analytics // MARK: - Analytics
#if DEBUG #if DEBUG
/// The configuration to use for analytics during development. Set `isEnabled` to false to disable analytics in debug builds. /// 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. /// **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. /// `true` when the user has opted in to send analytics.
@UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, persistIn: store) @UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, persistIn: store)
var enableAnalytics 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 // MARK: - Room Screen
@UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, persistIn: store) @UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, persistIn: store)

View File

@@ -38,4 +38,16 @@ class ServiceLocator {
func register(networkMonitor: NetworkMonitor) { func register(networkMonitor: NetworkMonitor) {
self.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
}
} }

View File

@@ -10,24 +10,6 @@ import Foundation
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
public enum UntranslatedL10n { public enum UntranslatedL10n {
/// Help us identify issues and improve %@ by sharing anonymous usage data. To understand how people use multiple devices, well 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 <b>don't</b> record or profile any account data
public static var analyticsOptInListItem1: String { return UntranslatedL10n.tr("Untranslated", "analytics_opt_in_list_item_1") }
/// We <b>don't</b> 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 /// Camera
public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") } public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") }
/// Document /// Document

View File

@@ -120,6 +120,8 @@ public enum L10n {
public static var actionYes: String { return L10n.tr("Localizable", "action_yes") } public static var actionYes: String { return L10n.tr("Localizable", "action_yes") }
/// About /// About
public static var commonAbout: String { return L10n.tr("Localizable", "common_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 /// Audio
public static var commonAudio: String { return L10n.tr("Localizable", "common_audio") } public static var commonAudio: String { return L10n.tr("Localizable", "common_audio") }
/// Bubbles /// Bubbles
@@ -390,6 +392,38 @@ public enum L10n {
public static func roomTimelineStateChanges(_ p1: Int) -> String { public static func roomTimelineStateChanges(_ p1: Int) -> String {
return L10n.tr("Localizable", "room_timeline_state_changes", p1) 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 <b>don't</b> 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 <b>don't</b> 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 /// Attach screenshot
public static var screenBugReportAttachScreenshot: String { return L10n.tr("Localizable", "screen_bug_report_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 /// 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") } public static var screenDmDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_description") }
/// Unblock user /// Unblock user
public static var screenDmDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_dm_details_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 /// No Invites
public static var screenInvitesEmptyList: String { return L10n.tr("Localizable", "screen_invites_empty_list") } public static var screenInvitesEmptyList: String { return L10n.tr("Localizable", "screen_invites_empty_list") }
/// %1$@ invited you /// %1$@ invited you

View File

@@ -5,13 +5,159 @@
import Combine import Combine
import Foundation import Foundation
import MatrixRustSDK 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 { class BugReportServiceMock: BugReportServiceProtocol {
var isRunning: Bool {
get { return underlyingIsRunning }
set(value) { underlyingIsRunning = value }
}
var underlyingIsRunning: Bool!
var crashedLastRun: Bool { var crashedLastRun: Bool {
get { return underlyingCrashedLastRun } get { return underlyingCrashedLastRun }
set(value) { underlyingCrashedLastRun = value } set(value) { underlyingCrashedLastRun = value }
} }
var underlyingCrashedLastRun: Bool! 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 //MARK: - crash
var crashCallsCount = 0 var crashCallsCount = 0

View File

@@ -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))
}
}

View File

@@ -16,21 +16,13 @@
import SwiftUI import SwiftUI
struct AnalyticsPromptCoordinatorParameters {
/// The user session to use if analytics are enabled.
let userSession: UserSessionProtocol
}
final class AnalyticsPromptCoordinator: CoordinatorProtocol { final class AnalyticsPromptCoordinator: CoordinatorProtocol {
private let parameters: AnalyticsPromptCoordinatorParameters
private var viewModel: AnalyticsPromptViewModel private var viewModel: AnalyticsPromptViewModel
var callback: (@MainActor () -> Void)? var callback: (@MainActor () -> Void)?
init(parameters: AnalyticsPromptCoordinatorParameters) { init() {
self.parameters = parameters viewModel = AnalyticsPromptViewModel()
viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
} }
// MARK: - Public // MARK: - Public
@@ -42,11 +34,11 @@ final class AnalyticsPromptCoordinator: CoordinatorProtocol {
switch result { switch result {
case .enable: case .enable:
MXLog.info("Enable Analytics") MXLog.info("Enable Analytics")
Analytics.shared.optIn(with: self.parameters.userSession) ServiceLocator.shared.analytics.optIn()
self.callback?() self.callback?()
case .disable: case .disable:
MXLog.info("Disable Analytics") MXLog.info("Disable Analytics")
Analytics.shared.optOut() ServiceLocator.shared.analytics.optOut()
self.callback?() self.callback?()
} }
} }

View File

@@ -32,22 +32,24 @@ enum AnalyticsPromptViewModelAction {
struct AnalyticsPromptViewState: BindableState { struct AnalyticsPromptViewState: BindableState {
/// Attributed strings created from localized HTML. /// 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 /// A collection of strings for the UI that need to be parsed from HTML
struct AnalyticsPromptStrings { struct AnalyticsPromptStrings {
let optInContent: AttributedString let optInContent: AttributedString
let point1 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem1) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem1) let point1 = AttributedStringBuilder().fromHTML(L10n.screenAnalyticsPromptDataUsage) ?? AttributedString(L10n.screenAnalyticsPromptDataUsage)
let point2 = AttributedStringBuilder().fromHTML(UntranslatedL10n.analyticsOptInListItem2) ?? AttributedString(UntranslatedL10n.analyticsOptInListItem2) 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. // Create the opt in content with a placeholder.
let linkPlaceholder = "{link}" let linkPlaceholder = "{link}"
var optInContent = AttributedString(UntranslatedL10n.analyticsOptInContent(InfoPlistReader.main.bundleDisplayName, linkPlaceholder)) var readTerms = AttributedString(L10n.screenAnalyticsPromptReadTerms(linkPlaceholder))
optInContent.replace(linkPlaceholder, readTerms.replace(linkPlaceholder,
with: UntranslatedL10n.analyticsOptInContentLink, with: L10n.screenAnalyticsPromptReadTermsContentLink,
asLinkTo: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) asLinkTo: termsURL)
self.optInContent = optInContent optInContent = content + "\n\n" + readTerms
} }
} }

View File

@@ -20,14 +20,12 @@ import SwiftUI
typealias AnalyticsPromptViewModelType = StateStoreViewModel<AnalyticsPromptViewState, AnalyticsPromptViewAction> typealias AnalyticsPromptViewModelType = StateStoreViewModel<AnalyticsPromptViewState, AnalyticsPromptViewAction>
class AnalyticsPromptViewModel: AnalyticsPromptViewModelType, AnalyticsPromptViewModelProtocol { class AnalyticsPromptViewModel: AnalyticsPromptViewModelType, AnalyticsPromptViewModelProtocol {
private let termsURL: URL
var callback: (@MainActor (AnalyticsPromptViewModelAction) -> Void)? var callback: (@MainActor (AnalyticsPromptViewModelAction) -> Void)?
/// Initialize a view model with the specified prompt type and app display name. /// Initialize a view model with the specified prompt type and app display name.
init(termsURL: URL) { init() {
self.termsURL = termsURL let promptStrings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
super.init(initialViewState: AnalyticsPromptViewState()) super.init(initialViewState: AnalyticsPromptViewState(strings: promptStrings))
} }
// MARK: - Public // MARK: - Public

View File

@@ -57,9 +57,9 @@ struct AnalyticsPrompt: View {
private var mainContent: some View { private var mainContent: some View {
VStack { VStack {
Image(uiImage: Asset.Images.analyticsLogo.image) 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) .font(.element.title2Bold)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.element.primaryContent) .foregroundColor(.element.primaryContent)
@@ -73,7 +73,7 @@ struct AnalyticsPrompt: View {
Divider() Divider()
.background(Color.element.quinaryContent) .background(Color.element.quinaryContent)
.padding(.vertical, 28) .padding(.vertical, 20)
checkmarkList checkmarkList
} }
@@ -81,10 +81,10 @@ struct AnalyticsPrompt: View {
/// The list of re-assurances about analytics. /// The list of re-assurances about analytics.
private var checkmarkList: some View { 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(attributedString: context.viewState.strings.point1) AnalyticsPromptCheckmarkItem(attributedString: context.viewState.strings.point2)
AnalyticsPromptCheckmarkItem(string: UntranslatedL10n.analyticsOptInListItem3) AnalyticsPromptCheckmarkItem(string: context.viewState.strings.point3)
} }
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.font(.element.body) .font(.element.body)
@@ -113,7 +113,7 @@ struct AnalyticsPrompt: View {
// MARK: - Previews // MARK: - Previews
struct AnalyticsPrompt_Previews: PreviewProvider { struct AnalyticsPrompt_Previews: PreviewProvider {
static let viewModel = AnalyticsPromptViewModel(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL) static let viewModel = AnalyticsPromptViewModel()
static var previews: some View { static var previews: some View {
AnalyticsPrompt(context: viewModel.context) AnalyticsPrompt(context: viewModel.context)
} }

View File

@@ -29,7 +29,7 @@ struct AnalyticsPromptCheckmarkItem: View {
var body: some View { var body: some View {
Label { Text(attributedString) } icon: { Label { Text(attributedString) } icon: {
Image(uiImage: Asset.Images.analyticsCheckmark.image) Image(systemName: "checkmark.circle")
.foregroundColor(.element.accent) .foregroundColor(.element.accent)
} }
} }
@@ -38,7 +38,7 @@ struct AnalyticsPromptCheckmarkItem: View {
// MARK: - Previews // MARK: - Previews
struct AnalyticsPromptCheckmarkItem_Previews: PreviewProvider { struct AnalyticsPromptCheckmarkItem_Previews: PreviewProvider {
static let strings = AnalyticsPromptStrings() static let strings = AnalyticsPromptStrings(termsURL: ServiceLocator.shared.settings.analyticsConfiguration.termsURL)
static var previews: some View { static var previews: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {

View File

@@ -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))
}
}

View File

@@ -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
}
}

View File

@@ -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<AnalyticsSettingsScreenViewState, AnalyticsSettingsScreenViewAction>
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()
}
}
}
}

View File

@@ -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 }
}

View File

@@ -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)
}
}

View File

@@ -101,23 +101,30 @@ class AuthenticationCoordinator: CoordinatorProtocol {
switch action { switch action {
case .signedIn(let userSession): case .signedIn(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) self.userHasSignedIn(userSession: userSession)
} }
} }
navigationStackCoordinator.push(coordinator) navigationStackCoordinator.push(coordinator)
} }
private func showAnalyticsPrompt(with userSession: UserSessionProtocol) { private func userHasSignedIn(userSession: UserSessionProtocol) {
let parameters = AnalyticsPromptCoordinatorParameters(userSession: userSession) showAnalyticsPromptIfNeeded { [weak self] in
let coordinator = AnalyticsPromptCoordinator(parameters: parameters)
coordinator.callback = { [weak self] in
guard let self else { return } guard let self else { return }
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) 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" static let loadingIndicatorIdentifier = "AuthenticationCoordinatorLoading"

View File

@@ -62,11 +62,8 @@ struct HomeScreenViewState: BindableState {
let userID: String let userID: String
var userDisplayName: String? var userDisplayName: String?
var userAvatarURL: URL? var userAvatarURL: URL?
var showSessionVerificationBanner = false var showSessionVerificationBanner = false
var rooms: [HomeScreenRoom] = [] var rooms: [HomeScreenRoom] = []
var roomListMode: HomeScreenRoomListMode = .skeletons var roomListMode: HomeScreenRoomListMode = .skeletons
/// The URL that will be shared when inviting friends to use the app. /// The URL that will be shared when inviting friends to use the app.

View File

@@ -119,6 +119,7 @@ struct HomeScreen: View {
} }
} }
.background(Color.element.background.ignoresSafeArea()) .background(Color.element.background.ignoresSafeArea())
.track(screen: .home)
} }
@ViewBuilder @ViewBuilder

View File

@@ -40,6 +40,7 @@ struct RoomScreen: View {
.overlay { loadingIndicator } .overlay { loadingIndicator }
.alert(item: $context.alertInfo) { $0.alert } .alert(item: $context.alertInfo) { $0.alert }
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) } .sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
.track(screen: .room)
.task(id: context.viewState.roomId) { .task(id: context.viewState.roomId) {
// Give a couple of seconds for items to load and to see them. // Give a couple of seconds for items to load and to see them.
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))

View File

@@ -46,8 +46,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
switch action { switch action {
case .close: case .close:
self.callback?(.dismiss) self.callback?(.dismiss)
case .toggleAnalytics: case .analytics:
self.toggleAnalytics() self.presentAnalyticsScreen()
case .reportBug: case .reportBug:
self.presentBugReportScreen() self.presentBugReportScreen()
case .sessionVerification: case .sessionVerification:
@@ -68,14 +68,11 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
// MARK: - Private // MARK: - Private
private func toggleAnalytics() { private func presentAnalyticsScreen() {
if ServiceLocator.shared.settings.enableAnalytics { let coordinator = AnalyticsSettingsScreenCoordinator()
Analytics.shared.optOut() parameters.navigationStackCoordinator?.push(coordinator)
} else {
Analytics.shared.optIn(with: parameters.userSession)
}
} }
private func presentBugReportScreen() { private func presentBugReportScreen() {
let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService, let params = BugReportCoordinatorParameters(bugReportService: parameters.bugReportService,
userID: parameters.userSession.userID, userID: parameters.userSession.userID,

View File

@@ -19,7 +19,7 @@ import UIKit
enum SettingsScreenViewModelAction { enum SettingsScreenViewModelAction {
case close case close
case toggleAnalytics case analytics
case reportBug case reportBug
case sessionVerification case sessionVerification
case developerOptions case developerOptions
@@ -42,7 +42,7 @@ struct SettingsScreenViewStateBindings {
enum SettingsScreenViewAction { enum SettingsScreenViewAction {
case close case close
case toggleAnalytics case analytics
case reportBug case reportBug
case sessionVerification case sessionVerification
case logout case logout

View File

@@ -77,8 +77,8 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
switch viewAction { switch viewAction {
case .close: case .close:
callback?(.close) callback?(.close)
case .toggleAnalytics: case .analytics:
callback?(.toggleAnalytics) callback?(.analytics)
case .reportBug: case .reportBug:
callback?(.reportBug) callback?(.reportBug)
case .logout: case .logout:

View File

@@ -92,7 +92,7 @@ struct SettingsScreen: View {
Label(L10n.commonDeveloperOptions, systemImage: "hammer.circle") Label(L10n.commonDeveloperOptions, systemImage: "hammer.circle")
} }
.buttonStyle(.compoundForm(accessory: .navigationLink)) .buttonStyle(.compoundForm(accessory: .navigationLink))
.accessibilityIdentifier("sessionVerificationButton") .accessibilityIdentifier("developerOptionsButton")
} }
.compoundFormSection() .compoundFormSection()
} }
@@ -113,6 +113,14 @@ struct SettingsScreen: View {
context.send(viewAction: .changedTimelineStyle) 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: { Button { context.send(viewAction: .reportBug) } label: {
Label(L10n.actionReportBug, systemImage: "questionmark.circle") Label(L10n.actionReportBug, systemImage: "questionmark.circle")
} }

View File

@@ -31,18 +31,13 @@ import PostHog
/// into `main`, update the AnalyticsEvents Swift package in `project.yml`. /// into `main`, update the AnalyticsEvents Swift package in `project.yml`.
/// ///
class Analytics { class Analytics {
/// The singleton instance to be used within the Riot target.
static let shared = Analytics()
/// The analytics client to send events with. /// The analytics client to send events with.
private var client: AnalyticsClientProtocol = PostHogAnalyticsClient() private let client: AnalyticsClientProtocol
// /// The monitoring client to track crashes, issues and performance init(client: AnalyticsClientProtocol) {
// private var monitoringClient = SentryMonitoringClient() self.client = client
}
/// The service used to interact with account data settings.
private var service: AnalyticsService?
/// Whether or not the object is enabled and sending events to the server. /// Whether or not the object is enabled and sending events to the server.
var isRunning: Bool { client.isRunning } var isRunning: Bool { client.isRunning }
@@ -53,13 +48,9 @@ class Analytics {
} }
/// Opts in to analytics tracking with the supplied user session. /// Opts in to analytics tracking with the supplied user session.
/// - Parameter userSession: The user session to use to when reading/generating the analytics ID. func optIn() {
/// The session will be ignored if not running.
func optIn(with userSession: UserSessionProtocol) {
ServiceLocator.shared.settings.enableAnalytics = true ServiceLocator.shared.settings.enableAnalytics = true
startIfEnabled() startIfEnabled()
Task { await useAnalyticsSettings(from: userSession) }
} }
/// Stops analytics tracking and calls `reset` to clear any IDs and event queues. /// 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. // The order is important here. PostHog ignores the reset if stopped.
reset() reset()
client.stop() client.stop()
// monitoringClient.stop() ServiceLocator.shared.bugReportService.stop()
MXLog.info("Stopped.") MXLog.info("Stopped.")
} }
@@ -79,38 +69,12 @@ class Analytics {
guard ServiceLocator.shared.settings.enableAnalytics, !isRunning else { return } guard ServiceLocator.shared.settings.enableAnalytics, !isRunning else { return }
client.start() client.start()
// monitoringClient.start() ServiceLocator.shared.bugReportService.start()
// Sanity check in case something went wrong. // Sanity check in case something went wrong.
guard client.isRunning else { return } guard client.isRunning else { return }
MXLog.info("Started.") 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 /// 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. /// Note: **MUST** be called before stopping PostHog or the reset is ignored.
func reset() { func reset() {
client.reset() client.reset()
// monitoringClient.reset() ServiceLocator.shared.bugReportService.reset()
MXLog.info("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. /// Flushes the event queue in the analytics client, uploading all pending events.
@@ -137,33 +96,23 @@ class Analytics {
// MARK: - Private // 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`. /// Capture an event in the `client`.
/// - Parameter event: The event to capture. /// - Parameter event: The event to capture.
private func capture(event: AnalyticsEventProtocol) { private func capture(event: AnalyticsEventProtocol) {
MXLog.debug("\(event)")
client.capture(event) client.capture(event)
} }
} }
// MARK: - Public tracking methods // MARK: - Public tracking methods
// The following methods are exposed for compatibility with Objective-C as extension Analytics {
// the `capture` method and the generated events cannot be bridged from Swift. /// Track the presentation of a screen
extension Analytics { } /// - Parameter screen: The screen that was shown
/// - Parameter duration: An optional value representing how long the screen was shown for in milliseconds.
// MARK: - MXAnalyticsDelegate func track(screen: AnalyticsScreen, duration milliseconds: Int? = nil) {
MXLog.debug("\(screen)")
// extension Analytics: MXAnalyticsDelegate { let event = AnalyticsEvent.MobileScreen(durationMs: milliseconds, screenName: screen.screenName)
// } client.screen(event)
}
}

View File

@@ -23,11 +23,7 @@ protocol AnalyticsClientProtocol {
/// Starts the analytics client reporting data. /// Starts the analytics client reporting data.
func start() 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 /// Reset all stored properties and any event queues on the client. Note that
/// the client will remain active, but in a fresh unidentified state. /// the client will remain active, but in a fresh unidentified state.
func reset() func reset()
@@ -54,3 +50,6 @@ protocol AnalyticsClientProtocol {
/// as part of the next event that gets captured. /// as part of the next event that gets captured.
func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties) func updateUserProperties(_ userProperties: AnalyticsEvent.UserProperties)
} }
// sourcery: AutoMockable
extension AnalyticsClientProtocol { }

View File

@@ -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
}
}
}

View File

@@ -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<AnalyticsSettings, AnalyticsServiceError> {
// 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<AnalyticsSettings?, ClientProxyError> = 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.")
}
}

View File

@@ -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)
}
}

View File

@@ -38,16 +38,6 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
postHog?.enable() 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() { func reset() {
postHog?.reset() postHog?.reset()
pendingUserProperties = nil pendingUserProperties = nil
@@ -65,10 +55,12 @@ class PostHogAnalyticsClient: AnalyticsClientProtocol {
} }
func capture(_ event: AnalyticsEventProtocol) { func capture(_ event: AnalyticsEventProtocol) {
guard isRunning else { return }
postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties)) postHog?.capture(event.eventName, properties: attachUserProperties(to: event.properties))
} }
func screen(_ event: AnalyticsScreenProtocol) { func screen(_ event: AnalyticsScreenProtocol) {
guard isRunning else { return }
postHog?.screen(event.screenName.rawValue, properties: attachUserProperties(to: event.properties)) postHog?.screen(event.screenName.rawValue, properties: attachUserProperties(to: event.properties))
} }

View File

@@ -39,13 +39,27 @@ class BugReportService: NSObject, BugReportServiceProtocol {
self.session = session self.session = session
super.init() 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 SentrySDK.start { options in
#if DEBUG #if DEBUG
options.enabled = false options.enabled = false
#endif #endif
options.dsn = self.sentryURL.absoluteString
options.dsn = sentryURL.absoluteString
// Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring.
// We recommend adjusting this value in production. // We recommend adjusting this value in production.
@@ -61,19 +75,22 @@ class BugReportService: NSObject, BugReportServiceProtocol {
self?.lastCrashEventId = event.eventId.sentryIdString self?.lastCrashEventId = event.eventId.sentryIdString
} }
} }
// also enable logging crashes, to send them with bug reports
MXLogger.logCrashes(true) MXLogger.logCrashes(true)
// set build version for logger MXLog.info("Started.")
MXLogger.buildVersion = InfoPlistReader.main.bundleShortVersionString
} }
// MARK: - BugReportServiceProtocol func stop() {
guard isRunning else { return }
var crashedLastRun: Bool { SentrySDK.close()
SentrySDK.crashedLastRun MXLogger.logCrashes(false)
MXLog.info("Stopped.")
} }
func reset() {
lastCrashEventId = nil
MXLog.info("Reset.")
}
func crash() { func crash() {
SentrySDK.crash() SentrySDK.crash()
} }

View File

@@ -33,10 +33,18 @@ struct SubmitBugReportResponse: Decodable {
// sourcery: AutoMockable // sourcery: AutoMockable
protocol BugReportServiceProtocol { protocol BugReportServiceProtocol {
var isRunning: Bool { get }
var crashedLastRun: Bool { get } var crashedLastRun: Bool { get }
func start()
func stop()
func reset()
func crash() func crash()
func submitBugReport(_ bugReport: BugReport, func submitBugReport(_ bugReport: BugReport,
progressListener: ProgressListener?) async throws -> SubmitBugReportResponse progressListener: ProgressListener?) async throws -> SubmitBugReportResponse
} }

View File

@@ -31,6 +31,8 @@ class UITestsAppCoordinator: AppCoordinatorProtocol {
AppSettings.configureWithSuiteName("io.element.elementx.uitests") AppSettings.configureWithSuiteName("io.element.elementx.uitests")
AppSettings.reset() AppSettings.reset()
ServiceLocator.shared.register(appSettings: AppSettings()) ServiceLocator.shared.register(appSettings: AppSettings())
ServiceLocator.shared.register(bugReportService: BugReportServiceMock())
ServiceLocator.shared.register(analytics: Analytics(client: AnalyticsClientMock()))
} }
func start() { func start() {
@@ -76,8 +78,12 @@ class MockScreen: Identifiable {
userIndicatorController: MockUserIndicatorController(), userIndicatorController: MockUserIndicatorController(),
isModallyPresented: false)) isModallyPresented: false))
case .analyticsPrompt: case .analyticsPrompt:
return AnalyticsPromptCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), return AnalyticsPromptCoordinator()
mediaProvider: MockMediaProvider()))) case .analyticsSettingsScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = AnalyticsSettingsScreenCoordinator()
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .authenticationFlow: case .authenticationFlow:
let navigationStackCoordinator = NavigationStackCoordinator() let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(), let coordinator = AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(),

View File

@@ -23,6 +23,7 @@ enum UITestsScreenIdentifier: String {
case authenticationFlow case authenticationFlow
case softLogout case softLogout
case analyticsPrompt case analyticsPrompt
case analyticsSettingsScreen
case simpleRegular case simpleRegular
case simpleUpgrade case simpleUpgrade
case home case home

View File

@@ -6,4 +6,4 @@ output:
../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift ../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
args: args:
automMockableTestableImports: [] automMockableTestableImports: []
autoMockableImports: [Combine, Foundation, MatrixRustSDK] autoMockableImports: [Combine, Foundation, MatrixRustSDK, AnalyticsEvents]

View File

@@ -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)
}
}

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:97e77a883d7803bf18ace6204966badb5f76f382f6b8900b7aee9f8d1fcf8d7b oid sha256:aa03abfa975225c44719b6ccf91f2a36c15e36706c4a8e7dd3ab8ceba1394290
size 135715 size 122070

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:788037ab6790666b292a8e985b36157ba66fbfd1acb10a2b6bbe3435ff42c474
size 80927

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:13570c5df20909284b7184702f60fd49be43160ebf62dd05f4dd57560e067106 oid sha256:818f6749b666dcdcc96a4fc4feeadd7cc6fc96e92ff6e1a5bd6a640e28584bc0
size 102087 size 106446

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ede0eb647b8e1f39777c7f5fc8a798f0c4399d95e22fc9bbace7baeffd1a1ce8 oid sha256:84fba7b597dfa6bad58275a2cff3e8ff9e9ef4a0ba2ca6966868bc1275f44c01
size 181782 size 155925

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:736f751f1c5e635626d8465825ae0e2e6e4408b9c141e655496758ccdf2d647f
size 91031

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:17b7a7c648de5de3b1fe9bff7cd6d77b1d5202f985b2e7377e0a2f9e1a4249ef oid sha256:1476d5003aa60c399587615e757e93bee462a5d073afcfe2ac4e28b0ac6de3db
size 123926 size 132960

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d0ff8a9c07666a38040d2171778a1d9604791780dcb41802a47511a02fb45e86 oid sha256:27351f2174b636b50875ecccdd6fbaaf7f90841e4650653492ab05524f815c22
size 167573 size 151969

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:294c5e6ac8ba878099d63ea282c65f2fc5e9de3c746414b452ce19cb6ff16e49
size 93352

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7eef8072fd55252bb8bafb94b9805e02d63ca117b60cfd2b613a759a1c8a0812 oid sha256:868c2151283b6b357071f548fda277142783e615ca18061acc86c8f9ab4ee539
size 108309 size 112696

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4deddcc486c84e9b0bcbd92276ceb265497adb5e17aa841b471b35882ef40015 oid sha256:ef70a25c245d517be3d86f3e79086f37f7d69d65658ffa1b7ce3ac82939c3710
size 195899 size 201812

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e4904c2275060e2c004f131a2ae8f77318bab86f9dd29427684813ef236d794e
size 115503

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:62b0c33b5f7888a7608adba56e9c85c1eda74f77104e43def618b6def39fc3fe oid sha256:98592869daf50a05bf16c6543b36642cd3434771d4e80d2d1f7e7e55f4feeac3
size 143600 size 156907

View File

@@ -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)
}
}

View File

@@ -20,17 +20,26 @@ import XCTest
class AnalyticsTests: XCTestCase { class AnalyticsTests: XCTestCase {
private var applicationSettings: AppSettings! private var applicationSettings: AppSettings!
private var analyticsClient: AnalyticsClientMock!
private var bugReportService: BugReportServiceMock!
override func setUp() { override func setUp() {
AppSettings.configureWithSuiteName("io.element.elementx.unitests") AppSettings.configureWithSuiteName("io.element.elementx.unitests")
AppSettings.reset() AppSettings.reset()
applicationSettings = AppSettings() 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() { func testAnalyticsPromptNewUser() {
// Given a fresh install of the app (without PostHog analytics having been set). // Given a fresh install of the app (without PostHog analytics having been set).
// When the user is prompted for analytics. // When the user is prompted for analytics.
let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt
// Then the prompt should be shown. // Then the prompt should be shown.
XCTAssertTrue(showPrompt, "A prompt should be shown for a new user.") XCTAssertTrue(showPrompt, "A prompt should be shown for a new user.")
@@ -41,7 +50,7 @@ class AnalyticsTests: XCTestCase {
applicationSettings.enableAnalytics = false applicationSettings.enableAnalytics = false
// When the user is prompted for analytics // When the user is prompted for analytics
let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt
// Then no prompt should be shown. // Then no prompt should be shown.
XCTAssertFalse(showPrompt, "A prompt should not be shown any more.") XCTAssertFalse(showPrompt, "A prompt should not be shown any more.")
@@ -52,12 +61,65 @@ class AnalyticsTests: XCTestCase {
applicationSettings.enableAnalytics = true applicationSettings.enableAnalytics = true
// When the user is prompted for analytics // When the user is prompted for analytics
let showPrompt = Analytics.shared.shouldShowAnalyticsPrompt let showPrompt = ServiceLocator.shared.analytics.shouldShowAnalyticsPrompt
// Then no prompt should be shown. // Then no prompt should be shown.
XCTAssertFalse(showPrompt, "A prompt should not be shown any more.") 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() { func testAddingUserProperties() {
// Given a client with no user properties set // Given a client with no user properties set
let client = PostHogAnalyticsClient() let client = PostHogAnalyticsClient()
@@ -120,23 +182,4 @@ class AnalyticsTests: XCTestCase {
// Then the properties should be cleared // Then the properties should be cleared
XCTAssertNil(client.pendingUserProperties, "The user 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.")
}
} }

View File

@@ -27,9 +27,14 @@ final class NotificationManagerTests: XCTestCase {
private var shouldDisplayInAppNotificationReturnValue = false private var shouldDisplayInAppNotificationReturnValue = false
private var handleInlineReplyDelegateCalled = false private var handleInlineReplyDelegateCalled = false
private var notificationTappedDelegateCalled = false private var notificationTappedDelegateCalled = false
private let settings = ServiceLocator.shared.settings private var settings: AppSettings!
override func setUp() { override func setUp() {
AppSettings.configureWithSuiteName("io.element.elementx.unitests")
AppSettings.reset()
settings = AppSettings()
ServiceLocator.shared.register(appSettings: settings)
notificationManager = NotificationManager(notificationCenter: notificationCenter) notificationManager = NotificationManager(notificationCenter: notificationCenter)
notificationManager.start() notificationManager.start()
notificationManager.setClientProxy(clientProxy) notificationManager.setClientProxy(clientProxy)
@@ -114,7 +119,7 @@ final class NotificationManagerTests: XCTestCase {
func test_whenStart_requestAuthorizationCalledWithCorrectParams() async throws { func test_whenStart_requestAuthorizationCalledWithCorrectParams() async throws {
notificationManager.requestAuthorization() notificationManager.requestAuthorization()
await Task.yield() try await Task.sleep(for: .milliseconds(100))
XCTAssertEqual(notificationCenter.requestAuthorizationOptions, [.alert, .sound, .badge]) XCTAssertEqual(notificationCenter.requestAuthorizationOptions, [.alert, .sound, .badge])
} }

View File

@@ -56,4 +56,15 @@ class SettingsScreenViewModelTests: XCTestCase {
await Task.yield() await Task.yield()
XCTAssert(correctResult) 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)
}
} }

1
changelog.d/106.feature Normal file
View File

@@ -0,0 +1 @@
Set up Analytics to track data.