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

pull/805/head
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) }
}