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 d01349a60e
commit 5b7ec6c9e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 953 additions and 375 deletions

View File

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

View File

@ -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 <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_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.";

View File

@ -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, 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
"soft_logout_forgot_password" = "Forgot password";

View File

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

View File

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

View File

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

View File

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

View File

@ -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, 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
public static var mediaUploadCameraPicker: String { return UntranslatedL10n.tr("Untranslated", "media_upload_camera_picker") }
/// Document

View File

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

View File

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

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
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?()
}
}

View File

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

View File

@ -20,14 +20,12 @@ import SwiftUI
typealias AnalyticsPromptViewModelType = StateStoreViewModel<AnalyticsPromptViewState, AnalyticsPromptViewAction>
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

View File

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

View File

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

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 {
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,4 +6,4 @@ output:
../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
args:
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)
}
}

Binary file not shown.

Binary file not shown.

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 {
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.")
}
}

View File

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

View File

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

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

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