From b690e49fe6cb58ecbae7c961e73cbdfccb5a84a4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 23:04:31 -0400 Subject: [PATCH 1/2] feat(analytics): send the batch catalogue events Wires the events catalogued in code-android-app#1889, #1890 and #1891: invite links, join requests, member silence and removal, Search, Currency Info tiles, New Chat type, Sent Boost, payment requests, and Sent Cash Context and Recipient Count. Moves iOS-only names onto the catalogue builders. Gallery Scan now sends the catalogue's Succeeded and Failed events instead of Code Found and Nothing Found, so those Mixpanel series stop on the release that ships this. Deletes six event names nothing sent any more. Needs a SharedCore release that includes those three PRs; it builds today only with FLIPCASH_SHARED_CORE_LOCAL. --- .../Conversation/ConversationScreen.swift | 3 +- .../Conversation/ReactionBoostViewModel.swift | 4 + .../Invite Links/GroupInviteLinks.swift | 11 +- .../Screens/Join Requests/HostedLobbies.swift | 14 +- .../CurrencyLaunchProcessingViewModel.swift | 6 +- .../Main/Currency Info/CommunityChats.swift | 1 + .../Currency Info/CurrencyInfoContentV2.swift | 16 +- .../Currency Info/CurrencyInfoScreen.swift | 2 + .../Core/Screens/Main/ScanViewModel.swift | 2 +- .../Main/Search/ChatSearchScreen.swift | 11 +- .../Screens/Main/Tips/NewChatScreen.swift | 4 +- .../Screens/Moderation/GroupModeration.swift | 16 +- .../Onboarding/OnboardingViewModel.swift | 4 +- .../PaymentRequestCreateViewModel.swift | 3 + .../PaymentRequestDetailsScreen.swift | 14 +- .../PaymentRequestPayment.swift | 4 +- .../Screens/Send/GroupSendViewModel.swift | 4 +- .../Screens/Send/SendAmountViewModel.swift | 14 +- .../Core/Session/SessionAuthenticator.swift | 4 +- Flipcash/Utilities/Events.swift | 393 +++++++++++++----- .../Analytics/CatalogueBatchEventsTests.swift | 220 ++++++++++ .../Analytics/GroupEventsTests.swift | 12 +- 22 files changed, 617 insertions(+), 145 deletions(-) create mode 100644 FlipcashTests/Analytics/CatalogueBatchEventsTests.swift 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 47e2b4706..025ba0ced 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 @@ -457,9 +471,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. @@ -535,6 +549,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. /// @@ -595,12 +639,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 { @@ -711,16 +809,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 { @@ -758,15 +934,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 + ) } } @@ -803,7 +983,6 @@ extension Analytics { extension Analytics { enum Property: String { - case ownerPublicKey = "Owner Public Key" case grabTime = "Grab Time" case state = "State" @@ -821,10 +1000,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/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 {} From 215e10743c2bbdb38adb1cd1daded0536e5bd417 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 07:48:57 -0400 Subject: [PATCH 2/2] chore(deps): bump SharedCore to 0.18.0 0.18.0 carries the catalogue entries from android #1889, #1890 and #1891, which the analytics wiring in this branch calls. --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- CrossPlatformVectors/Package.swift | 2 +- FlipcashCore/Package.swift | 2 +- FlipcashUI/Package.swift | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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/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/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(