diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c51cd4b7b..fc56e7006 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3ee17d36ad31821cf605e1fe4265849c1713bd8469ddbae5a0189b76ad0be421", + "originHash" : "c36a5ac92cad5f44069558a377146d774e448484d1494dd2572c544084990739", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash-shared-core-spm", "state" : { - "revision" : "01c05c5d7a7a2ed234189e806801b03058f2dc16", - "version" : "0.17.0" + "revision" : "d21abab7ab56981bfcf0da9ce20e0b8d5f8a2fe9", + "version" : "0.18.0" } }, { diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 17b2cebee..09e761fb1 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -18,7 +18,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.18.0")) }() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index a5f626ac6..ee4dbb504 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1434,7 +1434,8 @@ struct ConversationScreen: View { conversationID: conversationID, messageID: message.id, messageSender: sender, - chat: chat + chat: chat, + conversationType: conversationController.conversation(withID: conversationID)?.type ) } diff --git a/Flipcash/Core/Screens/Conversation/ReactionBoostViewModel.swift b/Flipcash/Core/Screens/Conversation/ReactionBoostViewModel.swift index 5835404d5..5a4599273 100644 --- a/Flipcash/Core/Screens/Conversation/ReactionBoostViewModel.swift +++ b/Flipcash/Core/Screens/Conversation/ReactionBoostViewModel.swift @@ -20,6 +20,8 @@ struct ReactionBoostTarget: Hashable, Identifiable { let messageID: MessageID let messageSender: UserID let chat: ReactionBoostSplit.Chat + /// The conversation's type, for analytics only. Nil when it isn't known locally. + var conversationType: ConversationType? var id: Self { self } } @@ -241,6 +243,7 @@ final class ReactionBoostViewModel { try await multiSender.sendMultiple(payments, verifiedState: pin, chat: chat) } } catch { + Analytics.sentBoost(chatType: target.conversationType, exchangedFiat: total, error: error) if !(error is CancellationError) { showSendError() } @@ -253,6 +256,7 @@ final class ReactionBoostViewModel { messageID: target.messageID, in: target.conversationID ) + Analytics.sentBoost(chatType: target.conversationType, exchangedFiat: total, error: nil) return .success } diff --git a/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift b/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift index 4b8b6d1b0..1b155099a 100644 --- a/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift +++ b/Flipcash/Core/Screens/Invite Links/GroupInviteLinks.swift @@ -244,6 +244,7 @@ final class GroupInviteLinks { // Only a transport failure is worth retrying with the same token; any answer from the // server is final for this secret. if error != .transportFailure { pendingSecret = nil } + Analytics.groupInviteLinkCreated(error: error) throw error } pendingSecret = nil @@ -252,12 +253,14 @@ final class GroupInviteLinks { logger.warning("CreateInvite returned an unusable invite", metadata: [ "status": "\(invite.status)", ]) + Analytics.groupInviteLinkCreated(error: CreatedUnusable()) throw CreatedUnusable() } let entry = Entry(invite: invite, url: url) entries.removeAll { $0.id == entry.id } entries.insert(entry, at: 0) + Analytics.groupInviteLinkCreated(error: nil) return entry } @@ -269,7 +272,13 @@ final class GroupInviteLinks { revoking.insert(entry.id) defer { revoking.remove(entry.id) } - try await service.revoke(conversationID, entry.id) + do { + try await service.revoke(conversationID, entry.id) + } catch { + Analytics.groupInviteLinkRevoked(error: error) + throw error + } + Analytics.groupInviteLinkRevoked(error: nil) guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return } let old = entries[index].invite entries[index] = Entry( diff --git a/Flipcash/Core/Screens/Join Requests/HostedLobbies.swift b/Flipcash/Core/Screens/Join Requests/HostedLobbies.swift index 56392fe85..133e70523 100644 --- a/Flipcash/Core/Screens/Join Requests/HostedLobbies.swift +++ b/Flipcash/Core/Screens/Join Requests/HostedLobbies.swift @@ -107,16 +107,26 @@ final class HostedLobbies { let envelope = try await wrapKey(member.publicKey, conversationID) try await admitMember(conversationID, userID, envelope) } catch ErrorAdmitLobbyMember.notInLobby { - // Withdrawn, or settled on another device. + // Withdrawn, or settled on another device. Counts as admitted. + } catch { + Analytics.groupJoinRequestApproved(error: error) + throw error } remove(userID, from: conversationID) + Analytics.groupJoinRequestApproved(error: nil) } /// Turns `member` away. The requester isn't told. func deny(_ member: LobbyMember, in conversationID: ConversationID) async throws { guard let userID = member.userID else { return } - try await denyMember(conversationID, userID) + do { + try await denyMember(conversationID, userID) + } catch { + Analytics.groupJoinRequestDenied(error: error) + throw error + } remove(userID, from: conversationID) + Analytics.groupJoinRequestDenied(error: nil) } private func remove(_ userID: UserID, from conversationID: ConversationID) { diff --git a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift index edad587ac..525ffeeb1 100644 --- a/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Creation/CurrencyLaunchProcessingViewModel.swift @@ -97,8 +97,8 @@ class CurrencyLaunchProcessingViewModel { var isFinished: Bool { displayState != .processing } var isSuccess: Bool { displayState == .success } - var analyticsEvent: Analytics.CurrencyLaunchEvent { - paymentMint == .usdf ? .launchWithReserves : .launchWithCurrency + var launchMethod: LaunchMethod { + paymentMint == .usdf ? .reserves : .currency } // MARK: - Actions - @@ -250,7 +250,7 @@ class CurrencyLaunchProcessingViewModel { } private func trackLaunch(successful: Bool, error: Error? = nil) { - Analytics.currencyLaunch(event: analyticsEvent, launchedMint: launchedMint, paymentMint: paymentMint, exchangedFiat: launchAmount, successful: successful, error: error) + Analytics.currencyLaunch(method: launchMethod, launchedMint: launchedMint, paymentMint: paymentMint, exchangedFiat: launchAmount, successful: successful, error: error) } /// Polls `session.balance(for: launchedMint)` until the launched currency diff --git a/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift b/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift index d225d0f4b..1ba0a771a 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CommunityChats.swift @@ -191,6 +191,7 @@ struct TopCommunityChatsDestination: View { chats: viewModel.communityChats, subtitle: viewModel.communitySubtitle(for:), onSelect: { chat in + Analytics.tokenInfoCommunityChatTapped(mint: mint) conversationController.hold(chat) router.push(.chatProfile(chat.id, origin: .featuredGroup)) } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 885c8b9cb..2b8e4e940 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -311,15 +311,15 @@ struct CurrencyInfoContentV2: View { ForEach(CurrencyInfoActions.tiles(isOwned: isOwned, isUSDF: isUSDF), id: \.self) { tile in switch tile { case .send: - actionTile("Send", icon: .asset("IconTileSend"), action: onSend) + actionTile("Send", tile: tile, icon: .asset("IconTileSend"), action: onSend) case .buy: - actionTile("Buy", icon: .asset("IconTileBuy"), action: onBuy) + actionTile("Buy", tile: tile, icon: .asset("IconTileBuy"), action: onBuy) case .buyIn: - actionTile("Buy In", icon: .asset("IconTileBuy"), action: onBuy) + actionTile("Buy In", tile: tile, icon: .asset("IconTileBuy"), action: onBuy) case .transactions: - actionTile("Transactions", icon: .system("clock.arrow.circlepath"), action: onShowTransactionHistory) + actionTile("Transactions", tile: tile, icon: .system("clock.arrow.circlepath"), action: onShowTransactionHistory) case .more: - actionTile("More", icon: .system("ellipsis"), action: onMore) + actionTile("More", tile: tile, icon: .system("ellipsis"), action: onMore) } } } @@ -327,11 +327,15 @@ struct CurrencyInfoContentV2: View { private func actionTile( _ title: String, + tile: CurrencyInfoActions.Tile, icon: TileIcon, isEnabled: Bool = true, action: @escaping () -> Void ) -> some View { - Button(action: action) { + Button { + Analytics.tokenInfoTileTapped(tile: tile, mint: metadata.mint) + action() + } label: { VStack(spacing: 8) { tileIcon(icon) .frame(height: 24) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 9df08dcee..4416274b3 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -263,6 +263,7 @@ private struct CurrencyInfoScreenContent: View { onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) }, onMore: { isShowingMore = true }, onSelectCommunityChat: { chat in + Analytics.tokenInfoCommunityChatTapped(mint: metadata.mint) conversationController.hold(chat) router.push(.chatProfile(chat.id, origin: .featuredGroup)) }, @@ -390,6 +391,7 @@ private struct CurrencyInfoScreenContent: View { private func runPendingMoreRow() { guard let row = pendingMoreRow else { return } pendingMoreRow = nil + Analytics.tokenInfoMoreOptionPicked(row: row, mint: mint) switch row { case .convert: router.push(.convertCurrency(mint)) case .withdraw: router.push(.withdrawCurrency(mint)) diff --git a/Flipcash/Core/Screens/Main/ScanViewModel.swift b/Flipcash/Core/Screens/Main/ScanViewModel.swift index 3d81f0263..9eced3171 100644 --- a/Flipcash/Core/Screens/Main/ScanViewModel.swift +++ b/Flipcash/Core/Screens/Main/ScanViewModel.swift @@ -235,7 +235,7 @@ class ScanViewModel { switch outcome { case .code(let code, let match): Analytics.galleryScanFoundCode( - tier: match.tier.rawValue, + tier: match.tier, zoom: Double(match.zoom), elapsed: elapsed ) diff --git a/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift b/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift index fb214fb3a..87ef7a2f2 100644 --- a/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift +++ b/Flipcash/Core/Screens/Main/Search/ChatSearchScreen.swift @@ -80,12 +80,16 @@ struct ChatSearchScreen: View { .animation(.easeInOut(duration: 0.2), value: model?.isIdle ?? true) } .toolbar(.hidden, for: .navigationBar) - .onChange(of: isActive) { _, _ in startFresh() } + .onChange(of: isActive) { _, active in + if active { Analytics.searchOpened() } + startFresh() + } .onChange(of: scenePhase) { _, phase in if phase == .background { startFresh() } } .task { guard model == nil else { return } + if isActive { Analytics.searchOpened() } let model = makeModel() self.model = model await model.load() @@ -179,16 +183,19 @@ struct ChatSearchScreen: View { } private func open(_ conversation: Conversation) { + Analytics.searchResultTapped(.chat) sessionContainer.chatSearchRecents.record(conversation.id) push(.tipConversation(conversation.id)) } private func open(_ hit: ChatSearchMessageHit) { + Analytics.searchResultTapped(.message) sessionContainer.chatSearchRecents.record(hit.conversationID) push(.chatMessage(hit.conversationID, MessageLanding(messageID: hit.messageID, returnTo: nil))) } private func open(_ link: ChatSearchLink) { + Analytics.searchResultTapped(.link) ExternalLinkOpener(session: session, trustedWebsites: container.trustedWebsites).open(link.url) } @@ -197,6 +204,7 @@ struct ChatSearchScreen: View { /// /// The keyboard goes down first, so it doesn't slide away over the currency. private func openCurrency(_ mint: MintMetadata) { + Analytics.searchResultTapped(.currency) guard isFieldFocused else { showCurrency(mint) return @@ -214,6 +222,7 @@ struct ChatSearchScreen: View { if chrome == .pushed { router.pop(on: .tips) } + Analytics.tokenInfoOpened(from: .openedFromSearch, mint: mint.address) router.navigate(to: .currencyInfo(mint.address)) } } diff --git a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift index 6619d0866..4cd2c1c5d 100644 --- a/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift +++ b/Flipcash/Core/Screens/Main/Tips/NewChatScreen.swift @@ -31,7 +31,7 @@ struct NewChatScreen: View { title: "Create a New Group", accessibilityIdentifier: "new-chat-private-group" ) { - Analytics.groupNewOpened() + Analytics.groupNewOpened(type: .private_) router.push(.newPrivateGroup) } @@ -68,7 +68,7 @@ struct NewChatScreen: View { return } - Analytics.groupNewOpened() + Analytics.groupNewOpened(type: .community) router.push(.newPublicGroup) } } diff --git a/Flipcash/Core/Screens/Moderation/GroupModeration.swift b/Flipcash/Core/Screens/Moderation/GroupModeration.swift index 5bd4ba2b2..b302fd6cb 100644 --- a/Flipcash/Core/Screens/Moderation/GroupModeration.swift +++ b/Flipcash/Core/Screens/Moderation/GroupModeration.swift @@ -126,7 +126,13 @@ struct GroupModeration { var didRemove: @MainActor (UserID) -> Void = { _ in } func silence(_ userID: UserID, firstName: String, duration: SilenceDuration, now: Date = .now) async throws { - try await moderation.silenceUser(owner: owner, conversationID: conversationID, userID: userID, silence: duration.silence(from: now)) + do { + try await moderation.silenceUser(owner: owner, conversationID: conversationID, userID: userID, silence: duration.silence(from: now)) + } catch { + Analytics.groupMemberSilenced(duration: duration, error: error) + throw error + } + Analytics.groupMemberSilenced(duration: duration, error: nil) announce(GroupModerationCopy.silencedToast(firstName: firstName, duration: duration)) } @@ -138,7 +144,13 @@ struct GroupModeration { /// Puts `userID` out of the group and bars them from rejoining. The roster drops them through /// the `MemberLeft` update the other members get, so nothing local needs to move. func remove(_ userID: UserID) async throws { - try await moderation.banUser(owner: owner, conversationID: conversationID, userID: userID) + do { + try await moderation.banUser(owner: owner, conversationID: conversationID, userID: userID) + } catch { + Analytics.groupMemberRemoved(error: error) + throw error + } + Analytics.groupMemberRemoved(error: nil) didRemove(userID) } diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 0eb7ab67f..28cfe7747 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -251,9 +251,7 @@ class OnboardingViewModel { // MARK: - Registration - private func registerAccount(mnemonic: MnemonicPhrase) async throws { - let owner = mnemonic.solanaKeyPair() - - Analytics.createAccount(owner: owner.publicKey) + Analytics.createAccount() let account = try await sessionAuthenticator.initialize( using: mnemonic, diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift index dfe6604ff..8e3b2d7a4 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestCreateViewModel.swift @@ -150,9 +150,12 @@ final class PaymentRequestCreateViewModel { do { request = try await create(amount: amount, people: people, key: idempotencyKey, now: now) created = request + Analytics.paymentRequestCreated(payerCount: people.askedCount, amount: amount, error: nil) } catch let error as ErrorCreatePaymentRequest { + Analytics.paymentRequestCreated(payerCount: people.askedCount, amount: amount, error: error) return .failed(alert(for: error)) } catch { + Analytics.paymentRequestCreated(payerCount: people.askedCount, amount: amount, error: error) ErrorReporting.captureError(error, reason: "Failed to create payment request") return .failed(Self.genericError) } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 63308d225..05eb694f0 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -265,7 +265,7 @@ struct PaymentRequestDetailsScreen: View { subtitle: "This will stop payments. Anyone who hasn't paid will be marked Didn't pay. Payments already made won't be affected." ) { DialogAction.destructive("Close Request") { - Task { await update { try await $0.close(requestID) } } + Task { await close() } } DialogAction.cancel() } @@ -303,6 +303,18 @@ struct PaymentRequestDetailsScreen: View { } /// Runs a creator action; the store takes the server's response, and a failure refetches. + private func close() async { + await update { requests in + do { + try await requests.close(requestID) + Analytics.paymentRequestClosed(error: nil) + } catch { + Analytics.paymentRequestClosed(error: error) + throw error + } + } + } + private func update(_ action: (PaymentRequests) async throws -> Void) async { isUpdating = true defer { isUpdating = false } diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift index d726df249..a33dd3dea 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -189,11 +189,11 @@ final class PaymentRequestPayment { intentID: intentKey ) self.intentID = nil - Analytics.transfer(event: .sentCash, exchangedFiat: amount, grabTime: nil, successful: true, error: nil) + Analytics.sentCash(exchangedFiat: amount, context: .paymentRequest, error: nil) try? await requests.refresh(requestID) return .success } catch { - Analytics.transfer(event: .sentCash, exchangedFiat: amount, grabTime: nil, successful: false, error: error) + Analytics.sentCash(exchangedFiat: amount, context: .paymentRequest, error: error) needsRefetch = true guard !(error is CancellationError) else { return .failed } // A refusal because the request ended reads as "Request Closed", not a send failure. diff --git a/Flipcash/Core/Screens/Send/GroupSendViewModel.swift b/Flipcash/Core/Screens/Send/GroupSendViewModel.swift index fd0699fbc..e530186f6 100644 --- a/Flipcash/Core/Screens/Send/GroupSendViewModel.swift +++ b/Flipcash/Core/Screens/Send/GroupSendViewModel.swift @@ -273,10 +273,10 @@ final class GroupSendViewModel { } else { try await multiSender.sendMultiple(payments, verifiedState: pin, chat: chat) } - Analytics.transfer(event: .sentCash, exchangedFiat: total, grabTime: nil, successful: true, error: nil) + Analytics.sentCash(exchangedFiat: total, context: .groupSend, recipientCount: payments.count, error: nil) return .success } catch { - Analytics.transfer(event: .sentCash, exchangedFiat: total, grabTime: nil, successful: false, error: error) + Analytics.sentCash(exchangedFiat: total, context: .groupSend, recipientCount: payments.count, error: error) if !(error is CancellationError) { showSendError() } diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index e6b165179..fbe67a279 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -438,7 +438,7 @@ final class SendAmountViewModel { // is the single source for whether this reports as a tip, so the // wire `action` and the analytics event never drift apart. let chat = chatPaymentMetadata() - let transferEvent: Analytics.TransferEvent = (chat?.isTip ?? false) ? .sentTip : .sentCash + let isTip = chat?.isTip ?? false do { try await sender.send( @@ -447,10 +447,18 @@ final class SendAmountViewModel { to: recipient, chat: chat ) - Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil) + if isTip { + Analytics.transfer(event: .sentTip, exchangedFiat: amountToSend, grabTime: nil, successful: true, error: nil) + } else { + Analytics.sentCash(exchangedFiat: amountToSend, context: .direct, error: nil) + } return .success } catch { - Analytics.transfer(event: transferEvent, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error) + if isTip { + Analytics.transfer(event: .sentTip, exchangedFiat: amountToSend, grabTime: nil, successful: false, error: error) + } else { + Analytics.sentCash(exchangedFiat: amountToSend, context: .direct, error: error) + } showSendError() return .failed } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 7a38611e3..e3f8a1be3 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -132,7 +132,7 @@ final class SessionAuthenticator { accountManager.set(keyAccount: description.account, userID: userID) completeLogin(with: account) - Analytics.track(event: Analytics.GeneralEvent.autoLoginComplete) + Analytics.autoLoginComplete() Task { await self.refreshRecentAccount(description.account) } return @@ -145,7 +145,7 @@ final class SessionAuthenticator { do { let account = try await self.initialize(using: description.account.mnemonic, isRegistration: false, interactive: false) self.completeLogin(with: account) - Analytics.track(event: Analytics.GeneralEvent.autoLoginComplete) + Analytics.autoLoginComplete() } catch { self.logout() } diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index a34f5c641..01395409c 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -7,44 +7,26 @@ import Foundation import FlipcashCore +import SharedCore // MARK: - Domain Event Enums - extension Analytics { - enum GeneralEvent: String, AnalyticsEvent { - case autoLoginComplete = "Auto-login complete" - } - - enum AccountEvent: String, AnalyticsEvent { - case createAccount = "Create Account" - } - - /// A tapped button. Most are built by the shared contract; the rest are iOS-only or - /// unused and keep their names here. + /// A tapped button, built by the shared contract. enum ButtonEvent { case createAccount case saveAccessKey case wroteAccessKey - case allowCamera case allowPush case skipPush case buyWithReserves case buyWithCurrency case give - case sell case shareTokenInfo } - fileprivate enum NativeButtonEvent: String, AnalyticsEvent { - case allowCamera = "Button: Allow Camera" - case buyWithCurrency = "Button: Buy With Currency" - case give = "Button: Give" - case sell = "Button: Sell" - } - enum TransferEvent: String, AnalyticsEvent { case withdrawal = "Withdrawal" - case sentCash = "Sent Cash" case sentTip = "Sent Tip" case sendCashLink = "Send Cash Link" case receiveCashLink = "Receive Cash Link" @@ -70,11 +52,6 @@ extension Analytics { } } - enum SendEvent: String, AnalyticsEvent { - case showEnterPhone = "Send: Show Enter Phone" - case showConfirmPhone = "Send: Show Confirm Phone" - } - enum ConversationEvent: String, AnalyticsEvent { case tipReceived = "Tip Received" } @@ -93,53 +70,39 @@ extension Analytics { case tipCardSetup } - enum GalleryScanEvent: String, AnalyticsEvent { - case codeFound = "Gallery Scan: Code Found" - case nothingFound = "Gallery Scan: Nothing Found" - } - /// Why a gallery scan came back with nothing. `cancelled` covers both the user tapping /// Cancel and the budget running out, which are the same event from the search's side. - enum GalleryScanMiss: String { - case exhausted = "Exhausted" - case cancelled = "Cancelled" - case routeRefused = "Route Refused" + enum GalleryScanMiss { + case exhausted + case cancelled + case routeRefused + + /// The shared `Exhausted` flag: true when the search was stopped before the ladder ended. + /// The shared event has no separate user-cancel value, so a Cancel tap reports true too. + var stoppedEarly: Bool { self == .cancelled } } enum WalletEvent: String, AnalyticsEvent { case connect = "Wallet: Connect" case requestAmount = "Wallet: Request Amount" - case transactionsSubmitted = "Wallet: Transactions Submitted" - case transactionsFailed = "Wallet: Transactions Failed" case cancel = "Wallet: Cancel" } - /// Where Token Info was opened from. Deeplink, Wallet, Discovery and Chat are built by the - /// shared contract; Give and Send are iOS-only and keep their names here. + /// Where Token Info was opened from, built by the shared contract. enum TokenInfoEvent { case openedFromDeeplink case openedFromWallet case openedFromDiscovery + case openedFromSearch case openedFromChat case openedFromGive case openedFromSend } - fileprivate enum NativeTokenInfoEvent: String, AnalyticsEvent { - case openedFromGive = "Token Info: Opened From Give" - case openedFromSend = "Token Info: Opened From Send" - } - enum TokenTransactionEvent: String, AnalyticsEvent { case purchaseWithReserves = "Token Purchase With Reserves" case purchaseWithCurrency = "Token Purchase With Currency" case sell = "Token Sell" - case withdraw = "Token Withdrawal" - } - - enum CurrencyLaunchEvent: String, AnalyticsEvent { - case launchWithReserves = "Currency Launch With Reserves" - case launchWithCurrency = "Currency Launch With Currency" } enum DeeplinkEvent: String, AnalyticsEvent { @@ -194,10 +157,8 @@ extension Analytics { case .skipPush: track(ButtonEvents.shared.tapped(button: .skipPush)) case .buyWithReserves: track(ButtonEvents.shared.tapped(button: .buyWithReserves)) case .shareTokenInfo: track(ButtonEvents.shared.tapped(button: .shareTokenInfo)) - case .allowCamera: track(event: NativeButtonEvent.allowCamera) - case .buyWithCurrency: track(event: NativeButtonEvent.buyWithCurrency) - case .give: track(event: NativeButtonEvent.give) - case .sell: track(event: NativeButtonEvent.sell) + case .buyWithCurrency: track(ButtonEvents.shared.tapped(button: .buyWithCurrency)) + case .give: track(ButtonEvents.shared.tapped(button: .give)) } } } @@ -205,13 +166,14 @@ extension Analytics { // MARK: - Account - extension Analytics { - static func createAccount(owner: PublicKey) { - track( - event: AccountEvent.createAccount, - properties: [ - .ownerPublicKey: owner.base58, - ] - ) + /// The user chose to create an account and registration began. + static func createAccount() { + track(AccountEvents.shared.createAccount()) + } + + /// A saved account logged in at launch with no action from the user. + static func autoLoginComplete() { + track(AccountEvents.shared.autoLoginComplete()) } } @@ -270,42 +232,36 @@ extension Analytics { track(ScanEvents.shared.galleryImagePicked()) } - /// A Kik code was found, and how deep in the ladder it was. The tier and zoom are the - /// reason these events exist: the ladder's constants were inherited without ever being - /// measured, and a tier distribution is what would justify or shrink them. - static func galleryScanFoundCode(tier: String, zoom: Double, elapsed: TimeInterval) { - track( - event: GalleryScanEvent.codeFound, - properties: [ - .type: "Kik", - .tier: tier, - .zoom: zoom, - .elapsed: elapsed, - ] - ) + /// A code was found, and how deep in the ladder it was. The tier and zoom are the reason + /// this event exists: the ladder's constants were inherited without ever being measured, and + /// a tier distribution is what would justify or shrink them. + static func galleryScanFoundCode(tier: StillImageCodeSearch.Tier, zoom: Double, elapsed: TimeInterval) { + track(ScanEvents.shared.gallerySucceeded(tier: galleryTier(tier), zoom: zoom, timeMillis: milliseconds(elapsed))) } - /// A QR code was found. No tier: QR is a single pass over the whole image. + /// A QR code was found. QR is a single pass over the whole image, so it reports the first + /// tier at zoom 1. The shared event has no Kik or QR type, so the two are no longer told apart. static func galleryScanFoundQR(elapsed: TimeInterval) { - track( - event: GalleryScanEvent.codeFound, - properties: [ - .type: "QR", - .elapsed: elapsed, - ] - ) + track(ScanEvents.shared.gallerySucceeded(tier: galleryTier(.wholeImage), zoom: 1, timeMillis: milliseconds(elapsed))) } - /// Nothing usable was found, and how long that took. A search that ran out of ladder is - /// a different fact from one that ran out of time. + /// Nothing usable was found, and how long that took. static func galleryScanFoundNothing(reason: GalleryScanMiss, elapsed: TimeInterval) { - track( - event: GalleryScanEvent.nothingFound, - properties: [ - .state: reason.rawValue, - .elapsed: elapsed, - ] - ) + track(ScanEvents.shared.galleryFailed(timeMillis: milliseconds(elapsed), exhausted: reason.stoppedEarly)) + } + + /// The shared `Tier`: the rung of the crop ladder that decoded, 1 to 3. + static func galleryTier(_ tier: StillImageCodeSearch.Tier) -> Int32 { + switch tier { + case .wholeImage: 1 + case .quadrant: 2 + case .window: 3 + } + } + + /// The shared events carry durations in milliseconds. + static func milliseconds(_ interval: TimeInterval) -> Int64 { + Int64((interval * 1000).rounded()) } } @@ -325,6 +281,35 @@ private extension Analytics { } } +extension ExchangedFiat { + /// The shared contract's amount block. USDC is the USDF value, which Android always sends. + var analyticsAmount: AnalyticsAmount { + AnalyticsAmount( + fiat: nativeAmount.doubleValue, + currency: currencyRate.currency.rawValue, + usdc: usdfValue.doubleValue, + quarks: Int64(clamping: onChainAmount.quarks), + exchangeRate: KotlinDouble(double: currencyRate.fx.analyticsValue), + mint: mint.base58 + ) + } +} + +extension FiatAmount { + /// The shared contract's amount block for a fiat-only amount, as Android builds it: no rate or + /// mint, with `USDC` repeating the amount and `Quarks` at six decimals. + var analyticsAmount: AnalyticsAmount { + AnalyticsAmount( + fiat: doubleValue, + currency: currency.rawValue, + usdc: doubleValue, + quarks: Int64((doubleValue * 1_000_000).rounded()), + exchangeRate: nil, + mint: nil + ) + } +} + // MARK: - Cash Transfer - extension Analytics { @@ -338,6 +323,35 @@ extension Analytics { track(TransferEvents.shared.giveBillStart()) } + /// Cash was sent, or a request share was paid. `error` is nil on success. `recipientCount` + /// is for `.groupSend` only. + static func sentCash(exchangedFiat: ExchangedFiat, context: SendContext, recipientCount: Int? = nil, error: Error?) { + track( + TransferEvents.shared.sentCash( + state: .from(error), + amount: exchangedFiat.analyticsAmount, + error: nil, + context: context, + recipientCount: recipientCount.map { KotlinInt(int: Int32(clamping: $0)) } + ), + error: error + ) + } + + /// A reaction boost was paid for. `exchangedFiat` is the whole boost, before the split. + /// `error` is nil on success. + static func sentBoost(chatType: ConversationType?, exchangedFiat: ExchangedFiat, error: Error?) { + track( + TransferEvents.shared.sentBoost( + chatType: chatType.sharedChatType, + state: .from(error), + amount: exchangedFiat.analyticsAmount, + error: nil + ), + error: error + ) + } + static func withdrawal(exchangedFiat: ExchangedFiat?, successful: Bool, error: Error?) { var properties: [Property: AnalyticsValue] = exchangedFiat.map(amountProperties) ?? [:] properties[.state] = successful ? String.success : String.failure @@ -508,9 +522,9 @@ extension Analytics { // MARK: - Group - extension Analytics { - /// The New Group form opened. - static func groupNewOpened() { - track(GroupEvents.shared.doNewOpened()) + /// The New Group form opened from the New Chat row of this `type`. + static func groupNewOpened(type: GroupType) { + track(GroupEvents.shared.doNewOpened(type: type)) } /// `StartChat` returned for a new group; `error` is nil on success. @@ -586,6 +600,36 @@ extension Analytics { track(GroupEvents.shared.infoOpened(memberCount: Int32(clamping: memberCount), isMember: isMember)) } + /// `CreateInvite` returned; `error` is nil on success. + static func groupInviteLinkCreated(error: Error?) { + track(GroupEvents.shared.inviteLinkCreated(state: .from(error), error: error.map(chatResult))) + } + + /// `RevokeInvite` returned after the host confirmed; `error` is nil on success. + static func groupInviteLinkRevoked(error: Error?) { + track(GroupEvents.shared.inviteLinkRevoked(state: .from(error), error: error.map(chatResult))) + } + + /// `AdmitLobbyMember` returned; `error` is nil on success. + static func groupJoinRequestApproved(error: Error?) { + track(GroupEvents.shared.joinRequestApproved(state: .from(error), error: error.map(chatResult))) + } + + /// `DenyLobbyMember` returned; `error` is nil on success. + static func groupJoinRequestDenied(error: Error?) { + track(GroupEvents.shared.joinRequestDenied(state: .from(error), error: error.map(chatResult))) + } + + /// `SilenceUser` returned; `error` is nil on success. + static func groupMemberSilenced(duration: SilenceDuration, error: Error?) { + track(GroupEvents.shared.memberSilenced(duration: duration.shared, state: .from(error), error: error.map(chatResult))) + } + + /// `BanUser` returned after the host confirmed removal; `error` is nil on success. + static func groupMemberRemoved(error: Error?) { + track(GroupEvents.shared.memberRemoved(state: .from(error), error: error.map(chatResult))) + } + /// The `Error` value the group and mute events send: the chat RPC's result as the proto names /// it, or `Network` for anything the server didn't answer with a result. /// @@ -646,12 +690,66 @@ extension Analytics { case .notFound: "NotFound" case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" } + case let error as ErrorCreateInvite: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .invalidExpiresAt: "InvalidExpiresAt" + case .tooManyInvites: "TooManyInvites" + case .invalidSignature: "InvalidSignature" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } + case let error as ErrorRevokeInvite: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } + case let error as ErrorAdmitLobbyMember: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .notInLobby: "NotInLobby" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } + case let error as ErrorDenyLobbyMember: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } + case let error as ErrorSilenceUser: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .cannotRestrictCreator: "CannotRestrictCreator" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } + case let error as ErrorBanUser: + switch error { + case .denied: "Denied" + case .notFound: "NotFound" + case .cannotRestrictCreator: "CannotRestrictCreator" + case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" + } default: "Network" } } } +extension SilenceDuration { + /// The shared contract's `Duration` for this choice. + var shared: SharedCoreKit.SilenceDuration { + switch self { + case .oneHour: .oneHour + case .eightHours: .eightHours + case .oneWeek: .oneWeek + case .forever: .forever + } + } +} + private extension EventState { /// `Success` when there was no error. static func from(_ error: Error?) -> EventState { @@ -762,16 +860,94 @@ extension Analytics { track(TokenInfoEvents.shared.opened(source: .wallet, mint: mint.base58)) case .openedFromDiscovery: track(TokenInfoEvents.shared.opened(source: .discovery, mint: mint.base58)) + case .openedFromSearch: + track(TokenInfoEvents.shared.opened(source: .search, mint: mint.base58)) case .openedFromChat: track(TokenInfoEvents.shared.opened(source: .chat, mint: mint.base58)) case .openedFromGive: - track(event: NativeTokenInfoEvent.openedFromGive, properties: [.mint: mint.base58]) + track(TokenInfoEvents.shared.opened(source: .give, mint: mint.base58)) case .openedFromSend: - track(event: NativeTokenInfoEvent.openedFromSend, properties: [.mint: mint.base58]) + track(TokenInfoEvents.shared.opened(source: .send, mint: mint.base58)) + } + } + + /// An action tile on Currency Info was tapped, before anything opens. + static func tokenInfoTileTapped(tile: CurrencyInfoActions.Tile, mint: PublicKey) { + track(TokenInfoEvents.shared.tileTapped(tile: tile.shared, mint: mint.base58)) + } + + /// A row of the More sheet was picked and is about to run. + static func tokenInfoMoreOptionPicked(row: CurrencyInfoActions.MoreRow, mint: PublicKey) { + track(TokenInfoEvents.shared.moreOptionPicked(option: row.shared, mint: mint.base58)) + } + + /// A community chat was tapped on Currency Info or its View All list. + static func tokenInfoCommunityChatTapped(mint: PublicKey) { + track(TokenInfoEvents.shared.communityChatTapped(mint: mint.base58)) + } +} + +extension CurrencyInfoActions.Tile { + /// The shared contract's tile. + var shared: CurrencyInfoTile { + switch self { + case .send: .send + case .buy: .buy + case .buyIn: .buyIn + case .transactions: .transactions + case .more: .more } } } +extension CurrencyInfoActions.MoreRow { + /// The shared contract's More option. + var shared: CurrencyInfoMoreOption { + switch self { + case .convert: .convert + case .withdraw: .withdraw + case .deposit: .deposit + } + } +} + +// MARK: - Search - + +extension Analytics { + /// The Chats search screen was shown. + static func searchOpened() { + track(SearchEvents.shared.opened()) + } + + /// A search result row was tapped, at the tap. + static func searchResultTapped(_ type: SearchResultType) { + track(SearchEvents.shared.resultTapped(resultType: type)) + } +} + +// MARK: - Payment Requests - + +extension Analytics { + /// `CreateRequest` returned, once per confirm. `amount` is the per-person amount and + /// `payerCount` the people asked, the creator excluded. `error` is nil on success. + static func paymentRequestCreated(payerCount: Int, amount: FiatAmount, error: Error?) { + track( + PaymentRequestEvents.shared.created( + payerCount: Int32(clamping: payerCount), + state: .from(error), + amount: amount.analyticsAmount, + error: nil + ), + error: error + ) + } + + /// The creator closed a request early and the call returned. `error` is nil on success. + static func paymentRequestClosed(error: Error?) { + track(PaymentRequestEvents.shared.closed(state: .from(error), error: nil), error: error) + } +} + // MARK: - Token Transactions - extension Analytics { @@ -809,15 +985,19 @@ extension Analytics { extension Analytics { /// `Mint` is the launched currency; `Payment Mint` is the token that paid /// the launch cost. - static func currencyLaunch(event: CurrencyLaunchEvent, launchedMint: PublicKey, paymentMint: PublicKey, exchangedFiat: ExchangedFiat, successful: Bool, error: Error? = nil) { - let properties: [Property: AnalyticsValue] = [ - .state: successful ? String.success : String.failure, - .mint: launchedMint.base58, - .paymentMint: paymentMint.base58, - .fiat: exchangedFiat.nativeAmount.doubleValue, - .currency: exchangedFiat.currencyRate.currency.rawValue, - ] - track(event: event, properties: properties, error: error) + static func currencyLaunch(method: LaunchMethod, launchedMint: PublicKey, paymentMint: PublicKey, exchangedFiat: ExchangedFiat, successful: Bool, error: Error? = nil) { + track( + CurrencyLaunchEvents.shared.launched( + method: method, + state: successful ? .success : .failure, + mint: launchedMint.base58, + paymentMint: paymentMint.base58, + fiat: exchangedFiat.nativeAmount.doubleValue, + currency: exchangedFiat.currencyRate.currency.rawValue, + error: nil + ), + error: error + ) } } @@ -854,7 +1034,6 @@ extension Analytics { extension Analytics { enum Property: String { - case ownerPublicKey = "Owner Public Key" case grabTime = "Grab Time" case state = "State" @@ -872,10 +1051,6 @@ extension Analytics { case chatType = "Chat Type" case error = "Error" - case tier = "Tier" - case zoom = "Zoom" - case elapsed = "Elapsed" - case title = "Title" case message = "Message" case screen = "Screen" diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index 0d16511fc..cfe0356c7 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -49,7 +49,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.18.0")) }() let package = Package( diff --git a/FlipcashTests/Analytics/CatalogueBatchEventsTests.swift b/FlipcashTests/Analytics/CatalogueBatchEventsTests.swift new file mode 100644 index 000000000..db733c7b8 --- /dev/null +++ b/FlipcashTests/Analytics/CatalogueBatchEventsTests.swift @@ -0,0 +1,220 @@ +// +// CatalogueBatchEventsTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +/// The invite, moderation, search, money-flow and Gallery Scan events, written as literals. +@MainActor +@Suite("Catalogue batch events", .serialized) +struct CatalogueBatchEventsTests { + + init() { + Analytics.tokenSymbolResolver = nil + } + + private static let fiat = ExchangedFiat.compute( + onChainAmount: TokenAmount(quarks: 5_000_000, mint: .usdf), + rate: Rate(fx: 1, currency: .usd), + supplyQuarks: nil + ) + + // MARK: - Invite links and join requests - + + @Test("Invite link events carry State, and the Error on failure") + func inviteLinks() throws { + let created = Analytics.recordingSends { Analytics.groupInviteLinkCreated(error: nil) } + let createdEvent = try created.single + #expect(createdEvent.name == "Group: Invite Link Created") + #expect(createdEvent.properties["State"] as? String == "Success") + #expect(createdEvent.properties["Error"] == nil) + + let revoked = Analytics.recordingSends { Analytics.groupInviteLinkRevoked(error: ErrorRevokeInvite.notFound) } + let revokedEvent = try revoked.single + #expect(revokedEvent.name == "Group: Invite Link Revoked") + #expect(revokedEvent.properties["State"] as? String == "Failure") + #expect(revokedEvent.properties["Error"] as? String == "NotFound") + } + + @Test("Join request events carry State and Error") + func joinRequests() throws { + let approved = try Analytics.recordingSends { Analytics.groupJoinRequestApproved(error: ErrorAdmitLobbyMember.denied) }.single + #expect(approved.name == "Group: Join Request Approved") + #expect(approved.properties["Error"] as? String == "Denied") + + let denied = try Analytics.recordingSends { Analytics.groupJoinRequestDenied(error: nil) }.single + #expect(denied.name == "Group: Join Request Denied") + #expect(denied.properties["State"] as? String == "Success") + } + + // MARK: - Moderation - + + @Test("Member Silenced carries the duration", arguments: [ + (Flipcash.SilenceDuration.oneHour, "1 Hour"), + (Flipcash.SilenceDuration.eightHours, "8 Hours"), + (Flipcash.SilenceDuration.oneWeek, "1 Week"), + (Flipcash.SilenceDuration.forever, "Forever"), + ]) + func silenced(_ duration: Flipcash.SilenceDuration, _ expected: String) throws { + let event = try Analytics.recordingSends { Analytics.groupMemberSilenced(duration: duration, error: nil) }.single + #expect(event.name == "Group: Member Silenced") + #expect(event.properties["Duration"] as? String == expected) + } + + @Test("Member Removed reports a moderation error by name") + func removed() throws { + let event = try Analytics.recordingSends { Analytics.groupMemberRemoved(error: ErrorBanUser.cannotRestrictCreator) }.single + #expect(event.name == "Group: Member Removed") + #expect(event.properties["Error"] as? String == "CannotRestrictCreator") + } + + @Test("Invite, lobby and moderation errors report their name, not Network", arguments: [ + (ErrorCreateInvite.tooManyInvites as Error, "TooManyInvites"), + (ErrorRevokeInvite.denied, "Denied"), + (ErrorAdmitLobbyMember.notInLobby, "NotInLobby"), + (ErrorDenyLobbyMember.notFound, "NotFound"), + (ErrorSilenceUser.cannotRestrictCreator, "CannotRestrictCreator"), + (ErrorBanUser.denied, "Denied"), + (ErrorBanUser.transportFailure, "Network"), + ]) + func moderationErrors(_ error: Error, _ expected: String) { + #expect(Analytics.chatResult(error) == expected) + } + + // MARK: - Search and Currency Info - + + @Test("Search events") + func search() throws { + let opened = try Analytics.recordingSends { Analytics.searchOpened() }.single + #expect(opened.name == "Search: Opened") + + let tapped = try Analytics.recordingSends { Analytics.searchResultTapped(.currency) }.single + #expect(tapped.name == "Search: Result Tapped") + #expect(tapped.properties["Result Type"] as? String == "Currency") + } + + @Test("Token Info from Search sends the catalogue name") + func tokenInfoFromSearch() throws { + let event = try Analytics.recordingSends { Analytics.tokenInfoOpened(from: .openedFromSearch, mint: .usdf) }.single + #expect(event.name == "Token Info: Opened From Search") + #expect(event.properties["Mint"] as? String == PublicKey.usdf.base58) + } + + @Test("Currency Info tiles and More rows map to the catalogue values", arguments: [ + (CurrencyInfoActions.Tile.send, "Send"), + (.buy, "Buy"), + (.buyIn, "Buy In"), + (.transactions, "Transactions"), + (.more, "More"), + ]) + func tiles(_ tile: CurrencyInfoActions.Tile, _ expected: String) throws { + let event = try Analytics.recordingSends { Analytics.tokenInfoTileTapped(tile: tile, mint: .usdf) }.single + #expect(event.name == "Token Info: Tile Tapped") + #expect(event.properties["Tile"] as? String == expected) + #expect(event.properties["Mint"] as? String == PublicKey.usdf.base58) + } + + @Test("More option and community chat events") + func moreAndCommunity() throws { + let more = try Analytics.recordingSends { Analytics.tokenInfoMoreOptionPicked(row: .withdraw, mint: .usdf) }.single + #expect(more.name == "Token Info: More Option Picked") + #expect(more.properties["Option"] as? String == "Withdraw") + + let chat = try Analytics.recordingSends { Analytics.tokenInfoCommunityChatTapped(mint: .usdf) }.single + #expect(chat.name == "Token Info: Community Chat Tapped") + } + + // MARK: - Money flow - + + @Test("Sent Cash carries its Context, and the count for a Group Send") + func sentCash() throws { + let direct = try Analytics.recordingSends { Analytics.sentCash(exchangedFiat: Self.fiat, context: .direct, error: nil) }.single + #expect(direct.name == "Sent Cash") + #expect(direct.properties["Context"] as? String == "Direct") + #expect(direct.properties["Recipient Count"] == nil) + + let group = try Analytics.recordingSends { Analytics.sentCash(exchangedFiat: Self.fiat, context: .groupSend, recipientCount: 4, error: nil) }.single + #expect(group.properties["Context"] as? String == "Group Send") + #expect(group.properties["Recipient Count"] as? Double == 4) + + let request = try Analytics.recordingSends { Analytics.sentCash(exchangedFiat: Self.fiat, context: .paymentRequest, error: nil) }.single + #expect(request.properties["Context"] as? String == "Payment Request") + } + + @Test("Sent Boost carries the chat type and a failure") + func sentBoost() throws { + let event = try Analytics.recordingSends { Analytics.sentBoost(chatType: .group, exchangedFiat: Self.fiat, error: ErrorSendMessage.denied) }.single + #expect(event.name == "Sent Boost") + #expect(event.properties["State"] as? String == "Failure") + #expect(event.properties["Chat Type"] != nil) + } + + @Test("Payment Request events") + func paymentRequests() throws { + let created = try Analytics.recordingSends { + Analytics.paymentRequestCreated(payerCount: 3, amount: Self.fiat.nativeAmount, error: nil) + }.single + #expect(created.name == "Payment Request: Created") + #expect(created.properties["Payer Count"] as? Double == 3) + #expect(created.properties["State"] as? String == "Success") + + let closed = try Analytics.recordingSends { Analytics.paymentRequestClosed(error: nil) }.single + #expect(closed.name == "Payment Request: Closed") + } + + // MARK: - Gallery Scan - + + @Test("A found code is Gallery Scan: Succeeded, with the tier as 1 to 3 and the time in ms") + func galleryFound() throws { + let event = try Analytics.recordingSends { + Analytics.galleryScanFoundCode(tier: .quadrant, zoom: 2, elapsed: 1.25) + }.single + #expect(event.name == "Gallery Scan: Succeeded") + #expect(event.properties["Tier"] as? Double == 2) + #expect(event.properties["Zoom"] as? Double == 2) + #expect(event.properties["Time"] as? Double == 1250) + } + + @Test("Nothing found is Gallery Scan: Failed; only a cancel stops early") + func galleryNothing() throws { + let exhausted = try Analytics.recordingSends { Analytics.galleryScanFoundNothing(reason: .exhausted, elapsed: 3) }.single + #expect(exhausted.name == "Gallery Scan: Failed") + #expect(exhausted.properties["Time"] as? Double == 3000) + #expect(Analytics.GalleryScanMiss.cancelled.stoppedEarly) + #expect(!Analytics.GalleryScanMiss.exhausted.stoppedEarly) + } + + @Test("Gallery tiers map to 1, 2 and 3") + func galleryTiers() { + #expect(Analytics.galleryTier(.wholeImage) == 1) + #expect(Analytics.galleryTier(.quadrant) == 2) + #expect(Analytics.galleryTier(.window) == 3) + } + + // MARK: - iOS-only events moved to the catalogue - + + @Test("Create Account and Auto-login complete carry no properties") + func accountEvents() throws { + let create = try Analytics.recordingSends { Analytics.createAccount() }.single + #expect(create.name == "Create Account") + #expect(create.properties.isEmpty) + + let login = try Analytics.recordingSends { Analytics.autoLoginComplete() }.single + #expect(login.name == "Auto-login complete") + } + + + @Test("Currency Launch names the method in the event name") + func currencyLaunch() throws { + let event = try Analytics.recordingSends { + Analytics.currencyLaunch(method: .currency, launchedMint: .jeffy, paymentMint: .usdf, exchangedFiat: Self.fiat, successful: true) + }.single + #expect(event.name == "Currency Launch With Currency") + #expect(event.properties["State"] as? String == "Success") + } +} + diff --git a/FlipcashTests/Analytics/GroupEventsTests.swift b/FlipcashTests/Analytics/GroupEventsTests.swift index 08e2a73f2..bea404d3e 100644 --- a/FlipcashTests/Analytics/GroupEventsTests.swift +++ b/FlipcashTests/Analytics/GroupEventsTests.swift @@ -25,10 +25,13 @@ struct GroupEventsTests { // MARK: - Group - - @Test("Group: New Opened has no properties") - func newOpened() throws { - let sent = Analytics.recordingSends { Analytics.groupNewOpened() } - try expectEvent(sent, "Group: New Opened", [:]) + @Test("Group: New Opened carries the row that was tapped", arguments: [ + (GroupType.private_, "Private"), + (GroupType.community, "Community"), + ]) + func newOpened(_ type: GroupType, _ expected: String) throws { + let sent = Analytics.recordingSends { Analytics.groupNewOpened(type: type) } + try expectEvent(sent, "Group: New Opened", ["Type": .text(expected)]) } @Test("Group: Created carries the gate mint and picture on success") @@ -272,3 +275,4 @@ extension GroupInviteSource: @retroactive @unchecked Sendable {} extension GroupAccess: @retroactive @unchecked Sendable {} extension GroupGateFunding: @retroactive @unchecked Sendable {} extension MuteDuration: @retroactive @unchecked Sendable {} +extension GroupType: @retroactive @unchecked Sendable {} diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 9b7e0de5e..646f7fbc1 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -20,7 +20,7 @@ let sharedCore: Package.Dependency = { if let sharedCoreLocalRoot { return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") } - return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.17.0")) + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.18.0")) }() let package = Package(