From dcd3d99eced7dc94f07e83d1177ffbe1b8607bb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:46:20 -0400 Subject: [PATCH 01/37] feat(analytics-events): add the shared event module --- libs/analytics-events/.gitignore | 1 + libs/analytics-events/build.gradle.kts | 34 +++++++++++++++++++ .../com/flipcash/analytics/AnalyticsEvent.kt | 30 ++++++++++++++++ .../com/flipcash/analytics/PeopleCounter.kt | 8 +++++ settings.gradle.kts | 2 ++ 5 files changed, 75 insertions(+) create mode 100644 libs/analytics-events/.gitignore create mode 100644 libs/analytics-events/build.gradle.kts create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt diff --git a/libs/analytics-events/.gitignore b/libs/analytics-events/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/libs/analytics-events/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/analytics-events/build.gradle.kts b/libs/analytics-events/build.gradle.kts new file mode 100644 index 0000000000..a0844a6823 --- /dev/null +++ b/libs/analytics-events/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + android { + namespace = "com.flipcash.analytics" + compileSdk { + version = release(libs.versions.android.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.android.compileSdkMinor.get().toInt() + } + } + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() + macosArm64() + macosX64() + + sourceSets { + commonMain { + // Pure Kotlin -- no external dependencies needed. + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt new file mode 100644 index 0000000000..c39b8246ce --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/AnalyticsEvent.kt @@ -0,0 +1,30 @@ +package com.flipcash.analytics + +/** + * One event, as both apps hand it to Mixpanel. The builders in `events/` are the only + * place a name or a property key is written; nothing else in either app should spell one. + */ +data class AnalyticsEvent(val name: String, val properties: Map = emptyMap()) + +/** + * A property's value with its type. Mixpanel treats `"12"` and `12` as different + * values, so the type is part of the contract, not a detail of either platform's sender. + */ +sealed interface PropertyValue { + data class Text(val value: String) : PropertyValue + data class Number(val value: Double) : PropertyValue + data class Flag(val value: Boolean) : PropertyValue +} + +/** A null value omits the key rather than sending an empty value. */ +internal class PropertiesBuilder { + private val map = LinkedHashMap() + fun text(key: String, value: String?) { if (value != null) map[key] = PropertyValue.Text(value) } + fun number(key: String, value: Double?) { if (value != null) map[key] = PropertyValue.Number(value) } + fun number(key: String, value: Long?) = number(key, value?.toDouble()) + fun flag(key: String, value: Boolean?) { if (value != null) map[key] = PropertyValue.Flag(value) } + fun build(): Map = map +} + +internal fun event(name: String, block: PropertiesBuilder.() -> Unit = {}) = + AnalyticsEvent(name, PropertiesBuilder().apply(block).build()) diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt new file mode 100644 index 0000000000..c732692a40 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt @@ -0,0 +1,8 @@ +package com.flipcash.analytics + +/** Cumulative per-user counters, stored as Mixpanel people properties. */ +enum class PeopleCounter(val key: String) { + TIPS("Tips Received"), + TIPS_VALUE("Tips Received Value"), + MESSAGES("Messages Received"), +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ff1f3436f6..0fa147ef21 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -196,6 +196,7 @@ include( // Internal libs ":libs:analytics", + ":libs:analytics-events", ":libs:biometrics", ":libs:code-detection", ":libs:codes:kikcode", @@ -327,6 +328,7 @@ val kmpUnitTestModules = setOf( // (macOS .dylib / Linux .so); it can't load on the Linux CI runner. Its ed25519.json // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", + ":libs:analytics-events", ":libs:reporting", ) // ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their From c6ddfbe107f28f51bece207c72224be812268dab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:47:00 -0400 Subject: [PATCH 02/37] feat(analytics-events): add the value vocabulary --- .../kotlin/com/flipcash/analytics/Amount.kt | 29 +++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 48 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt new file mode 100644 index 0000000000..a0d9b65e46 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Amount.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +/** + * Android's amount properties, as sent today. + * + * DRIFT: iOS sends Fiat, Currency, Exchange Rate, Quarks and Mint, never USDC. Android sends + * USDC always, and Exchange Rate and Mint only for a `LocalFiat`. Part 2 settles one block + * for every amount-bearing event; until then, only Android builds this. + * + * [quarks] crosses as a `Double`, which is exact below 2^53. + */ +data class Amount( + val fiat: Double, + val currency: String, + val usdc: Double, + val quarks: Long, + val exchangeRate: Double? = null, + val mint: String? = null, +) + +internal fun PropertiesBuilder.amount(amount: Amount?) { + amount ?: return + number("Fiat", amount.fiat) + text("Currency", amount.currency) + number("USDC", amount.usdc) + number("Quarks", amount.quarks) + number("Exchange Rate", amount.exchangeRate) + text("Mint", amount.mint) +} diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt new file mode 100644 index 0000000000..9c1a354fea --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -0,0 +1,48 @@ +package com.flipcash.analytics + +/** The `Chat Type` property. */ +enum class ChatType(val value: String) { CONTACT("Contact"), TIP("Tip"), GROUP("Group"), UNKNOWN("Unknown") } + +/** The `State` property on an event that reports an outcome. */ +enum class State(val value: String) { SUCCESS("Success"), FAILURE("Failure") } + +enum class AddMoneySource(val value: String) { + MENU("Menu"), GIVE_SHORTFALL("Give Shortfall"), BUY_SHORTFALL("Buy Shortfall"), + USERNAME_SHORTFALL("Username Shortfall"), CHAT("Chat"), SCANNER("Scanner"), BALANCE("Balance"), +} + +enum class AddMoneyMethod(val value: String) { + COINBASE("Coinbase"), PHANTOM("Phantom"), OTHER_WALLET("Other Wallet"), + // DRIFT: iOS has no Reserves method. + RESERVES("Reserves"), +} + +enum class DisplayNameSource(val value: String) { + ONBOARDING("Onboarding"), MY_ACCOUNT("My Account"), TIP_CARD_SETUP("Tip Card Setup"), +} + +/** + * Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android + * previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2. + */ +enum class TokenInfoSource(val value: String) { + DEEPLINK("Deeplink"), WALLET("Wallet"), DISCOVERY("Discovery"), CHAT("Chat"), CHAT_GATE("Chat Gate"), +} + +enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Phantom"), COINBASE("Coinbase") } + +enum class WalletProvider(val value: String) { PHANTOM("Phantom") } + +enum class CashLinkChoice(val value: String) { COPIED("Copied to clipboard"), SHARED("Shared to app") } + +enum class OnrampStep(val value: String) { + SHOW_INFO("Show Verification Info"), ENTER_PHONE("Show Enter Phone"), CONFIRM_PHONE("Show Confirm Phone"), + ENTER_EMAIL("Show Enter Email"), CONFIRM_EMAIL("Show Confirm Email"), +} + +enum class Button(val value: String) { + CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), + ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), + BUY_WITH_RESERVES("Buy With Reserves"), BUY_WITH_PHANTOM("Buy With Phantom"), BUY_WITH_COINBASE("Buy With Coinbase"), + BUY_WITH_OTHER_WALLET("Buy With Other Wallet"), SHARE_TOKEN_INFO("Share Token Info"), +} From 4ab3ed14e5261cc7220cfd6b741c3b33f8230f81 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:48:10 -0400 Subject: [PATCH 03/37] feat(analytics-events): chat events --- .../flipcash/analytics/events/ChatEvents.kt | 23 ++++++ .../com/flipcash/analytics/ChatEventsTest.kt | 75 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt new file mode 100644 index 0000000000..822d89d7a2 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt @@ -0,0 +1,23 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.Amount +import com.flipcash.analytics.ChatType +import com.flipcash.analytics.amount +import com.flipcash.analytics.event + +object ChatEvents { + fun sentMessage(chatType: ChatType, error: String?) = event("Sent Message") { + text("Chat Type", chatType.value) + text("Error", error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun tipReceived(chatType: ChatType, amount: Amount) = event("Tip Received") { + text("Chat Type", chatType.value) + amount(amount) + } + + fun messageReceived(chatType: ChatType) = event("Message Received") { + text("Chat Type", chatType.value) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt new file mode 100644 index 0000000000..91c1acd9b9 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ChatEventsTest.kt @@ -0,0 +1,75 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ChatEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChatEventsTest { + + @Test + fun sentMessageCarriesTheChatType() { + assertEquals( + AnalyticsEvent("Sent Message", mapOf("Chat Type" to PropertyValue.Text("Group"))), + ChatEvents.sentMessage(ChatType.GROUP, null), + ) + } + + @Test + fun sentMessageCarriesTheError() { + assertEquals( + AnalyticsEvent( + "Sent Message", + mapOf("Chat Type" to PropertyValue.Text("Contact"), "Error" to PropertyValue.Text("boom")), + ), + ChatEvents.sentMessage(ChatType.CONTACT, "boom"), + ) + } + + @Test + fun chatTypesKeepTheirWireStrings() { + assertEquals( + listOf("Contact", "Tip", "Group", "Unknown"), + listOf(ChatType.CONTACT, ChatType.TIP, ChatType.GROUP, ChatType.UNKNOWN) + .map { (ChatEvents.messageReceived(it).properties.getValue("Chat Type") as PropertyValue.Text).value }, + ) + } + + @Test + fun tipReceivedCarriesTheAmountBlock() { + assertEquals( + AnalyticsEvent( + "Tip Received", + mapOf( + "Chat Type" to PropertyValue.Text("Tip"), + "Fiat" to PropertyValue.Number(1.5), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(1.5), + "Quarks" to PropertyValue.Number(25_000_000.0), + "Mint" to PropertyValue.Text("mint"), + ), + ), + ChatEvents.tipReceived( + ChatType.TIP, + Amount(fiat = 1.5, currency = "USD", usdc = 1.5, quarks = 25_000_000, mint = "mint"), + ), + ) + } + + @Test + fun amountBlockCarriesExchangeRateWhenGiven() { + val properties = ChatEvents.tipReceived( + ChatType.TIP, + Amount(fiat = 2.0, currency = "CAD", usdc = 1.4, quarks = 1, exchangeRate = 1.43, mint = null), + ).properties + assertEquals(PropertyValue.Number(1.43), properties["Exchange Rate"]) + assertEquals(null, properties["Mint"]) + } + + @Test + fun messageReceivedCarriesOnlyTheChatType() { + assertEquals( + AnalyticsEvent("Message Received", mapOf("Chat Type" to PropertyValue.Text("Unknown"))), + ChatEvents.messageReceived(ChatType.UNKNOWN), + ) + } +} From 3e837eb647167e98d479ddb34a144b5891931528 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:49:29 -0400 Subject: [PATCH 04/37] feat(analytics-events): transfer events --- .../analytics/events/TransferEvents.kt | 66 ++++++++++++++ .../flipcash/analytics/TransferEventsTest.kt | 86 +++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt new file mode 100644 index 0000000000..1b3c30c857 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt @@ -0,0 +1,66 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.Amount +import com.flipcash.analytics.CashLinkChoice +import com.flipcash.analytics.PropertiesBuilder +import com.flipcash.analytics.State +import com.flipcash.analytics.amount +import com.flipcash.analytics.event + +object TransferEvents { + fun grabBillStart() = event("Grab Bill Start") + + fun giveBillStart() = event("Give Bill Start") + + // DRIFT: iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat + // and Currency of the amount (see Amount). + fun grabBill(state: State, amount: Amount?, grabTimeMillis: Long?, error: String?) = event("Grab Bill") { + number("Grab Time", grabTimeMillis) + outcome(state, amount, error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun giveBill(state: State, amount: Amount?, error: String?) = event("Give Bill") { + outcome(state, amount, error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun withdrawal(state: State, amount: Amount?, error: String?) = event("Withdrawal") { + outcome(state, amount, error) + } + + // DRIFT: iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see + // Amount). Android never sends Failure. + fun sendCashLink( + state: State, + amount: Amount?, + choice: CashLinkChoice?, + app: String?, + error: String?, + ) = event("Send Cash Link") { + text("Cash Link Choice", choice?.value) + text("App", app) + outcome(state, amount, error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun receiveCashLink(state: State, amount: Amount?, error: String?) = event("Receive Cash Link") { + outcome(state, amount, error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun sentCash(state: State, amount: Amount?, error: String?) = event("Sent Cash") { + outcome(state, amount, error) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun sentTip(state: State, amount: Amount?, error: String?) = event("Sent Tip") { + outcome(state, amount, error) + } + + private fun PropertiesBuilder.outcome(state: State, amount: Amount?, error: String?) { + text("State", state.value) + amount(amount) + text("Error", error) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt new file mode 100644 index 0000000000..508d4eb286 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TransferEventsTest.kt @@ -0,0 +1,86 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.TransferEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class TransferEventsTest { + + private val amount = Amount(fiat = 5.0, currency = "USD", usdc = 5.0, quarks = 500, exchangeRate = 1.0, mint = "mint") + private val amountProperties = mapOf( + "Fiat" to PropertyValue.Number(5.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(5.0), + "Quarks" to PropertyValue.Number(500.0), + "Exchange Rate" to PropertyValue.Number(1.0), + "Mint" to PropertyValue.Text("mint"), + ) + + @Test + fun startsCarryNoProperties() { + assertEquals(AnalyticsEvent("Grab Bill Start", emptyMap()), TransferEvents.grabBillStart()) + assertEquals(AnalyticsEvent("Give Bill Start", emptyMap()), TransferEvents.giveBillStart()) + } + + @Test + fun grabBillCarriesGrabTimeAsANumber() { + assertEquals( + AnalyticsEvent( + "Grab Bill", + mapOf("Grab Time" to PropertyValue.Number(1200.0), "State" to PropertyValue.Text("Success")) + amountProperties, + ), + TransferEvents.grabBill(State.SUCCESS, amount, 1200, null), + ) + } + + @Test + fun grabBillFailureOmitsWhatItDoesNotHave() { + assertEquals( + AnalyticsEvent( + "Grab Bill", + mapOf("State" to PropertyValue.Text("Failure"), "Error" to PropertyValue.Text("timed out")), + ), + TransferEvents.grabBill(State.FAILURE, null, null, "timed out"), + ) + } + + @Test + fun outcomeEventsCarryStateAmountAndError() { + val expected = mapOf("State" to PropertyValue.Text("Failure")) + amountProperties + + ("Error" to PropertyValue.Text("e")) + assertEquals(AnalyticsEvent("Give Bill", expected), TransferEvents.giveBill(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Withdrawal", expected), TransferEvents.withdrawal(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Receive Cash Link", expected), TransferEvents.receiveCashLink(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Sent Cash", expected), TransferEvents.sentCash(State.FAILURE, amount, "e")) + assertEquals(AnalyticsEvent("Sent Tip", expected), TransferEvents.sentTip(State.FAILURE, amount, "e")) + } + + @Test + fun sendCashLinkToClipboard() { + assertEquals( + AnalyticsEvent( + "Send Cash Link", + mapOf( + "Cash Link Choice" to PropertyValue.Text("Copied to clipboard"), + "State" to PropertyValue.Text("Success"), + ) + amountProperties, + ), + TransferEvents.sendCashLink(State.SUCCESS, amount, CashLinkChoice.COPIED, null, null), + ) + } + + @Test + fun sendCashLinkToAnApp() { + assertEquals( + AnalyticsEvent( + "Send Cash Link", + mapOf( + "Cash Link Choice" to PropertyValue.Text("Shared to app"), + "App" to PropertyValue.Text("com.whatsapp"), + "State" to PropertyValue.Text("Success"), + ), + ), + TransferEvents.sendCashLink(State.SUCCESS, null, CashLinkChoice.SHARED, "com.whatsapp", null), + ) + } +} From 9e84240a1ab83a904d442c6fd944a057a9a19596 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:51:08 -0400 Subject: [PATCH 05/37] feat(analytics-events): add money events --- .../analytics/events/AddMoneyEvents.kt | 45 ++++++++++ .../flipcash/analytics/AddMoneyEventsTest.kt | 84 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt new file mode 100644 index 0000000000..0a85c9861a --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt @@ -0,0 +1,45 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.Amount +import com.flipcash.analytics.State +import com.flipcash.analytics.amount +import com.flipcash.analytics.event + +object AddMoneyEvents { + fun opened(source: AddMoneySource) = event("Add Money: Opened") { + text("Source", source.value) + } + + // DRIFT: iOS has no Reserves method. + fun methodSelected(method: AddMoneyMethod) = event("Add Money: Method Selected") { + text("Method", method.value) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun amountConfirmed(method: AddMoneyMethod, amount: Amount) = event("Add Money: Amount Confirmed") { + text("Method", method.value) + amount(amount) + } + + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the + // native amount here. + fun paymentInvoked(method: AddMoneyMethod, amount: Amount) = event("Add Money: Payment Invoked") { + text("Method", method.value) + amount(amount) + } + + fun addressCopied(mint: String) = event("Add Money: Address Copied") { + text("Mint", mint) + } + + /** The outcome of an add-money flow, sent as the event named `Add Money`. */ + // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). + fun result(method: AddMoneyMethod, state: State, amount: Amount?, error: String?) = event("Add Money") { + text("Method", method.value) + text("State", state.value) + amount(amount) + text("Error", error) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt new file mode 100644 index 0000000000..b4f7bae783 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AddMoneyEventsTest.kt @@ -0,0 +1,84 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.AddMoneyEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class AddMoneyEventsTest { + + private val amount = Amount(fiat = 20.0, currency = "USD", usdc = 20.0, quarks = 2_000) + private val amountProperties = mapOf( + "Fiat" to PropertyValue.Number(20.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(20.0), + "Quarks" to PropertyValue.Number(2_000.0), + ) + + @Test + fun openedCarriesEverySourceByItsWireString() { + assertEquals( + listOf("Menu", "Give Shortfall", "Buy Shortfall", "Username Shortfall", "Chat", "Scanner", "Balance"), + AddMoneySource.entries.map { + val event = AddMoneyEvents.opened(it) + assertEquals("Add Money: Opened", event.name) + (event.properties.getValue("Source") as PropertyValue.Text).value + }, + ) + } + + @Test + fun methodSelectedCarriesEveryMethodByItsWireString() { + assertEquals( + listOf("Coinbase", "Phantom", "Other Wallet", "Reserves"), + AddMoneyMethod.entries.map { + val event = AddMoneyEvents.methodSelected(it) + assertEquals(AnalyticsEvent("Add Money: Method Selected", mapOf("Method" to event.properties.getValue("Method"))), event) + (event.properties.getValue("Method") as PropertyValue.Text).value + }, + ) + } + + @Test + fun amountConfirmedAndPaymentInvokedCarryMethodAndAmount() { + val expected = mapOf("Method" to PropertyValue.Text("Phantom")) + amountProperties + assertEquals( + AnalyticsEvent("Add Money: Amount Confirmed", expected), + AddMoneyEvents.amountConfirmed(AddMoneyMethod.PHANTOM, amount), + ) + assertEquals( + AnalyticsEvent("Add Money: Payment Invoked", expected), + AddMoneyEvents.paymentInvoked(AddMoneyMethod.PHANTOM, amount), + ) + } + + @Test + fun addressCopiedCarriesTheMint() { + assertEquals( + AnalyticsEvent("Add Money: Address Copied", mapOf("Mint" to PropertyValue.Text("mint"))), + AddMoneyEvents.addressCopied("mint"), + ) + } + + @Test + fun resultIsTheEventNamedAddMoney() { + assertEquals( + AnalyticsEvent( + "Add Money", + mapOf("Method" to PropertyValue.Text("Coinbase"), "State" to PropertyValue.Text("Success")) + + amountProperties, + ), + AddMoneyEvents.result(AddMoneyMethod.COINBASE, State.SUCCESS, amount, null), + ) + assertEquals( + AnalyticsEvent( + "Add Money", + mapOf( + "Method" to PropertyValue.Text("Other Wallet"), + "State" to PropertyValue.Text("Failure"), + "Error" to PropertyValue.Text("declined"), + ), + ), + AddMoneyEvents.result(AddMoneyMethod.OTHER_WALLET, State.FAILURE, null, "declined"), + ) + } +} From 38e626072703b3a628e4f94a5288ed03af664228 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:52:28 -0400 Subject: [PATCH 06/37] feat(analytics-events): onramp events --- .../flipcash/analytics/events/OnrampEvents.kt | 9 ++++++ .../flipcash/analytics/OnrampEventsTest.kt | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt new file mode 100644 index 0000000000..4aa1acd1d6 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt @@ -0,0 +1,9 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.event + +object OnrampEvents { + // Show Verification Info is Android only; part 2 decides whether iOS sends it. + fun step(step: OnrampStep) = event("Onramp: ${step.value}") +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt new file mode 100644 index 0000000000..b3b4f818f0 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/OnrampEventsTest.kt @@ -0,0 +1,28 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.OnrampEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class OnrampEventsTest { + + @Test + fun eachStepIsItsOwnPropertylessEvent() { + assertEquals( + listOf( + AnalyticsEvent("Onramp: Show Verification Info", emptyMap()), + AnalyticsEvent("Onramp: Show Enter Phone", emptyMap()), + AnalyticsEvent("Onramp: Show Confirm Phone", emptyMap()), + AnalyticsEvent("Onramp: Show Enter Email", emptyMap()), + AnalyticsEvent("Onramp: Show Confirm Email", emptyMap()), + ), + listOf( + OnrampStep.SHOW_INFO, + OnrampStep.ENTER_PHONE, + OnrampStep.CONFIRM_PHONE, + OnrampStep.ENTER_EMAIL, + OnrampStep.CONFIRM_EMAIL, + ).map(OnrampEvents::step), + ) + } +} From df7409f18c6e1d94e6a2e4d3733610179e462b31 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:53:39 -0400 Subject: [PATCH 07/37] feat(analytics-events): wallet events --- .../flipcash/analytics/events/WalletEvents.kt | 32 +++++++++++++++ .../flipcash/analytics/WalletEventsTest.kt | 39 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt new file mode 100644 index 0000000000..61e08ce9cc --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt @@ -0,0 +1,32 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.Amount +import com.flipcash.analytics.WalletProvider +import com.flipcash.analytics.amount +import com.flipcash.analytics.event + +/** The Phantom deeplink flow. */ +object WalletEvents { + // DRIFT: iOS sends no Provider, and fires on the request rather than after connecting. + fun connect(provider: WalletProvider) = event("Wallet: Connect") { + text("Provider", provider.value) + } + + // DRIFT: iOS sends no Provider, and sends only Fiat (the native amount) and Currency where + // Android sends the token amount's block (see Amount). + fun requestAmount(provider: WalletProvider, amount: Amount) = event("Wallet: Request Amount") { + text("Provider", provider.value) + amount(amount) + } + + // DRIFT: iOS declares this with no Provider and never sends it. + fun transactionsFailed(provider: WalletProvider) = event("Wallet: Transactions Failed") { + text("Provider", provider.value) + } + + // DRIFT: iOS sends no Provider, and fires on any error code where Android fires only on a + // user reject. + fun cancel(provider: WalletProvider) = event("Wallet: Cancel") { + text("Provider", provider.value) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt new file mode 100644 index 0000000000..bcc3133d9f --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/WalletEventsTest.kt @@ -0,0 +1,39 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.WalletEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class WalletEventsTest { + + private val phantom = mapOf("Provider" to PropertyValue.Text("Phantom")) + + @Test + fun providerOnlyEvents() { + assertEquals(AnalyticsEvent("Wallet: Connect", phantom), WalletEvents.connect(WalletProvider.PHANTOM)) + assertEquals( + AnalyticsEvent("Wallet: Transactions Failed", phantom), + WalletEvents.transactionsFailed(WalletProvider.PHANTOM), + ) + assertEquals(AnalyticsEvent("Wallet: Cancel", phantom), WalletEvents.cancel(WalletProvider.PHANTOM)) + } + + @Test + fun requestAmountCarriesProviderAndAmount() { + assertEquals( + AnalyticsEvent( + "Wallet: Request Amount", + phantom + mapOf( + "Fiat" to PropertyValue.Number(10.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(10.0), + "Quarks" to PropertyValue.Number(1_000_000.0), + ), + ), + WalletEvents.requestAmount( + WalletProvider.PHANTOM, + Amount(fiat = 10.0, currency = "USD", usdc = 10.0, quarks = 1_000_000), + ), + ) + } +} From 63d0f189220f2431733616c5c7ada220a292fcb1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:55:32 -0400 Subject: [PATCH 08/37] feat(analytics-events): token info events --- .../analytics/events/TokenInfoEvents.kt | 10 +++++++ .../flipcash/analytics/TokenInfoEventsTest.kt | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt new file mode 100644 index 0000000000..513234fdb0 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt @@ -0,0 +1,10 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.TokenInfoSource +import com.flipcash.analytics.event + +object TokenInfoEvents { + fun opened(source: TokenInfoSource, mint: String) = event("Token Info: Opened From ${source.value}") { + text("Mint", mint) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt new file mode 100644 index 0000000000..9ea7a76b07 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/TokenInfoEventsTest.kt @@ -0,0 +1,30 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.TokenInfoEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class TokenInfoEventsTest { + + private val mint = mapOf("Mint" to PropertyValue.Text("mint")) + + @Test + fun eachSourceIsItsOwnEvent() { + assertEquals( + listOf( + AnalyticsEvent("Token Info: Opened From Deeplink", mint), + AnalyticsEvent("Token Info: Opened From Wallet", mint), + AnalyticsEvent("Token Info: Opened From Discovery", mint), + AnalyticsEvent("Token Info: Opened From Chat", mint), + AnalyticsEvent("Token Info: Opened From Chat Gate", mint), + ), + listOf( + TokenInfoSource.DEEPLINK, + TokenInfoSource.WALLET, + TokenInfoSource.DISCOVERY, + TokenInfoSource.CHAT, + TokenInfoSource.CHAT_GATE, + ).map { TokenInfoEvents.opened(it, "mint") }, + ) + } +} From 0f3f83c647aff405a27faece80c4d55c556f2b34 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:56:37 -0400 Subject: [PATCH 09/37] feat(analytics-events): buy and sell events --- .../flipcash/analytics/events/SwapEvents.kt | 28 +++++++++++ .../com/flipcash/analytics/SwapEventsTest.kt | 49 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt new file mode 100644 index 0000000000..f0759238ec --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt @@ -0,0 +1,28 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.Amount +import com.flipcash.analytics.PurchaseMethod +import com.flipcash.analytics.amount +import com.flipcash.analytics.event + +/** Buying and selling a token. */ +object SwapEvents { + // DRIFT (Reserves): iOS also sends State, Payment Mint and Payment Token Symbol, and sends + // only Fiat and Currency of the amount, with no USDC or Quarks. + // Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. + fun purchase(method: PurchaseMethod, mint: String, amount: Amount, error: String?) = + event("Token Purchase With ${method.value}") { + text("Mint", mint) + amount(amount) + text("Error", error) + } + + // DRIFT: iOS also sends State, and sends only Fiat and Currency of the amount, with no + // USDC, Quarks or Fee. + fun sell(mint: String, amount: Amount, fee: Double, error: String?) = event("Token Sell") { + text("Mint", mint) + amount(amount) + number("Fee", fee) + text("Error", error) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt new file mode 100644 index 0000000000..6d56dec442 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/SwapEventsTest.kt @@ -0,0 +1,49 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.SwapEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class SwapEventsTest { + + private val amount = Amount(fiat = 3.0, currency = "USD", usdc = 3.0, quarks = 300) + private val properties = mapOf( + "Mint" to PropertyValue.Text("mint"), + "Fiat" to PropertyValue.Number(3.0), + "Currency" to PropertyValue.Text("USD"), + "USDC" to PropertyValue.Number(3.0), + "Quarks" to PropertyValue.Number(300.0), + ) + + @Test + fun purchaseIsNamedForItsMethod() { + assertEquals( + listOf( + AnalyticsEvent("Token Purchase With Reserves", properties), + AnalyticsEvent("Token Purchase With Phantom", properties), + AnalyticsEvent("Token Purchase With Coinbase", properties), + ), + listOf(PurchaseMethod.RESERVES, PurchaseMethod.PHANTOM, PurchaseMethod.COINBASE) + .map { SwapEvents.purchase(it, "mint", amount, null) }, + ) + } + + @Test + fun purchaseCarriesTheError() { + assertEquals( + AnalyticsEvent("Token Purchase With Coinbase", properties + ("Error" to PropertyValue.Text("e"))), + SwapEvents.purchase(PurchaseMethod.COINBASE, "mint", amount, "e"), + ) + } + + @Test + fun sellCarriesTheFeeAsANumber() { + assertEquals( + AnalyticsEvent( + "Token Sell", + properties + mapOf("Fee" to PropertyValue.Number(0.03), "Error" to PropertyValue.Text("e")), + ), + SwapEvents.sell("mint", amount, 0.03, "e"), + ) + } +} From bdc108a23f6b3179c789c57038fb02a166f639b9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:57:43 -0400 Subject: [PATCH 10/37] feat(analytics-events): scan events --- .../flipcash/analytics/events/ScanEvents.kt | 29 +++++++++++++ .../com/flipcash/analytics/ScanEventsTest.kt | 41 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt new file mode 100644 index 0000000000..0c6ace63a2 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.event + +/** The gallery scan path, and the tip card. */ +object ScanEvents { + fun galleryImagePicked() = event("Gallery Scan: Image Picked") + + /** @param tier which rung of the crop ladder decoded, 1 to 3. */ + // DRIFT: iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as + // {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms. + fun gallerySucceeded(tier: Int, zoom: Double, timeMillis: Long) = event("Gallery Scan: Succeeded") { + number("Tier", tier.toDouble()) + number("Zoom", zoom) + number("Time", timeMillis) + } + + /** @param exhausted true when the budget ran out rather than the ladder ending. */ + // DRIFT: iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, + // Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted. + fun galleryFailed(timeMillis: Long, exhausted: Boolean) = event("Gallery Scan: Failed") { + number("Time", timeMillis) + flag("Exhausted", exhausted) + } + + fun tipCardScanned() = event("Tip Card Scanned") + + fun tipCardPresented() = event("Tip Card Presented") +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt new file mode 100644 index 0000000000..4305964efe --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ScanEventsTest.kt @@ -0,0 +1,41 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ScanEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ScanEventsTest { + + @Test + fun propertylessEvents() { + assertEquals(AnalyticsEvent("Gallery Scan: Image Picked", emptyMap()), ScanEvents.galleryImagePicked()) + assertEquals(AnalyticsEvent("Tip Card Scanned", emptyMap()), ScanEvents.tipCardScanned()) + assertEquals(AnalyticsEvent("Tip Card Presented", emptyMap()), ScanEvents.tipCardPresented()) + } + + @Test + fun gallerySucceededCarriesNumbers() { + assertEquals( + AnalyticsEvent( + "Gallery Scan: Succeeded", + mapOf( + "Tier" to PropertyValue.Number(2.0), + "Zoom" to PropertyValue.Number(1.5), + "Time" to PropertyValue.Number(840.0), + ), + ), + ScanEvents.gallerySucceeded(2, 1.5, 840), + ) + } + + @Test + fun galleryFailedCarriesExhaustedAsAFlag() { + assertEquals( + AnalyticsEvent( + "Gallery Scan: Failed", + mapOf("Time" to PropertyValue.Number(1500.0), "Exhausted" to PropertyValue.Flag(true)), + ), + ScanEvents.galleryFailed(1500, true), + ) + } +} From 28f65a5dd85e91f6d5f97810f918bf1668b432e4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:01:22 -0400 Subject: [PATCH 11/37] feat(analytics-events): deeplink events --- .../analytics/events/DeeplinkEvents.kt | 31 ++++++++++++ .../flipcash/analytics/DeeplinkEventsTest.kt | 50 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt new file mode 100644 index 0000000000..54ed037591 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt @@ -0,0 +1,31 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.event + +/** + * [url] arrives with its query and fragment already stripped. `type` is a string because the + * two apps' deeplink vocabularies differ. + */ +object DeeplinkEvents { + fun open(url: String) = event("Deeplink: Open") { + text("URL", url) + } + + // DRIFT: iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, + // ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, "Sheet:"}, and iOS sends this + // for every link where Android sends it only on the gallery QR path. + fun parsed(type: String) = event("Deeplink: Parse") { + text("Type", type) + } + + fun parseFailed(url: String) = event("Deeplink: Parse") { + text("Error", "Failed to parse deeplink => $url") + } + + // DRIFT: iOS never passes Error, and sends this for every kind where Android sends it only + // for a cash link. + fun routed(type: String, error: String?) = event("Deeplink: Routed") { + text("Type", type) + text("Error", error) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt new file mode 100644 index 0000000000..2c49783fe7 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DeeplinkEventsTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.DeeplinkEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeeplinkEventsTest { + + @Test + fun openCarriesTheUrl() { + assertEquals( + AnalyticsEvent("Deeplink: Open", mapOf("URL" to PropertyValue.Text("https://app.flipcash.com/c"))), + DeeplinkEvents.open("https://app.flipcash.com/c"), + ) + } + + @Test + fun parsedCarriesTheType() { + assertEquals( + AnalyticsEvent("Deeplink: Parse", mapOf("Type" to PropertyValue.Text("CashLink"))), + DeeplinkEvents.parsed("CashLink"), + ) + } + + @Test + fun parseFailedCarriesTheUrlInTheError() { + assertEquals( + AnalyticsEvent( + "Deeplink: Parse", + mapOf("Error" to PropertyValue.Text("Failed to parse deeplink => https://example.com/x")), + ), + DeeplinkEvents.parseFailed("https://example.com/x"), + ) + } + + @Test + fun routedCarriesTypeAndError() { + assertEquals( + AnalyticsEvent("Deeplink: Routed", mapOf("Type" to PropertyValue.Text("CashLink"))), + DeeplinkEvents.routed("CashLink", null), + ) + assertEquals( + AnalyticsEvent( + "Deeplink: Routed", + mapOf("Type" to PropertyValue.Text("CashLink"), "Error" to PropertyValue.Text("e")), + ), + DeeplinkEvents.routed("CashLink", "e"), + ) + } +} From c89e44131dd627debf36511e9c6ff8089a4a1fe0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:02:04 -0400 Subject: [PATCH 12/37] feat(analytics-events): display name events --- .../analytics/events/DisplayNameEvents.kt | 16 ++++++++++ .../analytics/DisplayNameEventsTest.kt | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt new file mode 100644 index 0000000000..b5d365ba7a --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt @@ -0,0 +1,16 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.DisplayNameSource +import com.flipcash.analytics.event + +object DisplayNameEvents { + /** The user had no display name before this submission. */ + fun set(source: DisplayNameSource) = event("Display Name Set") { + text("Source", source.value) + } + + /** The user replaced an existing display name. */ + fun updated(source: DisplayNameSource) = event("Display Name Updated") { + text("Source", source.value) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt new file mode 100644 index 0000000000..ff712ea3dc --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/DisplayNameEventsTest.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.DisplayNameEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class DisplayNameEventsTest { + + @Test + fun setCarriesEverySourceByItsWireString() { + assertEquals( + listOf( + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("Onboarding"))), + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("My Account"))), + AnalyticsEvent("Display Name Set", mapOf("Source" to PropertyValue.Text("Tip Card Setup"))), + ), + listOf(DisplayNameSource.ONBOARDING, DisplayNameSource.MY_ACCOUNT, DisplayNameSource.TIP_CARD_SETUP) + .map(DisplayNameEvents::set), + ) + } + + @Test + fun updatedCarriesTheSource() { + assertEquals( + AnalyticsEvent("Display Name Updated", mapOf("Source" to PropertyValue.Text("My Account"))), + DisplayNameEvents.updated(DisplayNameSource.MY_ACCOUNT), + ) + } +} From 4a85e224a2abc79d644ae911df836e44d0d5adb1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:02:35 -0400 Subject: [PATCH 13/37] feat(analytics-events): error modal events --- .../analytics/events/ErrorModalEvents.kt | 15 ++++++++ .../analytics/ErrorModalEventsTest.kt | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt new file mode 100644 index 0000000000..bc5f1599e1 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt @@ -0,0 +1,15 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.event + +object ErrorModalEvents { + // DRIFT: iOS's Screen is `presentedSheet.description` or "scan" where Android's is the + // back stack top's `screenName()`, and iOS never sends Call Site. + fun displayed(title: String, message: String, screen: String?, callSite: String?) = + event("Error Modal Displayed") { + text("Title", title) + text("Message", message) + text("Screen", screen) + text("Call Site", callSite) + } +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt new file mode 100644 index 0000000000..587169a72c --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ErrorModalEventsTest.kt @@ -0,0 +1,35 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ErrorModalEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ErrorModalEventsTest { + + @Test + fun displayedCarriesEverythingItIsGiven() { + assertEquals( + AnalyticsEvent( + "Error Modal Displayed", + mapOf( + "Title" to PropertyValue.Text("t"), + "Message" to PropertyValue.Text("m"), + "Screen" to PropertyValue.Text("s"), + "Call Site" to PropertyValue.Text("c"), + ), + ), + ErrorModalEvents.displayed("t", "m", "s", "c"), + ) + } + + @Test + fun displayedOmitsAbsentScreenAndCallSite() { + assertEquals( + AnalyticsEvent( + "Error Modal Displayed", + mapOf("Title" to PropertyValue.Text("t"), "Message" to PropertyValue.Text("m")), + ), + ErrorModalEvents.displayed("t", "m", null, null), + ) + } +} From af81e9715bd55b964d9ce862f4646bd97f5a5c3a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:03:02 -0400 Subject: [PATCH 14/37] feat(analytics-events): account events --- .../analytics/events/AccountEvents.kt | 21 ++++++++++ .../flipcash/analytics/AccountEventsTest.kt | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt new file mode 100644 index 0000000000..e57fb7e34c --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt @@ -0,0 +1,21 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.event + +object AccountEvents { + // Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at + // registration and is a different moment. + fun createAccountPayment(price: Double, currency: String, owner: String) = event("Create Account Payment") { + number("Fiat", price) + text("Currency", currency) + text("Owner Public Key", owner) + } + + fun enteredPhoneNumber() = event("Entered Phone Number") + + fun verifiedPhoneNumber() = event("Verified Phone Number") + + fun linkedPhoneNumber() = event("Linked Phone Number") + + fun completeOnboarding() = event("Complete Onboarding") +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt new file mode 100644 index 0000000000..1afc5a8151 --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/AccountEventsTest.kt @@ -0,0 +1,41 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.AccountEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class AccountEventsTest { + + @Test + fun createAccountPaymentCarriesPriceCurrencyAndOwner() { + assertEquals( + AnalyticsEvent( + "Create Account Payment", + mapOf( + "Fiat" to PropertyValue.Number(4.99), + "Currency" to PropertyValue.Text("USD"), + "Owner Public Key" to PropertyValue.Text("owner"), + ), + ), + AccountEvents.createAccountPayment(4.99, "USD", "owner"), + ) + } + + @Test + fun propertylessEvents() { + assertEquals( + listOf( + AnalyticsEvent("Entered Phone Number", emptyMap()), + AnalyticsEvent("Verified Phone Number", emptyMap()), + AnalyticsEvent("Linked Phone Number", emptyMap()), + AnalyticsEvent("Complete Onboarding", emptyMap()), + ), + listOf( + AccountEvents.enteredPhoneNumber(), + AccountEvents.verifiedPhoneNumber(), + AccountEvents.linkedPhoneNumber(), + AccountEvents.completeOnboarding(), + ), + ) + } +} From e3c195904f57c890d042321940522d2dcdc5bf62 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:04:42 -0400 Subject: [PATCH 15/37] feat(analytics-events): button events --- .../flipcash/analytics/events/ButtonEvents.kt | 8 +++++ .../flipcash/analytics/ButtonEventsTest.kt | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt create mode 100644 libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt new file mode 100644 index 0000000000..0c45cb6462 --- /dev/null +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt @@ -0,0 +1,8 @@ +package com.flipcash.analytics.events + +import com.flipcash.analytics.Button +import com.flipcash.analytics.event + +object ButtonEvents { + fun tapped(button: Button) = event("Button: ${button.value}") +} diff --git a/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt new file mode 100644 index 0000000000..10595c14ef --- /dev/null +++ b/libs/analytics-events/src/commonTest/kotlin/com/flipcash/analytics/ButtonEventsTest.kt @@ -0,0 +1,29 @@ +package com.flipcash.analytics + +import com.flipcash.analytics.events.ButtonEvents +import kotlin.test.Test +import kotlin.test.assertEquals + +class ButtonEventsTest { + + @Test + fun tappedNamesEveryButtonByItsWireString() { + assertEquals( + listOf( + "Button: Create Account", + "Button: Save Access Key", + "Button: Wrote Access Key", + "Button: Allow Push", + "Button: Skip Push", + "Button: Allow Contacts", + "Button: Skip Contacts", + "Button: Buy With Reserves", + "Button: Buy With Phantom", + "Button: Buy With Coinbase", + "Button: Buy With Other Wallet", + "Button: Share Token Info", + ).map { AnalyticsEvent(it, emptyMap()) }, + Button.entries.map(ButtonEvents::tapped), + ) + } +} From f581f7bdcbe46f76ea07848a9c3aef5ae069e323 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:09:53 -0400 Subject: [PATCH 16/37] build(shared-core): export the analytics events module --- kmp/shared-core/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index f115b5c75e..cb9fb0a06f 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { export(project(":libs:encryption:mnemonic")) export(project(":libs:currency-math:discrete-curve")) export(project(":libs:reporting")) + export(project(":libs:analytics-events")) } } @@ -61,6 +62,7 @@ kotlin { api(project(":libs:encryption:mnemonic")) api(project(":libs:currency-math:discrete-curve")) api(project(":libs:reporting")) + api(project(":libs:analytics-events")) } } } From 2242c430c7cbb40d09d451c18d8c896f23b75a2b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:12:12 -0400 Subject: [PATCH 17/37] feat(shared-core-kit): add the analytics events facade --- .../SharedCoreKit/AnalyticsEvents.swift | 61 +++++++++++++++++++ .../AnalyticsEventsTests.swift | 38 ++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift new file mode 100644 index 0000000000..f03090989e --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -0,0 +1,61 @@ +import SharedCore + +/// One event, built by the shared Kotlin. Named `TrackedEvent` rather than re-exporting +/// `AnalyticsEvent` because the iOS app already declares `protocol AnalyticsEvent`. +public typealias TrackedEvent = SharedCore.AnalyticsEvent + +// The value vocabulary. Deliberately typealiases and not Swift enums, as with `ReportReason`: +// the wire strings are written down once, in `Values.kt`. Kotlin's `Button`, `State` and +// `Amount` are renamed here so they don't read as SwiftUI's `Button` and `@State`, or as +// one of the app's money types. +public typealias ChatType = SharedCore.ChatType +public typealias EventState = SharedCore.State +public typealias AddMoneySource = SharedCore.AddMoneySource +public typealias AddMoneyMethod = SharedCore.AddMoneyMethod +public typealias DisplayNameSource = SharedCore.DisplayNameSource +public typealias TokenInfoSource = SharedCore.TokenInfoSource +public typealias PurchaseMethod = SharedCore.PurchaseMethod +public typealias WalletProvider = SharedCore.WalletProvider +public typealias CashLinkChoice = SharedCore.CashLinkChoice +public typealias OnrampStep = SharedCore.OnrampStep +public typealias AnalyticsButton = SharedCore.Button +public typealias AnalyticsAmount = SharedCore.Amount +public typealias PeopleCounter = SharedCore.PeopleCounter + +// The builders. Each is a Kotlin `object`, so calls go through `.shared`. +public typealias ChatEvents = SharedCore.ChatEvents +public typealias TransferEvents = SharedCore.TransferEvents +public typealias AddMoneyEvents = SharedCore.AddMoneyEvents +public typealias OnrampEvents = SharedCore.OnrampEvents +public typealias WalletEvents = SharedCore.WalletEvents +public typealias TokenInfoEvents = SharedCore.TokenInfoEvents +public typealias SwapEvents = SharedCore.SwapEvents +public typealias ScanEvents = SharedCore.ScanEvents +public typealias DeeplinkEvents = SharedCore.DeeplinkEvents +public typealias DisplayNameEvents = SharedCore.DisplayNameEvents +public typealias ErrorModalEvents = SharedCore.ErrorModalEvents +public typealias AccountEvents = SharedCore.AccountEvents +public typealias ButtonEvents = SharedCore.ButtonEvents + +/// A property value as the iOS sender needs it. Mixpanel is not a dependency of this +/// package, so the app maps these to `MixpanelType` itself. +public enum AnalyticsScalar: Equatable, Sendable { + case text(String) + case number(Double) + case flag(Bool) +} + +public extension TrackedEvent { + /// Kotlin's sealed `PropertyValue` bridges as a protocol with no exhaustiveness, so a + /// fourth case crashes here, in the facade's own tests, rather than sending nothing. + var scalarProperties: [String: AnalyticsScalar] { + properties.mapValues { value in + switch value { + case let v as PropertyValueText: .text(v.value) + case let v as PropertyValueNumber: .number(v.value) + case let v as PropertyValueFlag: .flag(v.value) + default: preconditionFailure("PropertyValue gained a case the facade does not map: \(value)") + } + } + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift new file mode 100644 index 0000000000..9aa0c9147c --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/AnalyticsEventsTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import SharedCoreKit + +@Suite struct AnalyticsEventsTests { + + @Test func flattensEachValueTypeToASwiftScalar() { + let event: TrackedEvent = ChatEvents.shared.sentMessage(chatType: .group, error: "e") + #expect(event.name == "Sent Message") + #expect(event.scalarProperties == ["Chat Type": .text("Group"), "Error": .text("e")]) + } + + @Test func carriesNumbersAndFlagsAsTheirOwnTypes() { + let event = ScanEvents.shared.galleryFailed(timeMillis: 1500, exhausted: true) + #expect(event.scalarProperties["Time"] == .number(1500)) + #expect(event.scalarProperties["Exhausted"] == .flag(true)) + } + + @Test func omitsAbsentOptionalProperties() { + #expect(ChatEvents.shared.sentMessage(chatType: .tip, error: nil).scalarProperties.keys.sorted() == ["Chat Type"]) + } + + @Test func buildsAnAmountBearingEventFromSwift() { + let amount = AnalyticsAmount(fiat: 5, currency: "USD", usdc: 5, quarks: 5_000_000, exchangeRate: nil, mint: nil) + let event = TransferEvents.shared.sentTip(state: EventState.success, amount: amount, error: nil) + #expect(event.scalarProperties["State"] == .text("Success")) + #expect(event.scalarProperties["Quarks"] == .number(5_000_000)) + } + + @Test func tapsAButtonUnderItsFacadeName() { + #expect(ButtonEvents.shared.tapped(button: AnalyticsButton.shareTokenInfo).name == "Button: Share Token Info") + } + + /// A value added in Kotlin compiles on both sides and would be silently missing from any + /// Swift switch that lists the cases; this is the check that it is not. + @Test func tokenInfoSourcesAreTheFiveTheSpecNames() { + #expect(TokenInfoSource.entries.map(\.value) == ["Deeplink", "Wallet", "Discovery", "Chat", "Chat Gate"]) + } +} From 1bb119752b34ca7a76f91eb99d2fd0118fae478e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:14:09 -0400 Subject: [PATCH 18/37] ci(shared-core): run the analytics events tests --- .github/workflows/shared-core-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 5db736050b..52578890ae 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -12,6 +12,7 @@ on: - 'libs/currency-math/**' - 'libs/encryption/**' - 'libs/reporting/**' + - 'libs/analytics-events/**' - 'gradle/libs.versions.toml' - '.github/workflows/shared-core-tests.yml' @@ -65,6 +66,7 @@ jobs: - name: Run the Kotlin tests on the iOS simulator target run: | ./gradlew \ + :libs:analytics-events:iosSimulatorArm64Test \ :libs:codes:kikcode:iosSimulatorArm64Test \ :libs:currency-math:discrete-curve:iosSimulatorArm64Test \ :libs:encryption:base58:iosSimulatorArm64Test \ From c1438a5e1388f7ca19d93d6a36d24149338a2959 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:33:43 -0400 Subject: [PATCH 19/37] feat(build-logic): generate analytics builders from a TOML catalogue Adds the `flipcash.analytics.catalogue` convention plugin. It reads a module's `events.toml` and generates the value enums, `PeopleCounter`, and one builder object per domain into `commonMain`, in the shapes `:libs:analytics-events` wrote by hand. The parser rejects unknown keys, types and groups, parameters nothing sends, templates naming a missing or optional parameter, and duplicate builders, cases and property keys. Each message names the entry at fault. --- build-logic/convention/build.gradle.kts | 8 + .../AnalyticsCatalogueConventionPlugin.kt | 43 ++ .../analytics/AnalyticsCatalogue.kt | 9 + .../getcode/buildlogic/analytics/Catalogue.kt | 64 +++ .../buildlogic/analytics/CatalogueParser.kt | 305 ++++++++++++ .../analytics/GenerateAnalyticsEvents.kt | 44 ++ .../buildlogic/analytics/KotlinEmitter.kt | 187 +++++++ .../analytics/AnalyticsCatalogueTest.kt | 463 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 9 files changed, 1126 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt create mode 100644 build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index b510504fb5..256dd56494 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -21,6 +21,10 @@ dependencies { compileOnly(libs.compose.compiler.gradle.plugin) compileOnly(libs.kotlin.serialization.gradle.plugin) compileOnly(libs.kover.gradle.plugin) + + implementation(libs.tomlj) + + testImplementation(kotlin("test-junit")) } gradlePlugin { @@ -49,5 +53,9 @@ gradlePlugin { id = "flipcash.kmp.test.fixtures" implementationClass = "KmpTestFixturesConventionPlugin" } + register("analyticsCatalogue") { + id = "flipcash.analytics.catalogue" + implementationClass = "AnalyticsCatalogueConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt new file mode 100644 index 0000000000..6f2decaa6c --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt @@ -0,0 +1,43 @@ +import com.getcode.buildlogic.analytics.GenerateAnalyticsEvents +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.register +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * Generates a KMP module's analytics builders and value enums from its `events.toml` into + * `commonMain`, so both apps call the same Kotlin and the catalogue is the only place an event + * is written down. See `com.getcode.buildlogic.analytics` for the format. + * + * Usage in a module's `build.gradle.kts`: + * ``` + * plugins { + * alias(libs.plugins.flipcash.analytics.catalogue) + * } + * ``` + * + * The generated directory is registered on `commonMain`, which carries the task dependency to + * every compilation, including the Kotlin/Native ones the XCFramework build links. Lint reads + * source directories straight off disk, so it is made to depend on the task explicitly, as in + * [KmpTestFixturesConventionPlugin]. + */ +class AnalyticsCatalogueConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val generateAnalyticsEvents = tasks.register("generateAnalyticsEvents") { + catalogue.set(layout.projectDirectory.file("events.toml")) + outputDirectory.set(layout.buildDirectory.dir("generated/analytics/commonMain/kotlin")) + } + + tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") } + .configureEach { dependsOn(generateAnalyticsEvents) } + + pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + extensions.configure { + sourceSets.named("commonMain") { kotlin.srcDir(generateAnalyticsEvents) } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt new file mode 100644 index 0000000000..6dda6971b7 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt @@ -0,0 +1,9 @@ +package com.getcode.buildlogic.analytics + +/** Reads `events.toml` and writes the builders and value enums it describes. */ +internal object AnalyticsCatalogue { + fun parse(text: String): Catalogue = CatalogueParser.parse(text) + + /** The generated sources, keyed by path relative to the source root. */ + fun emit(catalogue: Catalogue): Map = KotlinEmitter.emit(catalogue) +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt new file mode 100644 index 0000000000..8003d97aae --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt @@ -0,0 +1,64 @@ +package com.getcode.buildlogic.analytics + +/** The catalogue is malformed. The message names the entry at fault. */ +class CatalogueException(message: String) : IllegalArgumentException(message) + +/** `events.toml`, parsed and checked. Every reference in it resolves. */ +internal data class Catalogue( + val packageName: String, + val enums: List, + val counters: List, + val domains: List, +) + +/** A value enum: a Kotlin enum whose cases carry the string sent to Mixpanel. */ +internal data class EnumDef(val name: String, val doc: String?, val cases: List) + +internal data class EnumCase(val name: String, val value: String, val drift: String?) + +/** One generated `object`, e.g. `ChatEvents`. */ +internal data class Domain(val name: String, val doc: String?, val builders: List) + +internal data class Builder( + val name: String, + val event: Template, + val params: List, + val properties: List, + val doc: String?, + val drift: String?, +) + +internal data class Param(val name: String, val type: ParamType, val nullable: Boolean) { + val kotlinType: String get() = type.kotlinName + if (nullable) "?" else "" +} + +internal sealed class ParamType(val kotlinName: String) { + data object Text : ParamType("String") + data object Decimal : ParamType("Double") + data object Whole : ParamType("Long") + data object SmallWhole : ParamType("Int") + data object Flag : ParamType("Boolean") + + /** The hand-written `Amount`, sent only through the `amount` group. */ + data object Amount : ParamType("Amount") + data class Enum(val name: String) : ParamType(name) +} + +internal sealed interface Property { + /** A parameter sent as it is, typed by the parameter. */ + data class Value(val key: String, val param: Param) : Property + + /** A text value built from literal text and parameters. */ + data class Format(val key: String, val template: Template) : Property + + /** The hand-written `amount(...)` block. */ + data class AmountBlock(val param: Param) : Property +} + +/** Text with `{param}` slots, used for event names and fixed-format values. */ +internal data class Template(val parts: List) { + sealed interface Part { + data class Literal(val text: String) : Part + data class Slot(val param: Param) : Part + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt new file mode 100644 index 0000000000..d75f51b646 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt @@ -0,0 +1,305 @@ +package com.getcode.buildlogic.analytics + +import org.tomlj.Toml +import org.tomlj.TomlArray +import org.tomlj.TomlTable + +/** + * Reads `events.toml` into a [Catalogue], rejecting anything the generator would otherwise have + * to guess at: unknown keys, types and groups, parameters nothing reads, templates naming a + * parameter that isn't there, and duplicates. + * + * Keys that come from the data (enum, domain and group names) are always looked up as one-element + * paths, since tomlj reads a plain string key as a dotted path. + */ +internal object CatalogueParser { + + private const val FILE = "events.toml" + + /** The built-in group that calls the hand-written `amount(...)` helper. */ + private const val AMOUNT_GROUP = "amount" + + /** The keys `amount(...)` writes, so a duplicate of one is caught like any other. */ + private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks", "Exchange Rate", "Mint") + + private val PRIMITIVES = mapOf( + "String" to ParamType.Text, + "Double" to ParamType.Decimal, + "Long" to ParamType.Whole, + "Int" to ParamType.SmallWhole, + "Boolean" to ParamType.Flag, + "Amount" to ParamType.Amount, + ) + + /** Types the module already declares by hand, which an enum must not shadow. */ + private val RESERVED_TYPES = PRIMITIVES.keys + setOf("AnalyticsEvent", "PropertyValue", "PeopleCounter") + + private val KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", "true", + "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + private val TYPE_NAME = Regex("[A-Z][A-Za-z0-9]*") + private val CASE_NAME = Regex("[A-Z][A-Z0-9_]*") + private val MEMBER_NAME = Regex("[a-z][A-Za-z0-9]*") + private val PARAM = Regex("""\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)(\?)?\s*""") + + fun parse(text: String): Catalogue { + val root = Toml.parse(text) + if (root.hasErrors()) { + fail(FILE, root.errors().joinToString("; ") { "line ${it.position().line()}: ${it.message}" }) + } + root.requireOnly(FILE, "package", "counters", "enums", "groups", "domains") + + val packageName = root.string("package", FILE) ?: fail(FILE, "`package` is required") + + val enums = root.subtables("enums", FILE).map { (name, table) -> enumDef(name, table) } + enums.groupBy { it.name }.forEach { (name, defs) -> + if (defs.size > 1) fail("enums.$name", "declared more than once") + } + val types = PRIMITIVES + enums.associate { it.name to ParamType.Enum(it.name) } + + val counters = root.table("counters", FILE)?.let { table -> + table.keySet().map { name -> enumCase("PeopleCounter", name, table.get(listOf(name))) } + }.orEmpty() + + val groups = root.subtables("groups", FILE).toMap() + groups.forEach { (name, table) -> + val where = "groups.$name" + if (name == AMOUNT_GROUP) fail(where, "`$AMOUNT_GROUP` is built in and cannot be redefined") + table.requireOnly(where, "properties") + } + + val domains = root.subtables("domains", FILE).map { (name, table) -> domain(name, table, types, groups) } + + return Catalogue(packageName, enums, counters, domains) + } + + private fun enumDef(name: String, table: TomlTable): EnumDef { + val where = "enums.$name" + if (!TYPE_NAME.matches(name)) fail(where, "an enum name must be UpperCamelCase") + if (name in RESERVED_TYPES) fail(where, "`$name` is already a type in the module") + table.requireOnly(where, "doc", "cases") + val cases = table.table("cases", where) ?: fail(where, "`cases` is required") + if (cases.isEmpty) fail(where, "an enum needs at least one case") + return EnumDef( + name = name, + doc = table.string("doc", where), + cases = cases.keySet().map { case -> enumCase(name, case, cases.get(listOf(case))) }, + ) + } + + private fun enumCase(enum: String, name: String, raw: Any?): EnumCase { + val where = "$enum.$name" + if (!CASE_NAME.matches(name)) fail(where, "a case name must be UPPER_SNAKE_CASE") + val (value, drift) = when (raw) { + is String -> raw to null + is TomlTable -> { + raw.requireOnly(where, "value", "drift") + raw.string("value", where) to raw.string("drift", where) + } + else -> fail(where, "a case is a wire string or `{ value = \"...\", drift = \"...\" }`") + } + if (value.isNullOrEmpty()) fail(where, "a case needs a non-empty wire string") + return EnumCase(name, value, drift) + } + + private fun domain( + name: String, + table: TomlTable, + types: Map, + groups: Map, + ): Domain { + val where = "domains.$name" + if (!TYPE_NAME.matches(name)) fail(where, "a domain name must be UpperCamelCase") + table.requireOnly(where, "doc", "events") + val entries = table.tables("events", where) + val builders = entries.mapIndexed { index, entry -> builder(name, index, entry, types, groups) } + builders.groupBy { it.name }.forEach { (builder, found) -> + if (found.size > 1) fail("$name.$builder", "builder is declared more than once") + } + return Domain(name, table.string("doc", where), builders) + } + + private fun builder( + domain: String, + index: Int, + table: TomlTable, + types: Map, + groups: Map, + ): Builder { + val name = table.string("builder", "$domain event #${index + 1}") + ?: fail("$domain event #${index + 1}", "`builder` is required") + val where = "$domain.$name" + if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable function name") + table.requireOnly(where, "builder", "event", "params", "properties", "doc", "drift") + + val params = table.strings("params", where).map { param(it, where, types) } + params.groupBy { it.name }.forEach { (param, found) -> + if (found.size > 1) fail(where, "parameter `$param` is declared more than once") + } + val byName = params.associateBy { it.name } + val used = mutableSetOf() + + val eventName = table.string("event", where) ?: fail(where, "`event` is required") + val event = template(eventName, where, byName, used) + + val properties = mutableListOf() + table.tables("properties", where).forEach { entry -> + expand(entry, where, byName, groups, used, properties, groupStack = emptyList()) + } + val keys = properties.flatMap { property -> + when (property) { + is Property.Value -> listOf(property.key) + is Property.Format -> listOf(property.key) + is Property.AmountBlock -> AMOUNT_KEYS + } + } + keys.groupBy { it }.forEach { (key, found) -> + if (found.size > 1) fail(where, "property \"$key\" is sent more than once") + } + + params.firstOrNull { it.name !in used }?.let { fail(where, "parameter `${it.name}` is never sent") } + + return Builder( + name = name, + event = event, + params = params, + properties = properties, + doc = table.string("doc", where), + drift = table.string("drift", where), + ) + } + + private fun param(declaration: String, where: String, types: Map): Param { + val match = PARAM.matchEntire(declaration) + ?: fail(where, "parameter \"$declaration\" is not `name: Type` or `name: Type?`") + val (name, typeName, optional) = match.destructured + if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable parameter name") + val type = types[typeName] ?: fail(where, "parameter `$name` has unknown type `$typeName`") + return Param(name, type, nullable = optional.isNotEmpty()) + } + + private fun expand( + entry: TomlTable, + where: String, + params: Map, + groups: Map, + used: MutableSet, + into: MutableList, + groupStack: List, + ) { + val group = entry.string("group", where) + if (group != null) { + entry.requireOnly(where, "group") + when { + group == AMOUNT_GROUP -> { + val param = params[AMOUNT_GROUP] + ?: fail(where, "the `amount` group reads a parameter `amount: Amount`, which isn't declared") + if (param.type != ParamType.Amount) fail(where, "the `amount` group needs `amount` to be an Amount") + used += param.name + into += Property.AmountBlock(param) + } + group in groupStack -> fail(where, "group `$group` includes itself") + else -> { + val table = groups[group] ?: fail(where, "unknown group `$group`") + table.tables("properties", "groups.$group").forEach { nested -> + expand(nested, where, params, groups, used, into, groupStack + group) + } + } + } + return + } + + entry.requireOnly(where, "key", "value", "format") + val key = entry.string("key", where) ?: fail(where, "a property needs a `key`, or a `group`") + val value = entry.string("value", where) + val format = entry.string("format", where) + into += when { + value != null && format != null -> fail(where, "property \"$key\" has both `value` and `format`") + value != null -> { + val param = params[value] ?: fail(where, "property \"$key\" reads `$value`, which isn't a parameter") + if (param.type == ParamType.Amount) { + fail(where, "property \"$key\": an Amount is sent through the amount group, not as a property") + } + used += param.name + Property.Value(key, param) + } + format != null -> Property.Format(key, template(format, where, params, used)) + else -> fail(where, "property \"$key\" needs a `value` or a `format`") + } + } + + /** Splits `Token Purchase With {method}` into literal text and parameter slots. */ + private fun template(text: String, where: String, params: Map, used: MutableSet): Template { + val parts = mutableListOf() + var index = 0 + while (index < text.length) { + val open = text.indexOf('{', index) + val close = text.indexOf('}', index) + if (open < 0) { + if (close >= 0) fail(where, "\"$text\" has a `}` with no `{`") + parts += Template.Part.Literal(text.substring(index)) + break + } + if (close in index until open) fail(where, "\"$text\" has a `}` with no `{`") + val end = text.indexOf('}', open) + if (end < 0) fail(where, "\"$text\" has a `{` with no `}`") + if (open > index) parts += Template.Part.Literal(text.substring(index, open)) + val slot = text.substring(open + 1, end) + val param = params[slot] ?: fail(where, "\"$text\" names {$slot}, which isn't a parameter") + if (param.nullable) fail(where, "\"$text\" names {$slot}, which is optional and would print \"null\"") + if (param.type != ParamType.Text && param.type !is ParamType.Enum) { + fail(where, "\"$text\" names {$slot}; only String and enum parameters can appear in text") + } + used += param.name + parts += Template.Part.Slot(param) + index = end + 1 + } + return Template(parts) + } + + private fun TomlTable.requireOnly(where: String, vararg allowed: String) { + val unknown = keySet() - allowed.toSet() + if (unknown.isNotEmpty()) { + fail(where, "unknown key(s) ${unknown.joinToString { "`$it`" }}; expected ${allowed.joinToString { "`$it`" }}") + } + } + + private fun TomlTable.string(key: String, where: String): String? = when (val value = get(listOf(key))) { + null -> null + is String -> value + else -> fail(where, "`$key` must be a string") + } + + private fun TomlTable.table(key: String, where: String): TomlTable? = when (val value = get(listOf(key))) { + null -> null + is TomlTable -> value + else -> fail(where, "`$key` must be a table") + } + + private fun TomlTable.subtables(key: String, where: String): List> { + val parent = table(key, where) ?: return emptyList() + return parent.keySet().map { name -> + name to (parent.table(name, "$key.$name") ?: fail("$key.$name", "must be a table")) + } + } + + private fun TomlTable.array(key: String, where: String): TomlArray? = when (val value = get(listOf(key))) { + null -> null + is TomlArray -> value + else -> fail(where, "`$key` must be an array") + } + + private fun TomlTable.tables(key: String, where: String): List = + array(key, where)?.toList()?.map { it as? TomlTable ?: fail(where, "every entry in `$key` must be a table") } + .orEmpty() + + private fun TomlTable.strings(key: String, where: String): List = + array(key, where)?.toList()?.map { it as? String ?: fail(where, "every entry in `$key` must be a string") } + .orEmpty() + + private fun fail(where: String, message: String): Nothing = + throw CatalogueException(if (where == FILE) "$FILE: $message" else "$FILE: $where: $message") +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt new file mode 100644 index 0000000000..7466def9af --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt @@ -0,0 +1,44 @@ +package com.getcode.buildlogic.analytics + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the analytics builders and value enums from `events.toml`. + * + * The output directory is cleared first, so an entry removed from the catalogue removes its + * generated file rather than leaving a stale builder behind. + */ +@CacheableTask +abstract class GenerateAnalyticsEvents : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val catalogue: RegularFileProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val sources = try { + AnalyticsCatalogue.emit(AnalyticsCatalogue.parse(catalogue.get().asFile.readText())) + } catch (malformed: CatalogueException) { + throw GradleException(malformed.message.orEmpty(), malformed) + } + + val root = outputDirectory.get().asFile + root.deleteRecursively() + sources.forEach { (path, source) -> + root.resolve(path).apply { parentFile.mkdirs() }.writeText(source) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt new file mode 100644 index 0000000000..bff8f2814f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/KotlinEmitter.kt @@ -0,0 +1,187 @@ +package com.getcode.buildlogic.analytics + +/** + * Writes a [Catalogue] as Kotlin, in the shapes the module's builders were first written in by + * hand: one `enum class X(val value: String)` per value enum, `PeopleCounter`, and one + * `object XEvents` per domain whose builders call the hand-written `event(name) { ... }` DSL. + * + * Output is keyed by path relative to the source root. Enums go in the catalogue's package and + * builder objects in its `events` subpackage. + */ +internal object KotlinEmitter { + + private const val HEADER = "// Generated from events.toml -- do not edit." + private const val INDENT = " " + private const val LINE_LIMIT = 100 + + fun emit(catalogue: Catalogue): Map { + val base = catalogue.packageName + val eventsPackage = "$base.events" + val files = linkedMapOf() + + catalogue.enums.forEach { enum -> + files[path(base, enum.name)] = enumFile(base, enum.name, "value", enum.doc, enum.cases) + } + if (catalogue.counters.isNotEmpty()) { + files[path(base, "PeopleCounter")] = enumFile( + packageName = base, + name = "PeopleCounter", + property = "key", + doc = "Cumulative per-user counters, stored as Mixpanel people properties.", + cases = catalogue.counters, + ) + } + catalogue.domains.forEach { domain -> + files[path(eventsPackage, domain.name)] = domainFile(base, eventsPackage, domain) + } + return files + } + + private fun path(packageName: String, type: String) = packageName.replace('.', '/') + "/$type.kt" + + private fun enumFile(packageName: String, name: String, property: String, doc: String?, cases: List) = + buildString { + appendLine("package $packageName") + appendLine() + appendLine(HEADER) + appendLine() + append(kdoc(listOfNotNull(doc), indent = "")) + appendLine("enum class $name(val $property: String) {") + cases.forEachIndexed { index, case -> + val documented = case.drift != null + val afterDocumented = index > 0 && cases[index - 1].drift != null + if (index > 0 && (documented || afterDocumented)) appendLine() + append(kdoc(listOfNotNull(case.drift?.let { "DRIFT: $it" }), INDENT)) + appendLine("$INDENT${case.name}(${literal(case.value)}),") + } + appendLine("}") + } + + private fun domainFile(base: String, packageName: String, domain: Domain) = buildString { + val imports = sortedSetOf("$base.AnalyticsEvent", "$base.event") + domain.builders.forEach { builder -> + builder.params.forEach { param -> + when (val type = param.type) { + is ParamType.Enum -> imports += "$base.${type.name}" + ParamType.Amount -> imports += "$base.Amount" + else -> Unit + } + } + if (builder.properties.any { it is Property.AmountBlock }) imports += "$base.amount" + } + + appendLine("package $packageName") + appendLine() + imports.forEach { appendLine("import $it") } + appendLine() + appendLine(HEADER) + appendLine() + append(kdoc(listOfNotNull(domain.doc), indent = "")) + appendLine("object ${domain.name} {") + domain.builders.forEachIndexed { index, builder -> + if (index > 0) appendLine() + append(builder(builder)) + } + appendLine("}") + } + + private fun builder(builder: Builder) = buildString { + append(kdoc(listOfNotNull(builder.doc, builder.drift?.let { "DRIFT: $it" }), INDENT)) + val params = builder.params.joinToString { "${it.name}: ${it.kotlinType}" } + val call = "event(${template(builder.event)})" + if (builder.properties.isEmpty()) { + appendLine("${INDENT}fun ${builder.name}($params): AnalyticsEvent = $call") + return@buildString + } + appendLine("${INDENT}fun ${builder.name}($params): AnalyticsEvent =") + appendLine("$INDENT$INDENT$call {") + builder.properties.forEach { appendLine("$INDENT$INDENT$INDENT${property(it)}") } + appendLine("$INDENT$INDENT}") + } + + private fun property(property: Property): String = when (property) { + is Property.AmountBlock -> "amount(${property.param.name})" + is Property.Format -> "text(${literal(property.key)}, ${template(property.template)})" + is Property.Value -> { + val param = property.param + val access = if (param.nullable) "?." else "." + val key = literal(property.key) + when (param.type) { + ParamType.Text -> "text($key, ${param.name})" + is ParamType.Enum -> "text($key, ${param.name}${access}value)" + ParamType.Decimal, ParamType.Whole -> "number($key, ${param.name})" + ParamType.SmallWhole -> "number($key, ${param.name}${access}toDouble())" + ParamType.Flag -> "flag($key, ${param.name})" + ParamType.Amount -> error("the parser sends an Amount only through the amount group") + } + } + } + + private fun template(template: Template): String = template.parts.joinToString("", "\"", "\"") { part -> + when (part) { + is Template.Part.Literal -> escape(part.text) + is Template.Part.Slot -> { + val param = part.param + if (param.type is ParamType.Enum) "\${${param.name}.value}" else "\${${param.name}}" + } + } + } + + private fun literal(text: String) = "\"${escape(text)}\"" + + private fun escape(text: String) = buildString(text.length) { + text.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '$' -> append("\\$") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + } + + /** + * One paragraph that fits goes on one line; otherwise each paragraph is word-wrapped at + * [LINE_LIMIT] with a blank line between. A blank line inside a paragraph's text starts a + * new paragraph, so a catalogue entry can carry an `@param` line of its own. + */ + private fun kdoc(paragraphs: List, indent: String): String { + val blocks = paragraphs + .flatMap { it.split(Regex("""\n\s*\n""")) } + .map { it.trim().replace(Regex("""\s+"""), " ").replace("*/", "* /") } + .filter { it.isNotEmpty() } + if (blocks.isEmpty()) return "" + if (blocks.size == 1) { + val single = "$indent/** ${blocks[0]} */" + if (single.length <= LINE_LIMIT) return single + "\n" + } + + val prefix = "$indent * " + return buildString { + appendLine("$indent/**") + blocks.forEachIndexed { index, block -> + if (index > 0) appendLine("$indent *") + wrap(block, LINE_LIMIT - prefix.length).forEach { appendLine(prefix + it) } + } + appendLine("$indent */") + } + } + + private fun wrap(text: String, width: Int): List { + val lines = mutableListOf() + val line = StringBuilder() + text.split(' ').forEach { word -> + if (line.isNotEmpty() && line.length + 1 + word.length > width) { + lines += line.toString() + line.clear() + } + if (line.isNotEmpty()) line.append(' ') + line.append(word) + } + if (line.isNotEmpty()) lines += line.toString() + return lines + } +} diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt new file mode 100644 index 0000000000..45a662faa4 --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt @@ -0,0 +1,463 @@ +package com.getcode.buildlogic.analytics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AnalyticsCatalogueTest { + + private fun generate(toml: String): Map = + AnalyticsCatalogue.emit(AnalyticsCatalogue.parse("package = \"com.example\"\n$toml")) + + private fun rejected(toml: String): String = + assertFailsWith { generate(toml) }.message.orEmpty() + + @Test + fun valueEnumsKeepCaseOrderAndCarryDriftAsKDoc() { + val source = generate( + """ + [enums.Method] + doc = "How the user paid." + [enums.Method.cases] + ZULU = "Zulu" + ALPHA = { value = "Alpha", drift = "iOS has no Alpha." } + """.trimIndent(), + ).getValue("com/example/Method.kt") + + assertEquals( + """ + package com.example + + // Generated from events.toml -- do not edit. + + /** How the user paid. */ + enum class Method(val value: String) { + ZULU("Zulu"), + + /** DRIFT: iOS has no Alpha. */ + ALPHA("Alpha"), + } + + """.trimIndent(), + source, + ) + } + + @Test + fun eventMapsEachParameterTypeToItsPropertyCall() { + val source = generate( + """ + [enums.Kind.cases] + ONE = "One" + + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["kind: Kind", "label: String", "rate: Double", "millis: Long", "tier: Int", "done: Boolean"] + properties = [ + { key = "Kind", value = "kind" }, + { key = "Label", value = "label" }, + { key = "Rate", value = "rate" }, + { key = "Time", value = "millis" }, + { key = "Tier", value = "tier" }, + { key = "Done", value = "done" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertEquals( + """ + package com.example.events + + import com.example.AnalyticsEvent + import com.example.Kind + import com.example.event + + // Generated from events.toml -- do not edit. + + object ThingEvents { + fun happened(kind: Kind, label: String, rate: Double, millis: Long, tier: Int, done: Boolean): AnalyticsEvent = + event("Thing Happened") { + text("Kind", kind.value) + text("Label", label) + number("Rate", rate) + number("Time", millis) + number("Tier", tier.toDouble()) + flag("Done", done) + } + } + + """.trimIndent(), + source, + ) + } + + @Test + fun optionalPropertiesPassNullThroughSoTheBuilderOmitsThem() { + val source = generate( + """ + [enums.Kind.cases] + ONE = "One" + + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["kind: Kind?", "error: String?", "tier: Int?"] + properties = [ + { key = "Kind", value = "kind" }, + { key = "Error", value = "error" }, + { key = "Tier", value = "tier" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun happened(kind: Kind?, error: String?, tier: Int?): AnalyticsEvent =") + assertContains(source, "text(\"Kind\", kind?.value)") + assertContains(source, "text(\"Error\", error)") + assertContains(source, "number(\"Tier\", tier?.toDouble())") + } + + @Test + fun anEventWithNoPropertiesHasNoBlock() { + val source = generate( + """ + [[domains.ThingEvents.events]] + builder = "started" + event = "Thing Started" + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, " fun started(): AnalyticsEvent = event(\"Thing Started\")\n") + } + + @Test + fun groupsExpandInPlaceAndTheAmountGroupCallsTheHandWrittenHelper() { + val source = generate( + """ + [enums.State.cases] + SUCCESS = "Success" + + [groups.outcome] + properties = [ + { key = "State", value = "state" }, + { group = "amount" }, + { key = "Error", value = "error" }, + ] + + [[domains.ThingEvents.events]] + builder = "grabbed" + event = "Grabbed" + params = ["state: State", "amount: Amount?", "millis: Long?", "error: String?"] + properties = [ + { key = "Time", value = "millis" }, + { group = "outcome" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "import com.example.Amount\n") + assertContains(source, "import com.example.amount\n") + assertContains( + source, + """ + fun grabbed(state: State, amount: Amount?, millis: Long?, error: String?): AnalyticsEvent = + event("Grabbed") { + number("Time", millis) + text("State", state.value) + amount(amount) + text("Error", error) + } + """.trimIndent().prependIndent(" "), + ) + } + + @Test + fun nameTemplatesInterpolateEnumWireValues() { + val source = generate( + """ + [enums.Method.cases] + RESERVES = "Reserves" + + [[domains.ThingEvents.events]] + builder = "purchase" + event = "Token Purchase With {method}" + params = ["method: Method"] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun purchase(method: Method): AnalyticsEvent = event(\"Token Purchase With \${method.value}\")") + } + + @Test + fun fixedFormatValuesInterpolateAndEscapeLiterals() { + val source = generate( + """ + [[domains.ThingEvents.events]] + builder = "parseFailed" + event = "Deeplink: Parse" + params = ["url: String"] + properties = [{ key = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, """text("Error", "Failed to parse \${'$'}deeplink \"=>\" ${'$'}{url}")""") + } + + @Test + fun twoBuildersMaySendOneEventName() { + val source = generate( + """ + [[domains.ThingEvents.events]] + builder = "parsed" + event = "Deeplink: Parse" + params = ["type: String"] + properties = [{ key = "Type", value = "type" }] + + [[domains.ThingEvents.events]] + builder = "parseFailed" + event = "Deeplink: Parse" + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun parsed(type: String): AnalyticsEvent =") + assertContains(source, "fun parseFailed(): AnalyticsEvent = event(\"Deeplink: Parse\")") + } + + @Test + fun docAndDriftBecomeKDocWrappedAtTheLineLimit() { + val source = generate( + """ + [domains.ThingEvents] + doc = "Things." + + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + doc = "The user had no display name before this submission." + drift = "iOS sends Exchange Rate and no USDC, and fires on the request rather than after connecting to the wallet." + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains( + source, + """ + /** Things. */ + object ThingEvents { + /** + * The user had no display name before this submission. + * + * DRIFT: iOS sends Exchange Rate and no USDC, and fires on the request rather than after + * connecting to the wallet. + */ + fun happened(): AnalyticsEvent = event("Thing Happened") + """.trimIndent(), + ) + } + + @Test + fun peopleCountersBecomeAKeyedEnum() { + val source = generate( + """ + [counters] + TIPS = "Tips Received" + MESSAGES = "Messages Received" + """.trimIndent(), + ).getValue("com/example/PeopleCounter.kt") + + assertContains( + source, + """ + /** Cumulative per-user counters, stored as Mixpanel people properties. */ + enum class PeopleCounter(val key: String) { + TIPS("Tips Received"), + MESSAGES("Messages Received"), + } + """.trimIndent(), + ) + } + + @Test + fun anEmptyCatalogueEmitsNothing() { + assertEquals(emptyMap(), generate("")) + } + + // Rejections. Each message names the entry at fault. + + @Test + fun rejectsTomlSyntaxErrors() { + assertContains(rejected("[enums.Method\n"), "events.toml") + } + + @Test + fun rejectsAnUnknownParameterType() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["kind: Knid"] + properties = [{ key = "Kind", value = "kind" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "Knid") + } + + @Test + fun rejectsADuplicateBuilderInADomain() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened Again" + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "more than once") + } + + @Test + fun rejectsATemplateNamingAMissingParameter() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "purchase" + event = "Token Purchase With {method}" + """.trimIndent(), + ) + assertContains(message, "ThingEvents.purchase") + assertContains(message, "{method}") + } + + @Test + fun rejectsAFormatNamingAMissingParameter() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "parseFailed" + event = "Deeplink: Parse" + properties = [{ key = "Error", format = "Failed => {url}" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.parseFailed") + assertContains(message, "{url}") + } + + @Test + fun rejectsAnEnumCaseWithNoWireString() { + val empty = rejected("[enums.Method.cases]\nRESERVES = \"\"") + assertContains(empty, "Method.RESERVES") + + val missing = rejected("[enums.Method.cases]\nRESERVES = { drift = \"iOS has none.\" }") + assertContains(missing, "Method.RESERVES") + } + + @Test + fun rejectsAPropertyReadingAnUndeclaredParameter() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + properties = [{ key = "Label", value = "label" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "label") + } + + @Test + fun rejectsAParameterNoPropertyReads() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["label: String"] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "label") + } + + @Test + fun rejectsAnUnknownKeySoATypoIsNotSilentlyIgnored() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + drfit = "iOS sends nothing." + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "drfit") + } + + @Test + fun rejectsAnUnknownGroup() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + properties = [{ group = "outcom" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "outcom") + } + + @Test + fun rejectsAnAmountSentAsAPlainProperty() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["amount: Amount"] + properties = [{ key = "Amount", value = "amount" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "amount group") + } + + @Test + fun rejectsTheSameKeyTwiceInOneEvent() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "happened" + event = "Thing Happened" + params = ["a: String", "b: String"] + properties = [{ key = "Label", value = "a" }, { key = "Label", value = "b" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.happened") + assertContains(message, "Label") + } + + @Test + fun rejectsANullableParameterInATemplate() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "parseFailed" + event = "Deeplink: Parse" + params = ["url: String?"] + properties = [{ key = "Error", format = "Failed => {url}" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.parseFailed") + assertContains(message, "url") + } + + private fun assertContains(actual: String, expected: String) = + assertTrue(expected in actual, "expected to find:\n$expected\n\nin:\n$actual") +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c18427bbf9..1915e5496d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ androidx-benchmark-macro = "1.5.0" androidx-profileinstaller = "1.4.1" androidx-test-runner = "1.7.0" junit = "4.13.2" +tomlj = "1.1.1" androidx-junit = "1.3.0" espresso = "3.7.0" mockito-kotlin = "6.3.0" @@ -295,6 +296,7 @@ androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstall androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } junit = { module = "junit:junit", version.ref = "junit" } +tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "espresso" } espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso" } @@ -345,6 +347,7 @@ flipcash-android-feature = { id = "flipcash.android.feature" } flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" } flipcash-kmp-library = { id = "flipcash.kmp.library" } flipcash-kmp-test-fixtures = { id = "flipcash.kmp.test.fixtures" } +flipcash-analytics-catalogue = { id = "flipcash.analytics.catalogue" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } From 7df1547141603791fb2100f4ea6525edf595f225 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:35:04 -0400 Subject: [PATCH 20/37] build(analytics-events): generate builders at build time Applies the catalogue plugin, with an events.toml that declares only the package. Domains move into it in the following commits. --- .../com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt | 1 + libs/analytics-events/build.gradle.kts | 2 ++ libs/analytics-events/events.toml | 1 + 3 files changed, 4 insertions(+) create mode 100644 libs/analytics-events/events.toml diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt index 7466def9af..f5c8cfd45b 100644 --- a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/GenerateAnalyticsEvents.kt @@ -37,6 +37,7 @@ abstract class GenerateAnalyticsEvents : DefaultTask() { val root = outputDirectory.get().asFile root.deleteRecursively() + root.mkdirs() sources.forEach { (path, source) -> root.resolve(path).apply { parentFile.mkdirs() }.writeText(source) } diff --git a/libs/analytics-events/build.gradle.kts b/libs/analytics-events/build.gradle.kts index a0844a6823..194f8071e1 100644 --- a/libs/analytics-events/build.gradle.kts +++ b/libs/analytics-events/build.gradle.kts @@ -1,6 +1,8 @@ plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + // Generates the builders and value enums from events.toml into commonMain. + alias(libs.plugins.flipcash.analytics.catalogue) } kotlin { diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml new file mode 100644 index 0000000000..5e227439bd --- /dev/null +++ b/libs/analytics-events/events.toml @@ -0,0 +1 @@ +package = "com.flipcash.analytics" From 1204e38be12d6a4ae91aa81bcffbd3534e5c430d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:35:40 -0400 Subject: [PATCH 21/37] refactor(analytics-events): move chat into the catalogue --- libs/analytics-events/events.toml | 36 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 3 -- .../flipcash/analytics/events/ChatEvents.kt | 23 ------------ 3 files changed, 36 insertions(+), 26 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 5e227439bd..1ec2190367 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1 +1,37 @@ package = "com.flipcash.analytics" + +# Chat + +[enums.ChatType] +doc = "The `Chat Type` property." + +[enums.ChatType.cases] +CONTACT = "Contact" +TIP = "Tip" +GROUP = "Group" +UNKNOWN = "Unknown" + +[[domains.ChatEvents.events]] +builder = "sentMessage" +event = "Sent Message" +params = ["chatType: ChatType", "error: String?"] +properties = [ + { key = "Chat Type", value = "chatType" }, + { key = "Error", value = "error" }, +] + +[[domains.ChatEvents.events]] +builder = "tipReceived" +event = "Tip Received" +params = ["chatType: ChatType", "amount: Amount"] +properties = [ + { key = "Chat Type", value = "chatType" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.ChatEvents.events]] +builder = "messageReceived" +event = "Message Received" +params = ["chatType: ChatType"] +properties = [{ key = "Chat Type", value = "chatType" }] diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 9c1a354fea..31156efb48 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -1,8 +1,5 @@ package com.flipcash.analytics -/** The `Chat Type` property. */ -enum class ChatType(val value: String) { CONTACT("Contact"), TIP("Tip"), GROUP("Group"), UNKNOWN("Unknown") } - /** The `State` property on an event that reports an outcome. */ enum class State(val value: String) { SUCCESS("Success"), FAILURE("Failure") } diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt deleted file mode 100644 index 822d89d7a2..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ChatEvents.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.Amount -import com.flipcash.analytics.ChatType -import com.flipcash.analytics.amount -import com.flipcash.analytics.event - -object ChatEvents { - fun sentMessage(chatType: ChatType, error: String?) = event("Sent Message") { - text("Chat Type", chatType.value) - text("Error", error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun tipReceived(chatType: ChatType, amount: Amount) = event("Tip Received") { - text("Chat Type", chatType.value) - amount(amount) - } - - fun messageReceived(chatType: ChatType) = event("Message Received") { - text("Chat Type", chatType.value) - } -} From 391a1914b516b304d6232e69e70ae055ebe699f0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:35:58 -0400 Subject: [PATCH 22/37] refactor(analytics-events): move transfer into the catalogue --- libs/analytics-events/events.toml | 85 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 5 -- .../analytics/events/TransferEvents.kt | 66 -------------- 3 files changed, 85 insertions(+), 71 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 1ec2190367..0166fcbdaf 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -35,3 +35,88 @@ builder = "messageReceived" event = "Message Received" params = ["chatType: ChatType"] properties = [{ key = "Chat Type", value = "chatType" }] + +# Transfers + +[enums.State] +doc = "The `State` property on an event that reports an outcome." + +[enums.State.cases] +SUCCESS = "Success" +FAILURE = "Failure" + +[enums.CashLinkChoice.cases] +COPIED = "Copied to clipboard" +SHARED = "Shared to app" + +# State, then the amount, then Error: the block every outcome event ends with. +[groups.outcome] +properties = [ + { key = "State", value = "state" }, + { group = "amount" }, + { key = "Error", value = "error" }, +] + +[[domains.TransferEvents.events]] +builder = "grabBillStart" +event = "Grab Bill Start" + +[[domains.TransferEvents.events]] +builder = "giveBillStart" +event = "Give Bill Start" + +[[domains.TransferEvents.events]] +builder = "grabBill" +event = "Grab Bill" +params = ["state: State", "amount: Amount?", "grabTimeMillis: Long?", "error: String?"] +properties = [ + { key = "Grab Time", value = "grabTimeMillis" }, + { group = "outcome" }, +] +drift = "iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat and Currency of the amount (see Amount)." + +[[domains.TransferEvents.events]] +builder = "giveBill" +event = "Give Bill" +params = ["state: State", "amount: Amount?", "error: String?"] +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.TransferEvents.events]] +builder = "withdrawal" +event = "Withdrawal" +params = ["state: State", "amount: Amount?", "error: String?"] +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.TransferEvents.events]] +builder = "sendCashLink" +event = "Send Cash Link" +params = ["state: State", "amount: Amount?", "choice: CashLinkChoice?", "app: String?", "error: String?"] +properties = [ + { key = "Cash Link Choice", value = "choice" }, + { key = "App", value = "app" }, + { group = "outcome" }, +] +drift = "iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure." + +[[domains.TransferEvents.events]] +builder = "receiveCashLink" +event = "Receive Cash Link" +params = ["state: State", "amount: Amount?", "error: String?"] +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.TransferEvents.events]] +builder = "sentCash" +event = "Sent Cash" +params = ["state: State", "amount: Amount?", "error: String?"] +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.TransferEvents.events]] +builder = "sentTip" +event = "Sent Tip" +params = ["state: State", "amount: Amount?", "error: String?"] +properties = [{ group = "outcome" }] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 31156efb48..e16120f036 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -1,8 +1,5 @@ package com.flipcash.analytics -/** The `State` property on an event that reports an outcome. */ -enum class State(val value: String) { SUCCESS("Success"), FAILURE("Failure") } - enum class AddMoneySource(val value: String) { MENU("Menu"), GIVE_SHORTFALL("Give Shortfall"), BUY_SHORTFALL("Buy Shortfall"), USERNAME_SHORTFALL("Username Shortfall"), CHAT("Chat"), SCANNER("Scanner"), BALANCE("Balance"), @@ -30,8 +27,6 @@ enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Ph enum class WalletProvider(val value: String) { PHANTOM("Phantom") } -enum class CashLinkChoice(val value: String) { COPIED("Copied to clipboard"), SHARED("Shared to app") } - enum class OnrampStep(val value: String) { SHOW_INFO("Show Verification Info"), ENTER_PHONE("Show Enter Phone"), CONFIRM_PHONE("Show Confirm Phone"), ENTER_EMAIL("Show Enter Email"), CONFIRM_EMAIL("Show Confirm Email"), diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt deleted file mode 100644 index 1b3c30c857..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TransferEvents.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.Amount -import com.flipcash.analytics.CashLinkChoice -import com.flipcash.analytics.PropertiesBuilder -import com.flipcash.analytics.State -import com.flipcash.analytics.amount -import com.flipcash.analytics.event - -object TransferEvents { - fun grabBillStart() = event("Grab Bill Start") - - fun giveBillStart() = event("Give Bill Start") - - // DRIFT: iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat - // and Currency of the amount (see Amount). - fun grabBill(state: State, amount: Amount?, grabTimeMillis: Long?, error: String?) = event("Grab Bill") { - number("Grab Time", grabTimeMillis) - outcome(state, amount, error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun giveBill(state: State, amount: Amount?, error: String?) = event("Give Bill") { - outcome(state, amount, error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun withdrawal(state: State, amount: Amount?, error: String?) = event("Withdrawal") { - outcome(state, amount, error) - } - - // DRIFT: iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see - // Amount). Android never sends Failure. - fun sendCashLink( - state: State, - amount: Amount?, - choice: CashLinkChoice?, - app: String?, - error: String?, - ) = event("Send Cash Link") { - text("Cash Link Choice", choice?.value) - text("App", app) - outcome(state, amount, error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun receiveCashLink(state: State, amount: Amount?, error: String?) = event("Receive Cash Link") { - outcome(state, amount, error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun sentCash(state: State, amount: Amount?, error: String?) = event("Sent Cash") { - outcome(state, amount, error) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun sentTip(state: State, amount: Amount?, error: String?) = event("Sent Tip") { - outcome(state, amount, error) - } - - private fun PropertiesBuilder.outcome(state: State, amount: Amount?, error: String?) { - text("State", state.value) - amount(amount) - text("Error", error) - } -} From 1bebeadf83fb62c7136e15719375f8469de987ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:36:16 -0400 Subject: [PATCH 23/37] refactor(analytics-events): move add money into the catalogue --- libs/analytics-events/events.toml | 67 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 11 --- .../analytics/events/AddMoneyEvents.kt | 45 ------------- 3 files changed, 67 insertions(+), 56 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 0166fcbdaf..60490879bc 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -120,3 +120,70 @@ event = "Sent Tip" params = ["state: State", "amount: Amount?", "error: String?"] properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +# Add money + +[enums.AddMoneySource.cases] +MENU = "Menu" +GIVE_SHORTFALL = "Give Shortfall" +BUY_SHORTFALL = "Buy Shortfall" +USERNAME_SHORTFALL = "Username Shortfall" +CHAT = "Chat" +SCANNER = "Scanner" +BALANCE = "Balance" + +[enums.AddMoneyMethod.cases] +COINBASE = "Coinbase" +PHANTOM = "Phantom" +OTHER_WALLET = "Other Wallet" +RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } + +[[domains.AddMoneyEvents.events]] +builder = "opened" +event = "Add Money: Opened" +params = ["source: AddMoneySource"] +properties = [{ key = "Source", value = "source" }] + +[[domains.AddMoneyEvents.events]] +builder = "methodSelected" +event = "Add Money: Method Selected" +params = ["method: AddMoneyMethod"] +properties = [{ key = "Method", value = "method" }] +drift = "iOS has no Reserves method." + +[[domains.AddMoneyEvents.events]] +builder = "amountConfirmed" +event = "Add Money: Amount Confirmed" +params = ["method: AddMoneyMethod", "amount: Amount"] +properties = [ + { key = "Method", value = "method" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +[[domains.AddMoneyEvents.events]] +builder = "paymentInvoked" +event = "Add Money: Payment Invoked" +params = ["method: AddMoneyMethod", "amount: Amount"] +properties = [ + { key = "Method", value = "method" }, + { group = "amount" }, +] +drift = "iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here." + +[[domains.AddMoneyEvents.events]] +builder = "addressCopied" +event = "Add Money: Address Copied" +params = ["mint: String"] +properties = [{ key = "Mint", value = "mint" }] + +[[domains.AddMoneyEvents.events]] +builder = "result" +event = "Add Money" +params = ["method: AddMoneyMethod", "state: State", "amount: Amount?", "error: String?"] +properties = [ + { key = "Method", value = "method" }, + { group = "outcome" }, +] +doc = "The outcome of an add-money flow, sent as the event named `Add Money`." +drift = "iOS sends Exchange Rate and no USDC (see Amount)." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index e16120f036..be77e8b620 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -1,16 +1,5 @@ package com.flipcash.analytics -enum class AddMoneySource(val value: String) { - MENU("Menu"), GIVE_SHORTFALL("Give Shortfall"), BUY_SHORTFALL("Buy Shortfall"), - USERNAME_SHORTFALL("Username Shortfall"), CHAT("Chat"), SCANNER("Scanner"), BALANCE("Balance"), -} - -enum class AddMoneyMethod(val value: String) { - COINBASE("Coinbase"), PHANTOM("Phantom"), OTHER_WALLET("Other Wallet"), - // DRIFT: iOS has no Reserves method. - RESERVES("Reserves"), -} - enum class DisplayNameSource(val value: String) { ONBOARDING("Onboarding"), MY_ACCOUNT("My Account"), TIP_CARD_SETUP("Tip Card Setup"), } diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt deleted file mode 100644 index 0a85c9861a..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AddMoneyEvents.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.AddMoneyMethod -import com.flipcash.analytics.AddMoneySource -import com.flipcash.analytics.Amount -import com.flipcash.analytics.State -import com.flipcash.analytics.amount -import com.flipcash.analytics.event - -object AddMoneyEvents { - fun opened(source: AddMoneySource) = event("Add Money: Opened") { - text("Source", source.value) - } - - // DRIFT: iOS has no Reserves method. - fun methodSelected(method: AddMoneyMethod) = event("Add Money: Method Selected") { - text("Method", method.value) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun amountConfirmed(method: AddMoneyMethod, amount: Amount) = event("Add Money: Amount Confirmed") { - text("Method", method.value) - amount(amount) - } - - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the - // native amount here. - fun paymentInvoked(method: AddMoneyMethod, amount: Amount) = event("Add Money: Payment Invoked") { - text("Method", method.value) - amount(amount) - } - - fun addressCopied(mint: String) = event("Add Money: Address Copied") { - text("Mint", mint) - } - - /** The outcome of an add-money flow, sent as the event named `Add Money`. */ - // DRIFT: iOS sends Exchange Rate and no USDC (see Amount). - fun result(method: AddMoneyMethod, state: State, amount: Amount?, error: String?) = event("Add Money") { - text("Method", method.value) - text("State", state.value) - amount(amount) - text("Error", error) - } -} From 916ab011448ab3882d822fab2ccad17dfc1cad70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:36:21 -0400 Subject: [PATCH 24/37] refactor(analytics-events): move onramp into the catalogue --- libs/analytics-events/events.toml | 15 +++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 5 ----- .../com/flipcash/analytics/events/OnrampEvents.kt | 9 --------- 3 files changed, 15 insertions(+), 14 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 60490879bc..14da85d38f 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -187,3 +187,18 @@ properties = [ ] doc = "The outcome of an add-money flow, sent as the event named `Add Money`." drift = "iOS sends Exchange Rate and no USDC (see Amount)." + +# Onramp + +[enums.OnrampStep.cases] +SHOW_INFO = "Show Verification Info" +ENTER_PHONE = "Show Enter Phone" +CONFIRM_PHONE = "Show Confirm Phone" +ENTER_EMAIL = "Show Enter Email" +CONFIRM_EMAIL = "Show Confirm Email" + +[[domains.OnrampEvents.events]] +builder = "step" +event = "Onramp: {step}" +params = ["step: OnrampStep"] +doc = "Show Verification Info is Android only; part 2 decides whether iOS sends it." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index be77e8b620..7099a03c55 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -16,11 +16,6 @@ enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Ph enum class WalletProvider(val value: String) { PHANTOM("Phantom") } -enum class OnrampStep(val value: String) { - SHOW_INFO("Show Verification Info"), ENTER_PHONE("Show Enter Phone"), CONFIRM_PHONE("Show Confirm Phone"), - ENTER_EMAIL("Show Enter Email"), CONFIRM_EMAIL("Show Confirm Email"), -} - enum class Button(val value: String) { CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt deleted file mode 100644 index 4aa1acd1d6..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/OnrampEvents.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.OnrampStep -import com.flipcash.analytics.event - -object OnrampEvents { - // Show Verification Info is Android only; part 2 decides whether iOS sends it. - fun step(step: OnrampStep) = event("Onramp: ${step.value}") -} From 52dd66b7f556fba2fa5af98051c231781d51a8e2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:36:43 -0400 Subject: [PATCH 25/37] refactor(analytics-events): move wallet into the catalogue --- libs/analytics-events/events.toml | 39 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 2 - .../flipcash/analytics/events/WalletEvents.kt | 32 --------------- 3 files changed, 39 insertions(+), 34 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 14da85d38f..0c97843ff3 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -202,3 +202,42 @@ builder = "step" event = "Onramp: {step}" params = ["step: OnrampStep"] doc = "Show Verification Info is Android only; part 2 decides whether iOS sends it." + +# Wallet + +[enums.WalletProvider.cases] +PHANTOM = "Phantom" + +[domains.WalletEvents] +doc = "The Phantom deeplink flow." + +[[domains.WalletEvents.events]] +builder = "connect" +event = "Wallet: Connect" +params = ["provider: WalletProvider"] +properties = [{ key = "Provider", value = "provider" }] +drift = "iOS sends no Provider, and fires on the request rather than after connecting." + +[[domains.WalletEvents.events]] +builder = "requestAmount" +event = "Wallet: Request Amount" +params = ["provider: WalletProvider", "amount: Amount"] +properties = [ + { key = "Provider", value = "provider" }, + { group = "amount" }, +] +drift = "iOS sends no Provider, and sends only Fiat (the native amount) and Currency where Android sends the token amount's block (see Amount)." + +[[domains.WalletEvents.events]] +builder = "transactionsFailed" +event = "Wallet: Transactions Failed" +params = ["provider: WalletProvider"] +properties = [{ key = "Provider", value = "provider" }] +drift = "iOS declares this with no Provider and never sends it." + +[[domains.WalletEvents.events]] +builder = "cancel" +event = "Wallet: Cancel" +params = ["provider: WalletProvider"] +properties = [{ key = "Provider", value = "provider" }] +drift = "iOS sends no Provider, and fires on any error code where Android fires only on a user reject." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 7099a03c55..75857a9ab1 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -14,8 +14,6 @@ enum class TokenInfoSource(val value: String) { enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Phantom"), COINBASE("Coinbase") } -enum class WalletProvider(val value: String) { PHANTOM("Phantom") } - enum class Button(val value: String) { CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt deleted file mode 100644 index 61e08ce9cc..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/WalletEvents.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.Amount -import com.flipcash.analytics.WalletProvider -import com.flipcash.analytics.amount -import com.flipcash.analytics.event - -/** The Phantom deeplink flow. */ -object WalletEvents { - // DRIFT: iOS sends no Provider, and fires on the request rather than after connecting. - fun connect(provider: WalletProvider) = event("Wallet: Connect") { - text("Provider", provider.value) - } - - // DRIFT: iOS sends no Provider, and sends only Fiat (the native amount) and Currency where - // Android sends the token amount's block (see Amount). - fun requestAmount(provider: WalletProvider, amount: Amount) = event("Wallet: Request Amount") { - text("Provider", provider.value) - amount(amount) - } - - // DRIFT: iOS declares this with no Provider and never sends it. - fun transactionsFailed(provider: WalletProvider) = event("Wallet: Transactions Failed") { - text("Provider", provider.value) - } - - // DRIFT: iOS sends no Provider, and fires on any error code where Android fires only on a - // user reject. - fun cancel(provider: WalletProvider) = event("Wallet: Cancel") { - text("Provider", provider.value) - } -} From 8d8bd883ed9ef0b9fc81770131c177dcc3eac89c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:36:50 -0400 Subject: [PATCH 26/37] refactor(analytics-events): move token info into the catalogue --- libs/analytics-events/events.toml | 18 ++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 8 -------- .../analytics/events/TokenInfoEvents.kt | 10 ---------- 3 files changed, 18 insertions(+), 18 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 0c97843ff3..634490cb1d 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -241,3 +241,21 @@ event = "Wallet: Cancel" params = ["provider: WalletProvider"] properties = [{ key = "Provider", value = "provider" }] drift = "iOS sends no Provider, and fires on any error code where Android fires only on a user reject." + +# Token info + +[enums.TokenInfoSource] +doc = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2." + +[enums.TokenInfoSource.cases] +DEEPLINK = "Deeplink" +WALLET = "Wallet" +DISCOVERY = "Discovery" +CHAT = "Chat" +CHAT_GATE = "Chat Gate" + +[[domains.TokenInfoEvents.events]] +builder = "opened" +event = "Token Info: Opened From {source}" +params = ["source: TokenInfoSource", "mint: String"] +properties = [{ key = "Mint", value = "mint" }] diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 75857a9ab1..2a346ec394 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -4,14 +4,6 @@ enum class DisplayNameSource(val value: String) { ONBOARDING("Onboarding"), MY_ACCOUNT("My Account"), TIP_CARD_SETUP("Tip Card Setup"), } -/** - * Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android - * previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2. - */ -enum class TokenInfoSource(val value: String) { - DEEPLINK("Deeplink"), WALLET("Wallet"), DISCOVERY("Discovery"), CHAT("Chat"), CHAT_GATE("Chat Gate"), -} - enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Phantom"), COINBASE("Coinbase") } enum class Button(val value: String) { diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt deleted file mode 100644 index 513234fdb0..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/TokenInfoEvents.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.TokenInfoSource -import com.flipcash.analytics.event - -object TokenInfoEvents { - fun opened(source: TokenInfoSource, mint: String) = event("Token Info: Opened From ${source.value}") { - text("Mint", mint) - } -} From a775b1f1d42fb5aca39396515661306c9c52c398 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:37:47 -0400 Subject: [PATCH 27/37] fix(build-logic): let a builder send Mint beside the amount block The amount helper writes Mint and Exchange Rate only when the amount has them, so a builder may send its own Mint first, as SwapEvents does. Only Fiat, Currency, USDC and Quarks now count as duplicates. --- .../buildlogic/analytics/CatalogueParser.kt | 9 ++++-- .../analytics/AnalyticsCatalogueTest.kt | 30 +++++++++++++++++++ .../flipcash/analytics/events/SwapEvents.kt | 28 ----------------- 3 files changed, 37 insertions(+), 30 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt index d75f51b646..ad6a99082f 100644 --- a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt @@ -19,8 +19,13 @@ internal object CatalogueParser { /** The built-in group that calls the hand-written `amount(...)` helper. */ private const val AMOUNT_GROUP = "amount" - /** The keys `amount(...)` writes, so a duplicate of one is caught like any other. */ - private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks", "Exchange Rate", "Mint") + /** + * The keys `amount(...)` always writes, so a duplicate of one is caught like any other. It + * also writes `Exchange Rate` and `Mint` when the amount has them, and those two may appear + * beside the group: the builder sends the property, and the amount's value replaces it when + * present. `SwapEvents` relies on that for `Mint`. + */ + private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks") private val PRIMITIVES = mapOf( "String" to ParamType.Text, diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt index 45a662faa4..9fa93cda7d 100644 --- a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt @@ -172,6 +172,36 @@ class AnalyticsCatalogueTest { ) } + @Test + fun aPropertyMayRepeatOneTheAmountSendsOnlyWhenItHasIt() { + val source = generate( + """ + [[domains.ThingEvents.events]] + builder = "sell" + event = "Token Sell" + params = ["mint: String", "amount: Amount"] + properties = [{ key = "Mint", value = "mint" }, { group = "amount" }] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "text(\"Mint\", mint)\n amount(amount)") + } + + @Test + fun rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { + val message = rejected( + """ + [[domains.ThingEvents.events]] + builder = "sell" + event = "Token Sell" + params = ["fiat: Double", "amount: Amount"] + properties = [{ key = "Fiat", value = "fiat" }, { group = "amount" }] + """.trimIndent(), + ) + assertContains(message, "ThingEvents.sell") + assertContains(message, "Fiat") + } + @Test fun nameTemplatesInterpolateEnumWireValues() { val source = generate( diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt deleted file mode 100644 index f0759238ec..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/SwapEvents.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.Amount -import com.flipcash.analytics.PurchaseMethod -import com.flipcash.analytics.amount -import com.flipcash.analytics.event - -/** Buying and selling a token. */ -object SwapEvents { - // DRIFT (Reserves): iOS also sends State, Payment Mint and Payment Token Symbol, and sends - // only Fiat and Currency of the amount, with no USDC or Quarks. - // Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. - fun purchase(method: PurchaseMethod, mint: String, amount: Amount, error: String?) = - event("Token Purchase With ${method.value}") { - text("Mint", mint) - amount(amount) - text("Error", error) - } - - // DRIFT: iOS also sends State, and sends only Fiat and Currency of the amount, with no - // USDC, Quarks or Fee. - fun sell(mint: String, amount: Amount, fee: Double, error: String?) = event("Token Sell") { - text("Mint", mint) - amount(amount) - number("Fee", fee) - text("Error", error) - } -} From 38a51e4125572a6ef4de5cee71b2c8c7325b816e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:37:55 -0400 Subject: [PATCH 28/37] refactor(analytics-events): move swap into the catalogue --- libs/analytics-events/events.toml | 34 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 2 -- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 634490cb1d..5738391b71 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -259,3 +259,37 @@ builder = "opened" event = "Token Info: Opened From {source}" params = ["source: TokenInfoSource", "mint: String"] properties = [{ key = "Mint", value = "mint" }] + +# Swap + +[enums.PurchaseMethod.cases] +RESERVES = "Reserves" +PHANTOM = "Phantom" +COINBASE = "Coinbase" + +[domains.SwapEvents] +doc = "Buying and selling a token." + +[[domains.SwapEvents.events]] +builder = "purchase" +event = "Token Purchase With {method}" +params = ["method: PurchaseMethod", "mint: String", "amount: Amount", "error: String?"] +properties = [ + { key = "Mint", value = "mint" }, + { group = "amount" }, + { key = "Error", value = "error" }, +] +doc = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them." +drift = "(Reserves) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks." + +[[domains.SwapEvents.events]] +builder = "sell" +event = "Token Sell" +params = ["mint: String", "amount: Amount", "fee: Double", "error: String?"] +properties = [ + { key = "Mint", value = "mint" }, + { group = "amount" }, + { key = "Fee", value = "fee" }, + { key = "Error", value = "error" }, +] +drift = "iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 2a346ec394..356ca881e7 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -4,8 +4,6 @@ enum class DisplayNameSource(val value: String) { ONBOARDING("Onboarding"), MY_ACCOUNT("My Account"), TIP_CARD_SETUP("Tip Card Setup"), } -enum class PurchaseMethod(val value: String) { RESERVES("Reserves"), PHANTOM("Phantom"), COINBASE("Coinbase") } - enum class Button(val value: String) { CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), From d6342558c4a8166711651c28e4f5c5d2101b1007 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:38:40 -0400 Subject: [PATCH 29/37] refactor(analytics-events): move scan into the catalogue --- libs/analytics-events/events.toml | 40 +++++++++++++++++++ .../flipcash/analytics/events/ScanEvents.kt | 29 -------------- 2 files changed, 40 insertions(+), 29 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 5738391b71..dc8f286158 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -293,3 +293,43 @@ properties = [ { key = "Error", value = "error" }, ] drift = "iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee." + +# Scan + +[domains.ScanEvents] +doc = "The gallery scan path, and the tip card." + +[[domains.ScanEvents.events]] +builder = "galleryImagePicked" +event = "Gallery Scan: Image Picked" + +[[domains.ScanEvents.events]] +builder = "gallerySucceeded" +event = "Gallery Scan: Succeeded" +params = ["tier: Int", "zoom: Double", "timeMillis: Long"] +properties = [ + { key = "Tier", value = "tier" }, + { key = "Zoom", value = "zoom" }, + { key = "Time", value = "timeMillis" }, +] +doc = "@param tier which rung of the crop ladder decoded, 1 to 3." +drift = "iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms." + +[[domains.ScanEvents.events]] +builder = "galleryFailed" +event = "Gallery Scan: Failed" +params = ["timeMillis: Long", "exhausted: Boolean"] +properties = [ + { key = "Time", value = "timeMillis" }, + { key = "Exhausted", value = "exhausted" }, +] +doc = "@param exhausted true when the budget ran out rather than the ladder ending." +drift = "iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted." + +[[domains.ScanEvents.events]] +builder = "tipCardScanned" +event = "Tip Card Scanned" + +[[domains.ScanEvents.events]] +builder = "tipCardPresented" +event = "Tip Card Presented" diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt deleted file mode 100644 index 0c6ace63a2..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ScanEvents.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.event - -/** The gallery scan path, and the tip card. */ -object ScanEvents { - fun galleryImagePicked() = event("Gallery Scan: Image Picked") - - /** @param tier which rung of the crop ladder decoded, 1 to 3. */ - // DRIFT: iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as - // {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms. - fun gallerySucceeded(tier: Int, zoom: Double, timeMillis: Long) = event("Gallery Scan: Succeeded") { - number("Tier", tier.toDouble()) - number("Zoom", zoom) - number("Time", timeMillis) - } - - /** @param exhausted true when the budget ran out rather than the ladder ending. */ - // DRIFT: iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, - // Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted. - fun galleryFailed(timeMillis: Long, exhausted: Boolean) = event("Gallery Scan: Failed") { - number("Time", timeMillis) - flag("Exhausted", exhausted) - } - - fun tipCardScanned() = event("Tip Card Scanned") - - fun tipCardPresented() = event("Tip Card Presented") -} From 267457ecf9a6c9d31a34b9759b0bf2c365e0d00c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:38:45 -0400 Subject: [PATCH 30/37] refactor(analytics-events): move deeplink into the catalogue --- libs/analytics-events/events.toml | 34 +++++++++++++++++++ .../analytics/events/DeeplinkEvents.kt | 31 ----------------- 2 files changed, 34 insertions(+), 31 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index dc8f286158..6a7e898e8b 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -333,3 +333,37 @@ event = "Tip Card Scanned" [[domains.ScanEvents.events]] builder = "tipCardPresented" event = "Tip Card Presented" + +# Deeplink + +[domains.DeeplinkEvents] +doc = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." + +[[domains.DeeplinkEvents.events]] +builder = "open" +event = "Deeplink: Open" +params = ["url: String"] +properties = [{ key = "URL", value = "url" }] + +[[domains.DeeplinkEvents.events]] +builder = "parsed" +event = "Deeplink: Parse" +params = ["type: String"] +properties = [{ key = "Type", value = "type" }] +drift = "iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, \"Sheet:\"}, and iOS sends this for every link where Android sends it only on the gallery QR path." + +[[domains.DeeplinkEvents.events]] +builder = "parseFailed" +event = "Deeplink: Parse" +params = ["url: String"] +properties = [{ key = "Error", format = "Failed to parse deeplink => {url}" }] + +[[domains.DeeplinkEvents.events]] +builder = "routed" +event = "Deeplink: Routed" +params = ["type: String", "error: String?"] +properties = [ + { key = "Type", value = "type" }, + { key = "Error", value = "error" }, +] +drift = "iOS never passes Error, and sends this for every kind where Android sends it only for a cash link." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt deleted file mode 100644 index 54ed037591..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DeeplinkEvents.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.event - -/** - * [url] arrives with its query and fragment already stripped. `type` is a string because the - * two apps' deeplink vocabularies differ. - */ -object DeeplinkEvents { - fun open(url: String) = event("Deeplink: Open") { - text("URL", url) - } - - // DRIFT: iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, - // ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, "Sheet:"}, and iOS sends this - // for every link where Android sends it only on the gallery QR path. - fun parsed(type: String) = event("Deeplink: Parse") { - text("Type", type) - } - - fun parseFailed(url: String) = event("Deeplink: Parse") { - text("Error", "Failed to parse deeplink => $url") - } - - // DRIFT: iOS never passes Error, and sends this for every kind where Android sends it only - // for a cash link. - fun routed(type: String, error: String?) = event("Deeplink: Routed") { - text("Type", type) - text("Error", error) - } -} From cdc622debde1ba28b7e7a7e045ff5d837f05d64a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:38:56 -0400 Subject: [PATCH 31/37] refactor(analytics-events): move display name into the catalogue --- libs/analytics-events/events.toml | 21 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 4 ---- .../analytics/events/DisplayNameEvents.kt | 16 -------------- 3 files changed, 21 insertions(+), 20 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 6a7e898e8b..804016721d 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -367,3 +367,24 @@ properties = [ { key = "Error", value = "error" }, ] drift = "iOS never passes Error, and sends this for every kind where Android sends it only for a cash link." + +# Display name + +[enums.DisplayNameSource.cases] +ONBOARDING = "Onboarding" +MY_ACCOUNT = "My Account" +TIP_CARD_SETUP = "Tip Card Setup" + +[[domains.DisplayNameEvents.events]] +builder = "set" +event = "Display Name Set" +params = ["source: DisplayNameSource"] +properties = [{ key = "Source", value = "source" }] +doc = "The user had no display name before this submission." + +[[domains.DisplayNameEvents.events]] +builder = "updated" +event = "Display Name Updated" +params = ["source: DisplayNameSource"] +properties = [{ key = "Source", value = "source" }] +doc = "The user replaced an existing display name." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt index 356ca881e7..d44335b39b 100644 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt @@ -1,9 +1,5 @@ package com.flipcash.analytics -enum class DisplayNameSource(val value: String) { - ONBOARDING("Onboarding"), MY_ACCOUNT("My Account"), TIP_CARD_SETUP("Tip Card Setup"), -} - enum class Button(val value: String) { CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt deleted file mode 100644 index b5d365ba7a..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/DisplayNameEvents.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.DisplayNameSource -import com.flipcash.analytics.event - -object DisplayNameEvents { - /** The user had no display name before this submission. */ - fun set(source: DisplayNameSource) = event("Display Name Set") { - text("Source", source.value) - } - - /** The user replaced an existing display name. */ - fun updated(source: DisplayNameSource) = event("Display Name Updated") { - text("Source", source.value) - } -} From c9764fea55812d36f299aa7273ac245db1438c48 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:39:08 -0400 Subject: [PATCH 32/37] refactor(analytics-events): move error modal into the catalogue --- libs/analytics-events/events.toml | 14 ++++++++++++++ .../flipcash/analytics/events/ErrorModalEvents.kt | 15 --------------- 2 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 804016721d..72294e9094 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -388,3 +388,17 @@ event = "Display Name Updated" params = ["source: DisplayNameSource"] properties = [{ key = "Source", value = "source" }] doc = "The user replaced an existing display name." + +# Error modal + +[[domains.ErrorModalEvents.events]] +builder = "displayed" +event = "Error Modal Displayed" +params = ["title: String", "message: String", "screen: String?", "callSite: String?"] +properties = [ + { key = "Title", value = "title" }, + { key = "Message", value = "message" }, + { key = "Screen", value = "screen" }, + { key = "Call Site", value = "callSite" }, +] +drift = "iOS's Screen is `presentedSheet.description` or \"scan\" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site." diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt deleted file mode 100644 index bc5f1599e1..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ErrorModalEvents.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.event - -object ErrorModalEvents { - // DRIFT: iOS's Screen is `presentedSheet.description` or "scan" where Android's is the - // back stack top's `screenName()`, and iOS never sends Call Site. - fun displayed(title: String, message: String, screen: String?, callSite: String?) = - event("Error Modal Displayed") { - text("Title", title) - text("Message", message) - text("Screen", screen) - text("Call Site", callSite) - } -} From 89e26b4e4c776be7d9aa47a67bc54749a4003dee Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:39:18 -0400 Subject: [PATCH 33/37] refactor(analytics-events): move account into the catalogue --- libs/analytics-events/events.toml | 29 +++++++++++++++++++ .../analytics/events/AccountEvents.kt | 21 -------------- 2 files changed, 29 insertions(+), 21 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 72294e9094..df770dfff3 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -402,3 +402,32 @@ properties = [ { key = "Call Site", value = "callSite" }, ] drift = "iOS's Screen is `presentedSheet.description` or \"scan\" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site." + +# Account + +[[domains.AccountEvents.events]] +builder = "createAccountPayment" +event = "Create Account Payment" +params = ["price: Double", "currency: String", "owner: String"] +properties = [ + { key = "Fiat", value = "price" }, + { key = "Currency", value = "currency" }, + { key = "Owner Public Key", value = "owner" }, +] +doc = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." + +[[domains.AccountEvents.events]] +builder = "enteredPhoneNumber" +event = "Entered Phone Number" + +[[domains.AccountEvents.events]] +builder = "verifiedPhoneNumber" +event = "Verified Phone Number" + +[[domains.AccountEvents.events]] +builder = "linkedPhoneNumber" +event = "Linked Phone Number" + +[[domains.AccountEvents.events]] +builder = "completeOnboarding" +event = "Complete Onboarding" diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt deleted file mode 100644 index e57fb7e34c..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/AccountEvents.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.event - -object AccountEvents { - // Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at - // registration and is a different moment. - fun createAccountPayment(price: Double, currency: String, owner: String) = event("Create Account Payment") { - number("Fiat", price) - text("Currency", currency) - text("Owner Public Key", owner) - } - - fun enteredPhoneNumber() = event("Entered Phone Number") - - fun verifiedPhoneNumber() = event("Verified Phone Number") - - fun linkedPhoneNumber() = event("Linked Phone Number") - - fun completeOnboarding() = event("Complete Onboarding") -} From 0e742c9a4714127cf038f8dc309d33c7dcc48853 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:39:24 -0400 Subject: [PATCH 34/37] refactor(analytics-events): move buttons into the catalogue --- libs/analytics-events/events.toml | 21 +++++++++++++++++++ .../kotlin/com/flipcash/analytics/Values.kt | 8 ------- .../flipcash/analytics/events/ButtonEvents.kt | 8 ------- 3 files changed, 21 insertions(+), 16 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index df770dfff3..d63ba694bc 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -431,3 +431,24 @@ event = "Linked Phone Number" [[domains.AccountEvents.events]] builder = "completeOnboarding" event = "Complete Onboarding" + +# Buttons + +[enums.Button.cases] +CREATE_ACCOUNT = "Create Account" +SAVE_ACCESS_KEY = "Save Access Key" +WROTE_ACCESS_KEY = "Wrote Access Key" +ALLOW_PUSH = "Allow Push" +SKIP_PUSH = "Skip Push" +ALLOW_CONTACTS = "Allow Contacts" +SKIP_CONTACTS = "Skip Contacts" +BUY_WITH_RESERVES = "Buy With Reserves" +BUY_WITH_PHANTOM = "Buy With Phantom" +BUY_WITH_COINBASE = "Buy With Coinbase" +BUY_WITH_OTHER_WALLET = "Buy With Other Wallet" +SHARE_TOKEN_INFO = "Share Token Info" + +[[domains.ButtonEvents.events]] +builder = "tapped" +event = "Button: {button}" +params = ["button: Button"] diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt deleted file mode 100644 index d44335b39b..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/Values.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flipcash.analytics - -enum class Button(val value: String) { - CREATE_ACCOUNT("Create Account"), SAVE_ACCESS_KEY("Save Access Key"), WROTE_ACCESS_KEY("Wrote Access Key"), - ALLOW_PUSH("Allow Push"), SKIP_PUSH("Skip Push"), ALLOW_CONTACTS("Allow Contacts"), SKIP_CONTACTS("Skip Contacts"), - BUY_WITH_RESERVES("Buy With Reserves"), BUY_WITH_PHANTOM("Buy With Phantom"), BUY_WITH_COINBASE("Buy With Coinbase"), - BUY_WITH_OTHER_WALLET("Buy With Other Wallet"), SHARE_TOKEN_INFO("Share Token Info"), -} diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt deleted file mode 100644 index 0c45cb6462..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/events/ButtonEvents.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flipcash.analytics.events - -import com.flipcash.analytics.Button -import com.flipcash.analytics.event - -object ButtonEvents { - fun tapped(button: Button) = event("Button: ${button.value}") -} From 24ebf561c919f2cc5a10f7cf69f8cb5413bc9689 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:39:54 -0400 Subject: [PATCH 35/37] refactor(analytics-events): move people counters into the catalogue --- libs/analytics-events/events.toml | 6 ++++++ .../kotlin/com/flipcash/analytics/PeopleCounter.kt | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index d63ba694bc..2cfc782356 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -50,6 +50,12 @@ COPIED = "Copied to clipboard" SHARED = "Shared to app" # State, then the amount, then Error: the block every outcome event ends with. +# People counters: cumulative per-user totals, sent as Mixpanel people properties. +[counters] +TIPS = "Tips Received" +TIPS_VALUE = "Tips Received Value" +MESSAGES = "Messages Received" + [groups.outcome] properties = [ { key = "State", value = "state" }, diff --git a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt b/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt deleted file mode 100644 index c732692a40..0000000000 --- a/libs/analytics-events/src/commonMain/kotlin/com/flipcash/analytics/PeopleCounter.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.flipcash.analytics - -/** Cumulative per-user counters, stored as Mixpanel people properties. */ -enum class PeopleCounter(val key: String) { - TIPS("Tips Received"), - TIPS_VALUE("Tips Received Value"), - MESSAGES("Messages Received"), -} From ed880ef659a5a880029a8617fb8d9fa8ba473df9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 19:43:00 -0400 Subject: [PATCH 36/37] ci(analytics-events): run the macOS lane on generator changes Add the generator's sources to the SharedCore tests paths filter, and run build-logic's tests from flipcashTestDebug so the Flipcash tests lane covers the generator. Document in events.toml how to add an event, and point the Swift facade's comment at the catalogue. --- .github/workflows/shared-core-tests.yml | 2 ++ build.gradle.kts | 2 ++ .../SharedCoreKit/AnalyticsEvents.swift | 3 ++- libs/analytics-events/events.toml | 23 +++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 52578890ae..63b9509076 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -13,6 +13,8 @@ on: - 'libs/encryption/**' - 'libs/reporting/**' - 'libs/analytics-events/**' + - 'build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt' + - 'build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/**' - 'gradle/libs.versions.toml' - '.github/workflows/shared-core-tests.yml' diff --git a/build.gradle.kts b/build.gradle.kts index 625977ef95..9918bba0f6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -88,4 +88,6 @@ tasks.register("flipcashTestDebug") { dependsOn(androidUnitTestModules.map { "$it:testDebugUnitTest" }) dependsOn(jvmUnitTestModules.map { "$it:test" }) dependsOn(kmpUnitTestModules.map { "$it:testAndroidHostTest" }) + // build-logic's own tests, which cover the analytics catalogue generator. + dependsOn(gradle.includedBuild("build-logic").task(":convention:test")) } diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift index f03090989e..b4cf189e91 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift @@ -5,7 +5,8 @@ import SharedCore public typealias TrackedEvent = SharedCore.AnalyticsEvent // The value vocabulary. Deliberately typealiases and not Swift enums, as with `ReportReason`: -// the wire strings are written down once, in `Values.kt`. Kotlin's `Button`, `State` and +// the wire strings are written down once, in +// `libs/analytics-events/events.toml`. Kotlin's `Button`, `State` and // `Amount` are renamed here so they don't read as SwiftUI's `Button` and `@State`, or as // one of the app's money types. public typealias ChatType = SharedCore.ChatType diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 2cfc782356..80f77d739e 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1,3 +1,26 @@ +# The analytics event catalogue: every event both apps can send, and the value +# vocabularies they use. The build generates the builder objects and value enums from +# this file into build/generated/analytics/commonMain (see +# build-logic/.../analytics/KotlinEmitter.kt); nothing generated is checked in. +# AnalyticsEvent.kt and Amount.kt stay hand-written. +# +# To add an event: +# 1. Add a [[domains.Events.events]] entry: `builder`, `event`, `params`, +# `properties`, and `doc` or `drift` where they apply. `event` and a `format` +# value may name a parameter as {param}; an enum parameter sends its wire value. +# `{ group = "amount" }` and `{ group = "outcome" }` expand in place. +# 2. Add a test to src/commonTest that asserts the event name and properties as +# string literals. Never read the expected strings back from the enums: the +# literal is what stops a catalogue edit from renaming an event by accident. +# 3. A new domain or enum also needs a typealias in +# kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift. +# 4. iOS gets the event only from a flipcash-shared-core-spm release: after the +# change merges, run the Publish SharedCore workflow with the next minor version. +# +# A `drift` note records how iOS differs today and is emitted as a "DRIFT:" KDoc line. +# The generator rejects unknown keys, unsent parameters and duplicate properties or +# builders; its tests are in build-logic/convention/src/test. + package = "com.flipcash.analytics" # Chat From 15413d58ee2378812fcc26de95e124d4bdbe7dbc Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 21:08:28 -0400 Subject: [PATCH 37/37] feat(analytics-events): make events.toml writable without Kotlin (#1547) --- .../AnalyticsCatalogueConventionPlugin.kt | 17 + .../analytics/AnalyticsCatalogue.kt | 5 +- .../analytics/AnalyticsEventsPage.kt | 60 ++ .../getcode/buildlogic/analytics/Catalogue.kt | 6 +- .../buildlogic/analytics/CatalogueParser.kt | 397 ++++++---- .../buildlogic/analytics/MarkdownEmitter.kt | 164 +++++ .../analytics/AnalyticsCatalogueTest.kt | 442 +++++++----- .../buildlogic/analytics/EventsPageTest.kt | 215 ++++++ build.gradle.kts | 2 + libs/analytics-events/EVENTS.md | 549 ++++++++++++++ libs/analytics-events/events.toml | 682 ++++++++++-------- 11 files changed, 1905 insertions(+), 634 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt create mode 100644 build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt create mode 100644 libs/analytics-events/EVENTS.md diff --git a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt index 6f2decaa6c..a7dec4ab57 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsCatalogueConventionPlugin.kt @@ -1,4 +1,6 @@ +import com.getcode.buildlogic.analytics.CheckAnalyticsEventsPage import com.getcode.buildlogic.analytics.GenerateAnalyticsEvents +import com.getcode.buildlogic.analytics.WriteAnalyticsEventsPage import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure @@ -21,6 +23,9 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension * every compilation, including the Kotlin/Native ones the XCFramework build links. Lint reads * source directories straight off disk, so it is made to depend on the task explicitly, as in * [KmpTestFixturesConventionPlugin]. + * + * `EVENTS.md`, the catalogue as a readable page, is checked in next to `events.toml`. + * `writeAnalyticsEventsPage` rewrites it; `checkAnalyticsEventsPage` fails when it is stale. */ class AnalyticsCatalogueConventionPlugin : Plugin { override fun apply(target: Project) { @@ -30,6 +35,18 @@ class AnalyticsCatalogueConventionPlugin : Plugin { outputDirectory.set(layout.buildDirectory.dir("generated/analytics/commonMain/kotlin")) } + val catalogueFile = layout.projectDirectory.file("events.toml") + val pageFile = layout.projectDirectory.file("EVENTS.md") + tasks.register("writeAnalyticsEventsPage") { + catalogue.set(catalogueFile) + page.set(pageFile) + } + tasks.register("checkAnalyticsEventsPage") { + catalogue.set(catalogueFile) + page.set(pageFile) + fixCommand.set("./gradlew ${path.substringBeforeLast(':')}:writeAnalyticsEventsPage") + } + tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") } .configureEach { dependsOn(generateAnalyticsEvents) } diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt index 6dda6971b7..6473259f2e 100644 --- a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogue.kt @@ -1,9 +1,12 @@ package com.getcode.buildlogic.analytics -/** Reads `events.toml` and writes the builders and value enums it describes. */ +/** Reads `events.toml` and writes the builders and value enums it describes, and `EVENTS.md`. */ internal object AnalyticsCatalogue { fun parse(text: String): Catalogue = CatalogueParser.parse(text) /** The generated sources, keyed by path relative to the source root. */ fun emit(catalogue: Catalogue): Map = KotlinEmitter.emit(catalogue) + + /** `EVENTS.md`: the catalogue as a readable page. */ + fun document(catalogue: Catalogue): String = MarkdownEmitter.emit(catalogue) } diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt new file mode 100644 index 0000000000..6e84db95f0 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/AnalyticsEventsPage.kt @@ -0,0 +1,60 @@ +package com.getcode.buildlogic.analytics + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +private fun page(catalogue: RegularFileProperty): String = try { + AnalyticsCatalogue.document(AnalyticsCatalogue.parse(catalogue.get().asFile.readText())) +} catch (malformed: CatalogueException) { + throw GradleException(malformed.message.orEmpty(), malformed) +} + +/** Writes `EVENTS.md` from `events.toml`. The page is checked in, so this writes into the module. */ +abstract class WriteAnalyticsEventsPage : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val catalogue: RegularFileProperty + + @get:OutputFile + abstract val page: RegularFileProperty + + @TaskAction + fun write() = page.get().asFile.writeText(page(catalogue)) +} + +/** Fails when the checked-in `EVENTS.md` is not what `events.toml` generates. */ +abstract class CheckAnalyticsEventsPage : DefaultTask() { + + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val catalogue: RegularFileProperty + + /** Read only if it exists: a missing page is reported, not a missing-input failure. */ + @get:Internal + abstract val page: RegularFileProperty + + /** The command the failure tells the reader to run. */ + @get:Input + abstract val fixCommand: Property + + @TaskAction + fun check() { + val file = page.get().asFile + val expected = page(catalogue) + if (!file.exists() || file.readText() != expected) { + throw GradleException( + "${file.name} is out of date with events.toml. Run `${fixCommand.get()}` and commit the result.", + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt index 8003d97aae..dd9973111f 100644 --- a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/Catalogue.kt @@ -11,13 +11,13 @@ internal data class Catalogue( val domains: List, ) -/** A value enum: a Kotlin enum whose cases carry the string sent to Mixpanel. */ +/** A value list: a Kotlin enum whose cases carry the string sent to Mixpanel. */ internal data class EnumDef(val name: String, val doc: String?, val cases: List) internal data class EnumCase(val name: String, val value: String, val drift: String?) -/** One generated `object`, e.g. `ChatEvents`. */ -internal data class Domain(val name: String, val doc: String?, val builders: List) +/** One generated `object`, e.g. `ChatEvents`, named from the [title] entries give, e.g. `Chat`. */ +internal data class Domain(val name: String, val title: String, val doc: String?, val builders: List) internal data class Builder( val name: String, diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt index ad6a99082f..9258d6bd21 100644 --- a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/CatalogueParser.kt @@ -5,12 +5,17 @@ import org.tomlj.TomlArray import org.tomlj.TomlTable /** - * Reads `events.toml` into a [Catalogue], rejecting anything the generator would otherwise have - * to guess at: unknown keys, types and groups, parameters nothing reads, templates naming a - * parameter that isn't there, and duplicates. + * Reads `events.toml` into a [Catalogue]. An entry describes the event in plain words and this + * derives the Kotlin: the builder name from the event name, each parameter from a property, and + * the parameter order from where the properties sit. * - * Keys that come from the data (enum, domain and group names) are always looked up as one-element - * paths, since tomlj reads a plain string key as a dotted path. + * Anything the generator would otherwise have to guess at is rejected: unknown keys, types, + * lists and groups, names that don't make a usable function or parameter, and duplicates. + * The people reading these messages may not know Kotlin, so each one names the event and says + * what to change in the file. + * + * Keys that come from the data (list, domain and group names) are always looked up as + * one-element paths, since tomlj reads a plain string key as a dotted path. */ internal object CatalogueParser { @@ -27,17 +32,22 @@ internal object CatalogueParser { */ private val AMOUNT_KEYS = listOf("Fiat", "Currency", "USDC", "Quarks") - private val PRIMITIVES = mapOf( - "String" to ParamType.Text, - "Double" to ParamType.Decimal, - "Long" to ParamType.Whole, - "Int" to ParamType.SmallWhole, - "Boolean" to ParamType.Flag, - "Amount" to ParamType.Amount, + /** The plain-word property types, in the order messages list them. */ + private val TYPE_WORDS = linkedMapOf( + "text" to ParamType.Text, + "yes/no" to ParamType.Flag, + "count" to ParamType.SmallWhole, + "decimal" to ParamType.Decimal, + "duration" to ParamType.Whole, ) - /** Types the module already declares by hand, which an enum must not shadow. */ - private val RESERVED_TYPES = PRIMITIVES.keys + setOf("AnalyticsEvent", "PropertyValue", "PeopleCounter") + /** A `duration` is sent in milliseconds, and its parameter says so. */ + private const val DURATION_SUFFIX = "Millis" + + /** Types the module already declares by hand, which a list must not shadow. */ + private val RESERVED_TYPES = setOf( + "String", "Double", "Long", "Int", "Boolean", "Amount", "AnalyticsEvent", "PropertyValue", "PeopleCounter", + ) private val KEYWORDS = setOf( "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", @@ -48,112 +58,125 @@ internal object CatalogueParser { private val TYPE_NAME = Regex("[A-Z][A-Za-z0-9]*") private val CASE_NAME = Regex("[A-Z][A-Z0-9_]*") private val MEMBER_NAME = Regex("[a-z][A-Za-z0-9]*") - private val PARAM = Regex("""\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([A-Za-z_][A-Za-z0-9_]*)(\?)?\s*""") + private val WORD_BREAK = Regex("[^A-Za-z0-9]+") + private val PLACEHOLDER = Regex("""\{[^{}]*}""") fun parse(text: String): Catalogue { val root = Toml.parse(text) if (root.hasErrors()) { fail(FILE, root.errors().joinToString("; ") { "line ${it.position().line()}: ${it.message}" }) } - root.requireOnly(FILE, "package", "counters", "enums", "groups", "domains") + root.requireOnly(FILE, "package", "lists", "counters", "groups", "domains", "event") val packageName = root.string("package", FILE) ?: fail(FILE, "`package` is required") - val enums = root.subtables("enums", FILE).map { (name, table) -> enumDef(name, table) } - enums.groupBy { it.name }.forEach { (name, defs) -> - if (defs.size > 1) fail("enums.$name", "declared more than once") - } - val types = PRIMITIVES + enums.associate { it.name to ParamType.Enum(it.name) } + val lists = root.subtables("lists", FILE).map { (name, table) -> list(name, table) } + val types = Types(lists.map { it.name }) val counters = root.table("counters", FILE)?.let { table -> - table.keySet().map { name -> enumCase("PeopleCounter", name, table.get(listOf(name))) } + table.keySet().map { name -> value("counters", name, table.get(listOf(name))) } }.orEmpty() val groups = root.subtables("groups", FILE).toMap() groups.forEach { (name, table) -> - val where = "groups.$name" - if (name == AMOUNT_GROUP) fail(where, "`$AMOUNT_GROUP` is built in and cannot be redefined") + val where = "group \"$name\"" + if (name == AMOUNT_GROUP) fail(where, "`$AMOUNT_GROUP` is built in, so a group can't use that name") table.requireOnly(where, "properties") } - val domains = root.subtables("domains", FILE).map { (name, table) -> domain(name, table, types, groups) } + val descriptions = root.table("domains", FILE)?.let { table -> + table.keySet().associateWith { name -> table.string(name, "domains") } + }.orEmpty() + + val entries = root.tables("event", FILE).mapIndexed { index, table -> entry(index, table, types, groups) } + val byDomain = entries.groupBy { it.first } + descriptions.keys.firstOrNull { it !in byDomain }?.let { unused -> + fail("domains", "\"$unused\" has a description, but no event has `domain = \"$unused\"`") + } - return Catalogue(packageName, enums, counters, domains) + val domains = byDomain.map { (domain, found) -> + val events = found.map { it.second } + events.groupBy { it.builder.name }.forEach { (builder, same) -> + if (same.size > 1) { + fail( + "domain \"$domain\"", + same.joinToString(" and ") { "event \"${it.eventName}\"" } + + " would all get the builder `$builder`. To fix it, add `builder = \"...\"` " + + "to all but one of them, with a short name of your own such as \"${builder}Failed\"", + ) + } + } + Domain(objectName(domain), domain, descriptions[domain], events.map { it.builder }) + } + + return Catalogue(packageName, lists, counters, domains) } - private fun enumDef(name: String, table: TomlTable): EnumDef { - val where = "enums.$name" - if (!TYPE_NAME.matches(name)) fail(where, "an enum name must be UpperCamelCase") - if (name in RESERVED_TYPES) fail(where, "`$name` is already a type in the module") - table.requireOnly(where, "doc", "cases") - val cases = table.table("cases", where) ?: fail(where, "`cases` is required") - if (cases.isEmpty) fail(where, "an enum needs at least one case") + private fun list(name: String, table: TomlTable): EnumDef { + val where = "list $name" + if (!TYPE_NAME.matches(name)) fail(where, "a list name is words run together, each starting with a capital, such as \"ChatType\"") + if (name in RESERVED_TYPES) fail(where, "`$name` is already a type in the module, so pick another list name") + table.requireOnly(where, "description", "values") + val values = table.table("values", where) ?: fail(where, "add a [lists.$name.values] table with the list's values") + if (values.isEmpty) fail(where, "a list needs at least one value") return EnumDef( name = name, - doc = table.string("doc", where), - cases = cases.keySet().map { case -> enumCase(name, case, cases.get(listOf(case))) }, + doc = table.string("description", where), + cases = values.keySet().map { case -> value(where, case, values.get(listOf(case))) }, ) } - private fun enumCase(enum: String, name: String, raw: Any?): EnumCase { - val where = "$enum.$name" - if (!CASE_NAME.matches(name)) fail(where, "a case name must be UPPER_SNAKE_CASE") - val (value, drift) = when (raw) { + private fun value(owner: String, name: String, raw: Any?): EnumCase { + val where = "$owner, value $name" + if (!CASE_NAME.matches(name)) fail(where, "a value's name is capitals and underscores, such as CHAT_GATE") + val (wire, drift) = when (raw) { is String -> raw to null is TomlTable -> { raw.requireOnly(where, "value", "drift") raw.string("value", where) to raw.string("drift", where) } - else -> fail(where, "a case is a wire string or `{ value = \"...\", drift = \"...\" }`") + else -> fail(where, "write it as $name = \"...\" or $name = { value = \"...\", drift = \"...\" }") } - if (value.isNullOrEmpty()) fail(where, "a case needs a non-empty wire string") - return EnumCase(name, value, drift) + if (wire.isNullOrEmpty()) fail(where, "give it the text Mixpanel receives, such as $name = \"${titleCase(name)}\"") + return EnumCase(name, wire, drift) } - private fun domain( - name: String, - table: TomlTable, - types: Map, - groups: Map, - ): Domain { - val where = "domains.$name" - if (!TYPE_NAME.matches(name)) fail(where, "a domain name must be UpperCamelCase") - table.requireOnly(where, "doc", "events") - val entries = table.tables("events", where) - val builders = entries.mapIndexed { index, entry -> builder(name, index, entry, types, groups) } - builders.groupBy { it.name }.forEach { (builder, found) -> - if (found.size > 1) fail("$name.$builder", "builder is declared more than once") + /** One `[[event]]`, paired with the domain it belongs to. */ + private fun entry(index: Int, table: TomlTable, types: Types, groups: Map): Pair { + val eventName = table.string("name", "event #${index + 1}") + ?: fail("event #${index + 1}", "every [[event]] needs a `name`: the event name as Mixpanel shows it") + val where = "event \"$eventName\"" + table.requireOnly(where, "domain", "name", "builder", "placeholders", "properties", "order", "description", "drift") + val domain = table.string("domain", where) + ?: fail(where, "add `domain = \"...\"`, the part of the app it belongs to, such as \"Transfer\"") + if (words(domain).isEmpty() || !objectName(domain).let(TYPE_NAME::matches)) { + fail(where, "`domain = \"$domain\"` must be words, such as \"Add Money\"") } - return Domain(name, table.string("doc", where), builders) - } - private fun builder( - domain: String, - index: Int, - table: TomlTable, - types: Map, - groups: Map, - ): Builder { - val name = table.string("builder", "$domain event #${index + 1}") - ?: fail("$domain event #${index + 1}", "`builder` is required") - val where = "$domain.$name" - if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable function name") - table.requireOnly(where, "builder", "event", "params", "properties", "doc", "drift") - - val params = table.strings("params", where).map { param(it, where, types) } - params.groupBy { it.name }.forEach { (param, found) -> - if (found.size > 1) fail(where, "parameter `$param` is declared more than once") - } - val byName = params.associateBy { it.name } - val used = mutableSetOf() + val placeholderTypes = table.table("placeholders", where)?.let { placeholders -> + placeholders.keySet().associateWith { name -> + val word = placeholders.string(name, where)!! + val type = types.resolve(word, where, "placeholder {$name}") + if (type != ParamType.Text && type !is ParamType.Enum) { + fail(where, "{$name} can only be text or a list, since it's written into the name") + } + type + } + }.orEmpty() - val eventName = table.string("event", where) ?: fail(where, "`event` is required") - val event = template(eventName, where, byName, used) + val params = Params(where) + val usedPlaceholders = mutableSetOf() + fun slots(text: String) = template(text, where, placeholderTypes, params, usedPlaceholders) + val event = slots(eventName) val properties = mutableListOf() - table.tables("properties", where).forEach { entry -> - expand(entry, where, byName, groups, used, properties, groupStack = emptyList()) + table.tables("properties", where).forEach { property -> + expand(property, where, types, groups, params, ::slots, properties, groupStack = emptyList()) } + (placeholderTypes.keys - usedPlaceholders).firstOrNull()?.let { unused -> + fail(where, "`placeholders` gives a type for {$unused}, but neither the name nor a format has {$unused}") + } + val keys = properties.flatMap { property -> when (property) { is Property.Value -> listOf(property.key) @@ -162,120 +185,226 @@ internal object CatalogueParser { } } keys.groupBy { it }.forEach { (key, found) -> - if (found.size > 1) fail(where, "property \"$key\" is sent more than once") + if (found.size > 1) { + val why = if (key in AMOUNT_KEYS) " (the amount group already sends \"$key\")" else "" + fail(where, "the property \"$key\" is sent more than once$why. Remove one of them") + } } - params.firstOrNull { it.name !in used }?.let { fail(where, "parameter `${it.name}` is never sent") } + val builderName = table.string("builder", where) ?: derivedBuilder(eventName, where) + if (!MEMBER_NAME.matches(builderName) || builderName in KEYWORDS) { + fail(where, "`builder = \"$builderName\"` must be one word, or words run together, starting lowercase, such as \"opened\"") + } - return Builder( - name = name, + val ordered = table.array("order", where)?.let { order(it, where, params.all) } ?: params.all + + val builder = Builder( + name = builderName, event = event, - params = params, + params = ordered, properties = properties, - doc = table.string("doc", where), + doc = table.string("description", where), drift = table.string("drift", where), ) + return domain to Entry(eventName, builder) } - private fun param(declaration: String, where: String, types: Map): Param { - val match = PARAM.matchEntire(declaration) - ?: fail(where, "parameter \"$declaration\" is not `name: Type` or `name: Type?`") - val (name, typeName, optional) = match.destructured - if (!MEMBER_NAME.matches(name) || name in KEYWORDS) fail(where, "`$name` is not a usable parameter name") - val type = types[typeName] ?: fail(where, "parameter `$name` has unknown type `$typeName`") - return Param(name, type, nullable = optional.isNotEmpty()) + private class Entry(val eventName: String, val builder: Builder) + + /** The event name in camelCase, without the `Domain:` prefix and the `{placeholders}`. */ + private fun derivedBuilder(eventName: String, where: String): String { + val name = camelCase(eventName.substringAfter(':').replace(PLACEHOLDER, " ")) + if (name.isEmpty() || !MEMBER_NAME.matches(name) || name in KEYWORDS) { + fail(where, "no builder name can be made from this event name, so add `builder = \"...\"`, a short name such as \"opened\"") + } + return name + } + + private fun order(order: TomlArray, where: String, params: List): List { + val names = order.toList().map { it as? String ?: fail(where, "`order` is a list of parameter names in quotes") } + val byName = params.associateBy { it.name } + val missing = params.map { it.name }.filter { it !in names } + val unknown = names.filter { it !in byName } + val twice = names.groupBy { it }.filterValues { it.size > 1 }.keys + if (missing.isNotEmpty() || unknown.isNotEmpty() || twice.isNotEmpty()) { + val problems = listOfNotNull( + missing.takeIf { it.isNotEmpty() }?.let { "missing ${it.joinToString()}" }, + unknown.takeIf { it.isNotEmpty() }?.let { "${it.joinToString()} isn't a parameter" }, + twice.takeIf { it.isNotEmpty() }?.let { "${it.joinToString()} is listed twice" }, + ) + fail( + where, + "`order` must list every parameter once: ${params.joinToString { it.name }}. " + + "Right now it's ${problems.joinToString("; ")}", + ) + } + return names.map(byName::getValue) } private fun expand( entry: TomlTable, where: String, - params: Map, + types: Types, groups: Map, - used: MutableSet, + params: Params, + template: (String) -> Template, into: MutableList, groupStack: List, ) { val group = entry.string("group", where) if (group != null) { - entry.requireOnly(where, "group") + entry.requireOnly(where, "group", "optional") + val optional = entry.boolean("optional", where) when { group == AMOUNT_GROUP -> { - val param = params[AMOUNT_GROUP] - ?: fail(where, "the `amount` group reads a parameter `amount: Amount`, which isn't declared") - if (param.type != ParamType.Amount) fail(where, "the `amount` group needs `amount` to be an Amount") - used += param.name + val param = params.add(Param(AMOUNT_GROUP, ParamType.Amount, optional), "the amount group") into += Property.AmountBlock(param) } - group in groupStack -> fail(where, "group `$group` includes itself") + optional -> fail(where, "only the amount group can be `optional`; mark the properties inside group \"$group\" instead") + group in groupStack -> fail(where, "group \"$group\" includes itself") else -> { - val table = groups[group] ?: fail(where, "unknown group `$group`") - table.tables("properties", "groups.$group").forEach { nested -> - expand(nested, where, params, groups, used, into, groupStack + group) + val table = groups[group] + ?: fail(where, "there's no group \"$group\". Groups: ${(groups.keys + AMOUNT_GROUP).joinToString()}") + table.tables("properties", "group \"$group\"").forEach { nested -> + expand(nested, where, types, groups, params, template, into, groupStack + group) } } } return } - entry.requireOnly(where, "key", "value", "format") - val key = entry.string("key", where) ?: fail(where, "a property needs a `key`, or a `group`") - val value = entry.string("value", where) + entry.requireOnly(where, "name", "type", "optional", "param", "format") + val key = entry.string("name", where) + ?: fail(where, "every property needs a `name`, the key Mixpanel shows, or a `group`") val format = entry.string("format", where) - into += when { - value != null && format != null -> fail(where, "property \"$key\" has both `value` and `format`") - value != null -> { - val param = params[value] ?: fail(where, "property \"$key\" reads `$value`, which isn't a parameter") - if (param.type == ParamType.Amount) { - fail(where, "property \"$key\": an Amount is sent through the amount group, not as a property") - } - used += param.name - Property.Value(key, param) + if (format != null) { + if (entry.keySet().any { it in setOf("type", "optional", "param") }) { + fail(where, "the property \"$key\" has a `format`, so it's always text and can't have `type`, `optional` or `param`") } - format != null -> Property.Format(key, template(format, where, params, used)) - else -> fail(where, "property \"$key\" needs a `value` or a `format`") + into += Property.Format(key, template(format)) + return } + + val word = entry.string("type", where) + ?: fail(where, "the property \"$key\" needs a `type`: ${TYPE_WORDS.keys.joinToString()}, or a list name") + val type = types.resolve(word, where, "the property \"$key\"") + val name = entry.string("param", where) + ?: (camelCase(key) + if (type == ParamType.Whole) DURATION_SUFFIX else "") + val param = params.add(Param(name, type, entry.boolean("optional", where)), "the property \"$key\"") + into += Property.Value(key, param) } - /** Splits `Token Purchase With {method}` into literal text and parameter slots. */ - private fun template(text: String, where: String, params: Map, used: MutableSet): Template { + /** + * Splits `Token Purchase With {method}` into literal text and parameter slots. A placeholder + * is a text parameter unless the entry's `placeholders` gives it a list, and one that appears + * twice reads the same parameter. + */ + private fun template( + text: String, + where: String, + placeholderTypes: Map, + params: Params, + used: MutableSet, + ): Template { val parts = mutableListOf() var index = 0 while (index < text.length) { val open = text.indexOf('{', index) val close = text.indexOf('}', index) if (open < 0) { - if (close >= 0) fail(where, "\"$text\" has a `}` with no `{`") + if (close >= 0) fail(where, "\"$text\" has a `}` with no `{` before it") parts += Template.Part.Literal(text.substring(index)) break } - if (close in index until open) fail(where, "\"$text\" has a `}` with no `{`") + if (close in index until open) fail(where, "\"$text\" has a `}` with no `{` before it") val end = text.indexOf('}', open) - if (end < 0) fail(where, "\"$text\" has a `{` with no `}`") + if (end < 0) fail(where, "\"$text\" has a `{` with no `}` after it") if (open > index) parts += Template.Part.Literal(text.substring(index, open)) val slot = text.substring(open + 1, end) - val param = params[slot] ?: fail(where, "\"$text\" names {$slot}, which isn't a parameter") - if (param.nullable) fail(where, "\"$text\" names {$slot}, which is optional and would print \"null\"") - if (param.type != ParamType.Text && param.type !is ParamType.Enum) { - fail(where, "\"$text\" names {$slot}; only String and enum parameters can appear in text") + if (!MEMBER_NAME.matches(slot) || slot in KEYWORDS) { + fail(where, "{$slot} in \"$text\" must be one word, or words run together, starting lowercase, such as {method}") + } + val param = if (slot in used) { + params.find(slot)!! + } else { + params.add(Param(slot, placeholderTypes[slot] ?: ParamType.Text, false), "{$slot}") } - used += param.name + used += slot parts += Template.Part.Slot(param) index = end + 1 } return Template(parts) } + /** Collects an entry's parameters in the order its name and properties produce them. */ + private class Params(private val where: String) { + val all = mutableListOf() + + fun find(name: String) = all.firstOrNull { it.name == name } + + fun add(param: Param, source: String): Param { + if (!MEMBER_NAME.matches(param.name) || param.name in KEYWORDS) { + fail(where, "$source can't be passed as `${param.name}`, so add `param = \"...\"`, a name such as \"choice\"") + } + find(param.name)?.let { + fail(where, "two properties would both be passed as `${param.name}`, so add `param = \"...\"` to one of them") + } + all += param + return param + } + } + + /** The type words and the catalogue's lists. */ + private class Types(private val lists: List) { + fun resolve(word: String, where: String, subject: String): ParamType { + TYPE_WORDS[word]?.let { return it } + if (word in lists) return ParamType.Enum(word) + val known = "The types are ${TYPE_WORDS.keys.joinToString()}, or one of the lists: " + + lists.joinToString().ifEmpty { "(none yet)" } + if (word.firstOrNull()?.isUpperCase() == true) { + fail(where, "$subject uses the list \"$word\", which doesn't exist. Add it as [lists.$word], or check the spelling. $known") + } + fail(where, "$subject has the type \"$word\", which isn't a type. $known") + } + } + + private fun words(text: String) = text.split(WORD_BREAK).filter { it.isNotEmpty() } + + /** `CHAT_GATE` → `Chat Gate`, as an example wire string. */ + private fun titleCase(name: String) = + words(name).joinToString(" ") { it.lowercase().replaceFirstChar(Char::uppercaseChar) } + + /** `Owner Public Key` → `ownerPublicKey`, `URL` → `url`. */ + private fun camelCase(text: String) = words(text).mapIndexed { index, word -> + val lower = word.lowercase() + if (index == 0) lower else lower.replaceFirstChar(Char::uppercaseChar) + }.joinToString("") + + /** `Add Money` → `AddMoneyEvents`; a domain already run together keeps its capitals. */ + private fun objectName(domain: String) = + words(domain).joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } + "Events" + private fun TomlTable.requireOnly(where: String, vararg allowed: String) { val unknown = keySet() - allowed.toSet() if (unknown.isNotEmpty()) { - fail(where, "unknown key(s) ${unknown.joinToString { "`$it`" }}; expected ${allowed.joinToString { "`$it`" }}") + fail( + where, + "${unknown.joinToString { "`$it`" }} isn't something this entry can have. " + + "Check the spelling; it can have ${allowed.joinToString { "`$it`" }}", + ) } } private fun TomlTable.string(key: String, where: String): String? = when (val value = get(listOf(key))) { null -> null is String -> value - else -> fail(where, "`$key` must be a string") + else -> fail(where, "`$key` must be text in quotes") + } + + private fun TomlTable.boolean(key: String, where: String): Boolean = when (val value = get(listOf(key))) { + null -> false + is Boolean -> value + else -> fail(where, "`$key` must be true or false, without quotes") } private fun TomlTable.table(key: String, where: String): TomlTable? = when (val value = get(listOf(key))) { @@ -294,15 +423,11 @@ internal object CatalogueParser { private fun TomlTable.array(key: String, where: String): TomlArray? = when (val value = get(listOf(key))) { null -> null is TomlArray -> value - else -> fail(where, "`$key` must be an array") + else -> fail(where, "`$key` must be a list in square brackets") } private fun TomlTable.tables(key: String, where: String): List = - array(key, where)?.toList()?.map { it as? TomlTable ?: fail(where, "every entry in `$key` must be a table") } - .orEmpty() - - private fun TomlTable.strings(key: String, where: String): List = - array(key, where)?.toList()?.map { it as? String ?: fail(where, "every entry in `$key` must be a string") } + array(key, where)?.toList()?.map { it as? TomlTable ?: fail(where, "every entry in `$key` must be { ... }") } .orEmpty() private fun fail(where: String, message: String): Nothing = diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt new file mode 100644 index 0000000000..5b21cadca3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/analytics/MarkdownEmitter.kt @@ -0,0 +1,164 @@ +package com.getcode.buildlogic.analytics + +/** + * Writes a [Catalogue] as `EVENTS.md`: the same catalogue as `events.toml`, for readers who + * don't read TOML. Types are named with the catalogue's own words, so a reader can copy one + * back into an entry. + */ +internal object MarkdownEmitter { + + /** What `amount(...)` in the hand-written `Amount.kt` sends, in its order. */ + private val AMOUNT_ROWS = listOf( + Triple("Fiat", "decimal", false), + Triple("Currency", "text", false), + Triple("USDC", "decimal", false), + Triple("Quarks", "count", false), + Triple("Exchange Rate", "decimal", true), + Triple("Mint", "text", true), + ) + + fun emit(catalogue: Catalogue): String = buildString { + appendLine( + """ + # Analytics events + + + + Every event both apps can send to Mixpanel, and the properties each one carries. To add + or change an event, edit [events.toml](events.toml) in a pull request. + + Types: **text** is words, **yes/no** is true or false, **count** is a whole number, + **decimal** is a number with a fraction, **duration** is milliseconds, and a **list** + is one value from the [Lists](#lists) below. An optional property is left out when the + app has no value for it. + """.trimIndent(), + ) + + catalogue.domains.forEach { domain -> + appendLine() + appendLine("## ${domain.title}") + domain.doc?.let { appendLine(); appendLine(it) } + // Two builders may send one name; they share a heading so the name has one anchor. + domain.builders.groupBy { name(it.event) }.forEach { (name, forms) -> event(name, forms) } + } + + val usesAmount = catalogue.domains.any { domain -> + domain.builders.any { builder -> builder.properties.any { it is Property.AmountBlock } } + } + if (usesAmount) { + appendLine() + appendLine("## Groups") + appendLine() + appendLine("### amount") + appendLine() + appendLine("Sent by events that carry an amount of money. Defined in `Amount.kt`.") + appendLine() + appendLine("| Property | Type | Optional |") + appendLine("|---|---|---|") + AMOUNT_ROWS.forEach { (key, type, optional) -> appendLine(tableRow(key, type, yesNo(optional))) } + } + + if (catalogue.enums.isNotEmpty()) { + appendLine() + appendLine("## Lists") + catalogue.enums.forEach { list -> + appendLine() + appendLine("### ${list.name}") + list.doc?.let { appendLine(); appendLine(it) } + appendLine() + appendLine("| Value sent | Name in code | Drift |") + appendLine("|---|---|---|") + list.cases.forEach { case -> + appendLine(tableRow(cell(case.value), "`${case.name}`", cell(case.drift.orEmpty()))) + } + } + } + + if (catalogue.counters.isNotEmpty()) { + appendLine() + appendLine("## People counters") + appendLine() + appendLine("Cumulative per-user counters, stored as Mixpanel people properties.") + appendLine() + appendLine("| Counter | Name in code |") + appendLine("|---|---|") + catalogue.counters.forEach { appendLine(tableRow(cell(it.value), "`${it.name}`")) } + } + } + + private fun StringBuilder.event(name: String, forms: List) { + appendLine() + appendLine("### $name") + if (forms.size > 1) { + appendLine() + appendLine("Sent in ${forms.size} forms.") + } + forms.forEach { form(it) } + } + + private fun StringBuilder.form(builder: Builder) { + builder.doc?.let { appendLine(); appendLine(it) } + + val listSlots = builder.event.parts + .filterIsInstance() + .filter { it.param.type is ParamType.Enum } + listSlots.forEach { slot -> + val list = (slot.param.type as ParamType.Enum).name + appendLine() + appendLine("`{${slot.param.name}}` in the name is one of [$list](#${anchor(list)}).") + } + + appendLine() + if (builder.properties.isEmpty()) { + appendLine("No properties.") + } else { + appendLine("| Property | Type | Optional | Values |") + appendLine("|---|---|---|---|") + builder.properties.forEach { appendLine(row(it)) } + } + + builder.drift?.let { appendLine(); appendLine("> **Drift:** $it") } + } + + private fun row(property: Property): String = when (property) { + is Property.AmountBlock -> + tableRow("amount", "group", yesNo(property.param.nullable), "[amount](#amount)") + is Property.Format -> + tableRow(cell(property.key), "text", "no", "`${cell(name(property.template))}`") + is Property.Value -> { + val param = property.param + val values = (param.type as? ParamType.Enum)?.let { "[${it.name}](#${anchor(it.name)})" }.orEmpty() + tableRow(cell(property.key), word(param.type), yesNo(param.nullable), values) + } + } + + private fun word(type: ParamType): String = when (type) { + ParamType.Text -> "text" + ParamType.Flag -> "yes/no" + ParamType.SmallWhole -> "count" + ParamType.Decimal -> "decimal" + ParamType.Whole -> "duration" + is ParamType.Enum -> "list" + ParamType.Amount -> error("the parser sends an Amount only through the amount group") + } + + /** The template as the catalogue writes it, with `{placeholder}` slots. */ + private fun name(template: Template) = template.parts.joinToString("") { part -> + when (part) { + is Template.Part.Literal -> part.text + is Template.Part.Slot -> "{${part.param.name}}" + } + } + + /** An empty cell is written `|` alone, without the padding a filled one gets. */ + private fun tableRow(vararg cells: String) = + "|" + cells.joinToString("") { if (it.isEmpty()) " |" else " $it |" } + + private fun yesNo(value: Boolean) = if (value) "yes" else "no" + + /** GitHub's heading anchor for a list name, which is a plain identifier. */ + private fun anchor(name: String) = name.lowercase() + + private fun cell(text: String) = text.replace("|", "\\|").replace("\n", " ") +} diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt index 9fa93cda7d..02f2d83a5c 100644 --- a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/AnalyticsCatalogueTest.kt @@ -14,12 +14,12 @@ class AnalyticsCatalogueTest { assertFailsWith { generate(toml) }.message.orEmpty() @Test - fun valueEnumsKeepCaseOrderAndCarryDriftAsKDoc() { + fun listsKeepValueOrderAndCarryDescriptionAndDriftAsKDoc() { val source = generate( """ - [enums.Method] - doc = "How the user paid." - [enums.Method.cases] + [lists.Method] + description = "How the user paid." + [lists.Method.values] ZULU = "Zulu" ALPHA = { value = "Alpha", drift = "iOS has no Alpha." } """.trimIndent(), @@ -45,23 +45,22 @@ class AnalyticsCatalogueTest { } @Test - fun eventMapsEachParameterTypeToItsPropertyCall() { + fun eachTypeWordBecomesAParameterAndItsPropertyCall() { val source = generate( """ - [enums.Kind.cases] + [lists.Kind.values] ONE = "One" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Kind", "label: String", "rate: Double", "millis: Long", "tier: Int", "done: Boolean"] + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [ - { key = "Kind", value = "kind" }, - { key = "Label", value = "label" }, - { key = "Rate", value = "rate" }, - { key = "Time", value = "millis" }, - { key = "Tier", value = "tier" }, - { key = "Done", value = "done" }, + { name = "Kind", type = "Kind" }, + { name = "Label", type = "text" }, + { name = "Rate", type = "decimal" }, + { name = "Time", type = "duration" }, + { name = "Tier", type = "count" }, + { name = "Done", type = "yes/no" }, ] """.trimIndent(), ).getValue("com/example/events/ThingEvents.kt") @@ -77,12 +76,12 @@ class AnalyticsCatalogueTest { // Generated from events.toml -- do not edit. object ThingEvents { - fun happened(kind: Kind, label: String, rate: Double, millis: Long, tier: Int, done: Boolean): AnalyticsEvent = + fun thingHappened(kind: Kind, label: String, rate: Double, timeMillis: Long, tier: Int, done: Boolean): AnalyticsEvent = event("Thing Happened") { text("Kind", kind.value) text("Label", label) number("Rate", rate) - number("Time", millis) + number("Time", timeMillis) number("Tier", tier.toDouble()) flag("Done", done) } @@ -97,22 +96,21 @@ class AnalyticsCatalogueTest { fun optionalPropertiesPassNullThroughSoTheBuilderOmitsThem() { val source = generate( """ - [enums.Kind.cases] + [lists.Kind.values] ONE = "One" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Kind?", "error: String?", "tier: Int?"] + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [ - { key = "Kind", value = "kind" }, - { key = "Error", value = "error" }, - { key = "Tier", value = "tier" }, + { name = "Kind", type = "Kind", optional = true }, + { name = "Error", type = "text", optional = true }, + { name = "Tier", type = "count", optional = true }, ] """.trimIndent(), ).getValue("com/example/events/ThingEvents.kt") - assertContains(source, "fun happened(kind: Kind?, error: String?, tier: Int?): AnalyticsEvent =") + assertContains(source, "fun thingHappened(kind: Kind?, error: String?, tier: Int?): AnalyticsEvent =") assertContains(source, "text(\"Kind\", kind?.value)") assertContains(source, "text(\"Error\", error)") assertContains(source, "number(\"Tier\", tier?.toDouble())") @@ -122,35 +120,95 @@ class AnalyticsCatalogueTest { fun anEventWithNoPropertiesHasNoBlock() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "started" - event = "Thing Started" + [[event]] + domain = "Thing" + name = "Thing Started" """.trimIndent(), ).getValue("com/example/events/ThingEvents.kt") - assertContains(source, " fun started(): AnalyticsEvent = event(\"Thing Started\")\n") + assertContains(source, " fun thingStarted(): AnalyticsEvent = event(\"Thing Started\")\n") + } + + @Test + fun theBuilderNameDropsTheDomainPrefixAndTheDomainNamesTheObject() { + val files = generate( + """ + [[event]] + domain = "Add Money" + name = "Add Money: Method Selected" + """.trimIndent(), + ) + + val source = files.getValue("com/example/events/AddMoneyEvents.kt") + assertContains(source, "object AddMoneyEvents {") + assertContains(source, "fun methodSelected(): AnalyticsEvent = event(\"Add Money: Method Selected\")") + } + + @Test + fun theParameterNameIsTheKeyInCamelCase() { + val source = generate( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [ + { name = "URL", type = "text" }, + { name = "Call Site", type = "text" }, + ] + """.trimIndent(), + ).getValue("com/example/events/ThingEvents.kt") + + assertContains(source, "fun thingHappened(url: String, callSite: String): AnalyticsEvent =") + assertContains(source, "text(\"Call Site\", callSite)") + } + + @Test + fun builderParamAndOrderOverrideWhatIsDerived() { + val source = generate( + """ + [[event]] + domain = "Account" + name = "Create Account Payment" + builder = "payment" + order = ["owner", "price"] + properties = [ + { name = "Fiat", type = "decimal", param = "price" }, + { name = "Owner Public Key", type = "text", param = "owner" }, + ] + """.trimIndent(), + ).getValue("com/example/events/AccountEvents.kt") + + assertContains( + source, + """ + fun payment(owner: String, price: Double): AnalyticsEvent = + event("Create Account Payment") { + number("Fiat", price) + text("Owner Public Key", owner) + } + """.trimIndent().prependIndent(" "), + ) } @Test fun groupsExpandInPlaceAndTheAmountGroupCallsTheHandWrittenHelper() { val source = generate( """ - [enums.State.cases] + [lists.State.values] SUCCESS = "Success" [groups.outcome] properties = [ - { key = "State", value = "state" }, - { group = "amount" }, - { key = "Error", value = "error" }, + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, ] - [[domains.ThingEvents.events]] - builder = "grabbed" - event = "Grabbed" - params = ["state: State", "amount: Amount?", "millis: Long?", "error: String?"] + [[event]] + domain = "Thing" + name = "Grabbed" properties = [ - { key = "Time", value = "millis" }, + { name = "Grab Time", type = "duration", optional = true }, { group = "outcome" }, ] """.trimIndent(), @@ -161,9 +219,9 @@ class AnalyticsCatalogueTest { assertContains( source, """ - fun grabbed(state: State, amount: Amount?, millis: Long?, error: String?): AnalyticsEvent = + fun grabbed(grabTimeMillis: Long?, state: State, amount: Amount?, error: String?): AnalyticsEvent = event("Grabbed") { - number("Time", millis) + number("Grab Time", grabTimeMillis) text("State", state.value) amount(amount) text("Error", error) @@ -176,61 +234,51 @@ class AnalyticsCatalogueTest { fun aPropertyMayRepeatOneTheAmountSendsOnlyWhenItHasIt() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "sell" - event = "Token Sell" - params = ["mint: String", "amount: Amount"] - properties = [{ key = "Mint", value = "mint" }, { group = "amount" }] + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Mint", type = "text" }, { group = "amount" }] """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/SwapEvents.kt") + assertContains(source, "fun tokenSell(mint: String, amount: Amount): AnalyticsEvent =") assertContains(source, "text(\"Mint\", mint)\n amount(amount)") } @Test - fun rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { - val message = rejected( - """ - [[domains.ThingEvents.events]] - builder = "sell" - event = "Token Sell" - params = ["fiat: Double", "amount: Amount"] - properties = [{ key = "Fiat", value = "fiat" }, { group = "amount" }] - """.trimIndent(), - ) - assertContains(message, "ThingEvents.sell") - assertContains(message, "Fiat") - } - - @Test - fun nameTemplatesInterpolateEnumWireValues() { + fun namePlaceholdersAreTextUnlessTypedAsAList() { val source = generate( """ - [enums.Method.cases] + [lists.Method.values] RESERVES = "Reserves" - [[domains.ThingEvents.events]] + [[event]] + domain = "Swap" + name = "Token Purchase With {method} From {place}" builder = "purchase" - event = "Token Purchase With {method}" - params = ["method: Method"] + placeholders = { method = "Method" } """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/SwapEvents.kt") - assertContains(source, "fun purchase(method: Method): AnalyticsEvent = event(\"Token Purchase With \${method.value}\")") + assertContains( + source, + "fun purchase(method: Method, place: String): AnalyticsEvent = " + + "event(\"Token Purchase With \${method.value} From \${place}\")", + ) } @Test fun fixedFormatValuesInterpolateAndEscapeLiterals() { val source = generate( """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - params = ["url: String"] - properties = [{ key = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Error", format = "Failed to parse ${'$'}deeplink \"=>\" {url}" }] """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/DeeplinkEvents.kt") + assertContains(source, "fun parse(url: String): AnalyticsEvent =") assertContains(source, """text("Error", "Failed to parse \${'$'}deeplink \"=>\" ${'$'}{url}")""") } @@ -238,33 +286,34 @@ class AnalyticsCatalogueTest { fun twoBuildersMaySendOneEventName() { val source = generate( """ - [[domains.ThingEvents.events]] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" builder = "parsed" - event = "Deeplink: Parse" - params = ["type: String"] - properties = [{ key = "Type", value = "type" }] + properties = [{ name = "Type", type = "text" }] - [[domains.ThingEvents.events]] + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" builder = "parseFailed" - event = "Deeplink: Parse" """.trimIndent(), - ).getValue("com/example/events/ThingEvents.kt") + ).getValue("com/example/events/DeeplinkEvents.kt") assertContains(source, "fun parsed(type: String): AnalyticsEvent =") assertContains(source, "fun parseFailed(): AnalyticsEvent = event(\"Deeplink: Parse\")") } @Test - fun docAndDriftBecomeKDocWrappedAtTheLineLimit() { + fun descriptionAndDriftBecomeKDocWrappedAtTheLineLimit() { val source = generate( """ - [domains.ThingEvents] - doc = "Things." + [domains] + Thing = "Things." - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - doc = "The user had no display name before this submission." + [[event]] + domain = "Thing" + name = "Thing Happened" + description = "The user had no display name before this submission." drift = "iOS sends Exchange Rate and no USDC, and fires on the request rather than after connecting to the wallet." """.trimIndent(), ).getValue("com/example/events/ThingEvents.kt") @@ -280,7 +329,7 @@ class AnalyticsCatalogueTest { * DRIFT: iOS sends Exchange Rate and no USDC, and fires on the request rather than after * connecting to the wallet. */ - fun happened(): AnalyticsEvent = event("Thing Happened") + fun thingHappened(): AnalyticsEvent = event("Thing Happened") """.trimIndent(), ) } @@ -312,180 +361,221 @@ class AnalyticsCatalogueTest { assertEquals(emptyMap(), generate("")) } - // Rejections. Each message names the entry at fault. + // Rejections. Each message names the event at fault and says what to change. @Test fun rejectsTomlSyntaxErrors() { - assertContains(rejected("[enums.Method\n"), "events.toml") + assertContains(rejected("[lists.Method\n"), "events.toml") } @Test - fun rejectsAnUnknownParameterType() { + fun rejectsAnUnknownTypeWordAndListsTheWords() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["kind: Knid"] - properties = [{ key = "Kind", value = "kind" }] + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "strng" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "Knid") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "strng") + assertContains(message, "text, yes/no, count, decimal, duration") } @Test - fun rejectsADuplicateBuilderInADomain() { + fun rejectsAnUnknownListAndSaysWhereListsGo() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [lists.ChatType.values] + TIP = "Tip" - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened Again" + [[event]] + domain = "Chat" + name = "Sent Message" + properties = [{ name = "Chat Type", type = "ChatTyp" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "more than once") + assertContains(message, "\"Sent Message\"") + assertContains(message, "ChatTyp") + assertContains(message, "[lists.ChatTyp]") + assertContains(message, "ChatType") } @Test - fun rejectsATemplateNamingAMissingParameter() { + fun rejectsTwoEventsThatWouldGetTheSameBuilderName() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "purchase" - event = "Token Purchase With {method}" + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + properties = [{ name = "Type", type = "text" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.purchase") - assertContains(message, "{method}") + assertContains(message, "\"Deeplink: Parse\"") + assertContains(message, "`parse`") + assertContains(message, "add `builder") } @Test - fun rejectsAFormatNamingAMissingParameter() { + fun rejectsANameThatGivesNoBuilderName() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - properties = [{ key = "Error", format = "Failed => {url}" }] + [lists.Step.values] + ONE = "One" + + [[event]] + domain = "Onramp" + name = "Onramp: {step}" + placeholders = { step = "Step" } + """.trimIndent(), + ) + assertContains(message, "\"Onramp: {step}\"") + assertContains(message, "add `builder") + } + + @Test + fun rejectsTwoPropertiesThatWouldGetTheSameParameterName() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Call Site", type = "text" }, { name = "Call-Site", type = "text" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.parseFailed") - assertContains(message, "{url}") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "`callSite`") + assertContains(message, "add `param") } @Test - fun rejectsAnEnumCaseWithNoWireString() { - val empty = rejected("[enums.Method.cases]\nRESERVES = \"\"") - assertContains(empty, "Method.RESERVES") + fun rejectsAnOrderThatDoesNotNameEveryParameter() { + val message = rejected( + """ + [[event]] + domain = "Thing" + name = "Thing Happened" + order = ["label", "labl"] + properties = [{ name = "Label", type = "text" }, { name = "Tier", type = "count" }] + """.trimIndent(), + ) + assertContains(message, "\"Thing Happened\"") + assertContains(message, "missing tier") + assertContains(message, "labl") + } - val missing = rejected("[enums.Method.cases]\nRESERVES = { drift = \"iOS has none.\" }") - assertContains(missing, "Method.RESERVES") + @Test + fun rejectsAListValueWithNoWireString() { + val empty = rejected("[lists.Method.values]\nRESERVES = \"\"") + assertContains(empty, "Method") + assertContains(empty, "RESERVES") + assertContains(empty, "the text Mixpanel receives") + + val missing = rejected("[lists.Method.values]\nRESERVES = { drift = \"iOS has none.\" }") + assertContains(missing, "RESERVES") } @Test - fun rejectsAPropertyReadingAnUndeclaredParameter() { + fun rejectsAPlaceholderThatIsNotTextOrAList() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - properties = [{ key = "Label", value = "label" }] + [[event]] + domain = "Thing" + name = "Thing {tier}" + builder = "thing" + placeholders = { tier = "count" } """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "label") + assertContains(message, "\"Thing {tier}\"") + assertContains(message, "{tier}") } @Test - fun rejectsAParameterNoPropertyReads() { + fun rejectsAPlaceholderTypeNothingUses() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["label: String"] + [[event]] + domain = "Thing" + name = "Thing Happened" + placeholders = { method = "text" } """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "label") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "method") } @Test fun rejectsAnUnknownKeySoATypoIsNotSilentlyIgnored() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [[event]] + domain = "Thing" + name = "Thing Happened" drfit = "iOS sends nothing." """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + assertContains(message, "\"Thing Happened\"") assertContains(message, "drfit") } + @Test + fun rejectsAnEventWithNoDomain() { + val message = rejected("[[event]]\nname = \"Thing Happened\"") + assertContains(message, "\"Thing Happened\"") + assertContains(message, "domain") + } + @Test fun rejectsAnUnknownGroup() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" + [[event]] + domain = "Thing" + name = "Thing Happened" properties = [{ group = "outcom" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + assertContains(message, "\"Thing Happened\"") assertContains(message, "outcom") } @Test - fun rejectsAnAmountSentAsAPlainProperty() { + fun rejectsAPropertyRepeatingOneTheAmountAlwaysSends() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["amount: Amount"] - properties = [{ key = "Amount", value = "amount" }] + [[event]] + domain = "Swap" + name = "Token Sell" + properties = [{ name = "Fiat", type = "decimal" }, { group = "amount" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") - assertContains(message, "amount group") + assertContains(message, "\"Token Sell\"") + assertContains(message, "Fiat") } @Test fun rejectsTheSameKeyTwiceInOneEvent() { val message = rejected( """ - [[domains.ThingEvents.events]] - builder = "happened" - event = "Thing Happened" - params = ["a: String", "b: String"] - properties = [{ key = "Label", value = "a" }, { key = "Label", value = "b" }] + [[event]] + domain = "Thing" + name = "Thing Happened" + properties = [{ name = "Label", type = "text" }, { name = "Label", type = "text", param = "other" }] """.trimIndent(), ) - assertContains(message, "ThingEvents.happened") + assertContains(message, "\"Thing Happened\"") assertContains(message, "Label") } @Test - fun rejectsANullableParameterInATemplate() { - val message = rejected( - """ - [[domains.ThingEvents.events]] - builder = "parseFailed" - event = "Deeplink: Parse" - params = ["url: String?"] - properties = [{ key = "Error", format = "Failed => {url}" }] - """.trimIndent(), - ) - assertContains(message, "ThingEvents.parseFailed") - assertContains(message, "url") + fun rejectsADomainDescriptionNoEventUses() { + val message = rejected("[domains]\nWalet = \"The Phantom flow.\"") + assertContains(message, "Walet") } private fun assertContains(actual: String, expected: String) = diff --git a/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt new file mode 100644 index 0000000000..e3cd8996bd --- /dev/null +++ b/build-logic/convention/src/test/kotlin/com/getcode/buildlogic/analytics/EventsPageTest.kt @@ -0,0 +1,215 @@ +package com.getcode.buildlogic.analytics + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EventsPageTest { + + private fun page(toml: String): String = + AnalyticsCatalogue.document(AnalyticsCatalogue.parse("package = \"com.example\"\n$toml")) + + @Test + fun thePageHasADomainSectionPerEventTablesListsGroupsAndCounters() { + val page = page( + """ + [lists.Method] + description = "How the user paid." + [lists.Method.values] + CARD = "Card" + BANK = { value = "Bank", drift = "iOS has no Bank." } + + [lists.State.values] + SUCCESS = "Success" + + [groups.outcome] + properties = [ + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, + ] + + [counters] + TIPS = "Tips Received" + + [domains] + Thing = "Things the user does." + + [[event]] + domain = "Thing" + name = "Thing: Bought With {method}" + placeholders = { method = "Method" } + description = "The user bought a thing." + drift = "iOS sends this without State." + properties = [ + { name = "Label", type = "text" }, + { name = "Tier", type = "count", optional = true }, + { name = "Rate", type = "decimal" }, + { name = "Time", type = "duration" }, + { name = "Done", type = "yes/no" }, + { group = "outcome" }, + ] + + [[event]] + domain = "Thing" + name = "Thing: Parse" + properties = [{ name = "Error", format = "Failed to parse => {url}" }] + + [[event]] + domain = "Thing" + name = "Thing: Started" + """.trimIndent(), + ) + + assertEquals( + """ + # Analytics events + + + + Every event both apps can send to Mixpanel, and the properties each one carries. To add + or change an event, edit [events.toml](events.toml) in a pull request. + + Types: **text** is words, **yes/no** is true or false, **count** is a whole number, + **decimal** is a number with a fraction, **duration** is milliseconds, and a **list** + is one value from the [Lists](#lists) below. An optional property is left out when the + app has no value for it. + + ## Thing + + Things the user does. + + ### Thing: Bought With {method} + + The user bought a thing. + + `{method}` in the name is one of [Method](#method). + + | Property | Type | Optional | Values | + |---|---|---|---| + | Label | text | no | | + | Tier | count | yes | | + | Rate | decimal | no | | + | Time | duration | no | | + | Done | yes/no | no | | + | State | list | no | [State](#state) | + | amount | group | yes | [amount](#amount) | + | Error | text | yes | | + + > **Drift:** iOS sends this without State. + + ### Thing: Parse + + | Property | Type | Optional | Values | + |---|---|---|---| + | Error | text | no | `Failed to parse => {url}` | + + ### Thing: Started + + No properties. + + ## Groups + + ### amount + + Sent by events that carry an amount of money. Defined in `Amount.kt`. + + | Property | Type | Optional | + |---|---|---| + | Fiat | decimal | no | + | Currency | text | no | + | USDC | decimal | no | + | Quarks | count | no | + | Exchange Rate | decimal | yes | + | Mint | text | yes | + + ## Lists + + ### Method + + How the user paid. + + | Value sent | Name in code | Drift | + |---|---|---| + | Card | `CARD` | | + | Bank | `BANK` | iOS has no Bank. | + + ### State + + | Value sent | Name in code | Drift | + |---|---|---| + | Success | `SUCCESS` | | + + ## People counters + + Cumulative per-user counters, stored as Mixpanel people properties. + + | Counter | Name in code | + |---|---| + | Tips Received | `TIPS` | + + """.trimIndent(), + page, + ) + } + + @Test + fun pipesInCellsAreEscapedSoTheTableHoldsItsShape() { + val page = page( + """ + [[event]] + domain = "Thing" + name = "Thing: Parse" + properties = [{ name = "Error", format = "a | b {url}" }] + """.trimIndent(), + ) + + assertTrue("| Error | text | no | `a \\| b {url}` |" in page, page) + } + + @Test + fun twoBuildersSendingOneNameShareOneHeadingSoItHasOneAnchor() { + val page = page( + """ + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parsed" + description = "The link parsed." + properties = [{ name = "Type", type = "text" }] + + [[event]] + domain = "Deeplink" + name = "Deeplink: Parse" + builder = "parseFailed" + description = "The link didn't parse." + """.trimIndent(), + ) + + assertTrue( + """ + ### Deeplink: Parse + + Sent in 2 forms. + + The link parsed. + + | Property | Type | Optional | Values | + |---|---|---|---| + | Type | text | no | | + + The link didn't parse. + + No properties. + + """.trimIndent() in page, + page, + ) + } + + @Test + fun anEmptyCatalogueStillWritesTheIntroduction() { + assertTrue(page("").startsWith("# Analytics events\n")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9918bba0f6..93b4573fc8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,4 +90,6 @@ tasks.register("flipcashTestDebug") { dependsOn(kmpUnitTestModules.map { "$it:testAndroidHostTest" }) // build-logic's own tests, which cover the analytics catalogue generator. dependsOn(gradle.includedBuild("build-logic").task(":convention:test")) + // EVENTS.md is generated from events.toml but checked in; fail when someone edits one alone. + dependsOn(":libs:analytics-events:checkAnalyticsEventsPage") } diff --git a/libs/analytics-events/EVENTS.md b/libs/analytics-events/EVENTS.md new file mode 100644 index 0000000000..94a4dd5259 --- /dev/null +++ b/libs/analytics-events/EVENTS.md @@ -0,0 +1,549 @@ +# Analytics events + + + +Every event both apps can send to Mixpanel, and the properties each one carries. To add +or change an event, edit [events.toml](events.toml) in a pull request. + +Types: **text** is words, **yes/no** is true or false, **count** is a whole number, +**decimal** is a number with a fraction, **duration** is milliseconds, and a **list** +is one value from the [Lists](#lists) below. An optional property is left out when the +app has no value for it. + +## Chat + +### Sent Message + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| Error | text | yes | | + +### Tip Received + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Message Received + +| Property | Type | Optional | Values | +|---|---|---|---| +| Chat Type | list | no | [ChatType](#chattype) | + +## Transfer + +### Grab Bill Start + +No properties. + +### Give Bill Start + +No properties. + +### Grab Bill + +| Property | Type | Optional | Values | +|---|---|---|---| +| Grab Time | duration | yes | | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat and Currency of the amount (see Amount). + +### Give Bill + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Withdrawal + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Send Cash Link + +| Property | Type | Optional | Values | +|---|---|---|---| +| Cash Link Choice | list | yes | [CashLinkChoice](#cashlinkchoice) | +| App | text | yes | | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure. + +### Receive Cash Link + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Sent Cash + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Sent Tip + +| Property | Type | Optional | Values | +|---|---|---|---| +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +## Add Money + +### Add Money: Opened + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [AddMoneySource](#addmoneysource) | + +### Add Money: Method Selected + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | + +> **Drift:** iOS has no Reserves method. + +### Add Money: Amount Confirmed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +### Add Money: Payment Invoked + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here. + +### Add Money: Address Copied + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | + +### Add Money + +The outcome of an add-money flow, sent as the event named `Add Money`. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Method | list | no | [AddMoneyMethod](#addmoneymethod) | +| State | list | no | [State](#state) | +| amount | group | yes | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** iOS sends Exchange Rate and no USDC (see Amount). + +## Onramp + +### Onramp: {step} + +Show Verification Info is Android only; part 2 decides whether iOS sends it. + +`{step}` in the name is one of [OnrampStep](#onrampstep). + +No properties. + +## Wallet + +The Phantom deeplink flow. + +### Wallet: Connect + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS sends no Provider, and fires on the request rather than after connecting. + +### Wallet: Request Amount + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | +| amount | group | no | [amount](#amount) | + +> **Drift:** iOS sends no Provider, and sends only Fiat (the native amount) and Currency where Android sends the token amount's block (see Amount). + +### Wallet: Transactions Failed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS declares this with no Provider and never sends it. + +### Wallet: Cancel + +| Property | Type | Optional | Values | +|---|---|---|---| +| Provider | list | no | [WalletProvider](#walletprovider) | + +> **Drift:** iOS sends no Provider, and fires on any error code where Android fires only on a user reject. + +## Token Info + +### Token Info: Opened From {source} + +`{source}` in the name is one of [TokenInfoSource](#tokeninfosource). + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | + +## Swap + +Buying and selling a token. + +### Token Purchase With {method} + +Phantom and Coinbase are Android only; part 2 decides whether iOS sends them. + +`{method}` in the name is one of [PurchaseMethod](#purchasemethod). + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | +| amount | group | no | [amount](#amount) | +| Error | text | yes | | + +> **Drift:** (Reserves) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks. + +### Token Sell + +| Property | Type | Optional | Values | +|---|---|---|---| +| Mint | text | no | | +| amount | group | no | [amount](#amount) | +| Fee | decimal | no | | +| Error | text | yes | | + +> **Drift:** iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee. + +## Scan + +The gallery scan path, and the tip card. + +### Gallery Scan: Image Picked + +No properties. + +### Gallery Scan: Succeeded + +@param tier which rung of the crop ladder decoded, 1 to 3. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Tier | count | no | | +| Zoom | decimal | no | | +| Time | duration | no | | + +> **Drift:** iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms. + +### Gallery Scan: Failed + +@param exhausted true when the budget ran out rather than the ladder ending. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Time | duration | no | | +| Exhausted | yes/no | no | | + +> **Drift:** iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted. + +### Tip Card Scanned + +No properties. + +### Tip Card Presented + +No properties. + +## Deeplink + +[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ. + +### Deeplink: Open + +| Property | Type | Optional | Values | +|---|---|---|---| +| URL | text | no | | + +### Deeplink: Parse + +Sent in 2 forms. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Type | text | no | | + +> **Drift:** iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, "Sheet:"}, and iOS sends this for every link where Android sends it only on the gallery QR path. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Error | text | no | `Failed to parse deeplink => {url}` | + +### Deeplink: Routed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Type | text | no | | +| Error | text | yes | | + +> **Drift:** iOS never passes Error, and sends this for every kind where Android sends it only for a cash link. + +## Display Name + +### Display Name Set + +The user had no display name before this submission. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [DisplayNameSource](#displaynamesource) | + +### Display Name Updated + +The user replaced an existing display name. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Source | list | no | [DisplayNameSource](#displaynamesource) | + +## Error Modal + +### Error Modal Displayed + +| Property | Type | Optional | Values | +|---|---|---|---| +| Title | text | no | | +| Message | text | no | | +| Screen | text | yes | | +| Call Site | text | yes | | + +> **Drift:** iOS's Screen is `presentedSheet.description` or "scan" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site. + +## Account + +### Create Account Payment + +Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment. + +| Property | Type | Optional | Values | +|---|---|---|---| +| Fiat | decimal | no | | +| Currency | text | no | | +| Owner Public Key | text | no | | + +### Entered Phone Number + +No properties. + +### Verified Phone Number + +No properties. + +### Linked Phone Number + +No properties. + +### Complete Onboarding + +No properties. + +## Button + +### Button: {button} + +`{button}` in the name is one of [Button](#button). + +No properties. + +## Groups + +### amount + +Sent by events that carry an amount of money. Defined in `Amount.kt`. + +| Property | Type | Optional | +|---|---|---| +| Fiat | decimal | no | +| Currency | text | no | +| USDC | decimal | no | +| Quarks | count | no | +| Exchange Rate | decimal | yes | +| Mint | text | yes | + +## Lists + +### ChatType + +The `Chat Type` property. + +| Value sent | Name in code | Drift | +|---|---|---| +| Contact | `CONTACT` | | +| Tip | `TIP` | | +| Group | `GROUP` | | +| Unknown | `UNKNOWN` | | + +### State + +The `State` property on an event that reports an outcome. + +| Value sent | Name in code | Drift | +|---|---|---| +| Success | `SUCCESS` | | +| Failure | `FAILURE` | | + +### CashLinkChoice + +The `Cash Link Choice` property on Send Cash Link: how the user passed the link on. + +| Value sent | Name in code | Drift | +|---|---|---| +| Copied to clipboard | `COPIED` | | +| Shared to app | `SHARED` | | + +### AddMoneySource + +The `Source` property on Add Money: Opened: where the user opened Add Money from. + +| Value sent | Name in code | Drift | +|---|---|---| +| Menu | `MENU` | | +| Give Shortfall | `GIVE_SHORTFALL` | | +| Buy Shortfall | `BUY_SHORTFALL` | | +| Username Shortfall | `USERNAME_SHORTFALL` | | +| Chat | `CHAT` | | +| Scanner | `SCANNER` | | +| Balance | `BALANCE` | | + +### AddMoneyMethod + +The `Method` property on the Add Money events: how the user adds money. + +| Value sent | Name in code | Drift | +|---|---|---| +| Coinbase | `COINBASE` | | +| Phantom | `PHANTOM` | | +| Other Wallet | `OTHER_WALLET` | | +| Reserves | `RESERVES` | iOS has no Reserves method. | + +### OnrampStep + +The verification screen shown, sent in the event name `Onramp: {step}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Show Verification Info | `SHOW_INFO` | | +| Show Enter Phone | `ENTER_PHONE` | | +| Show Confirm Phone | `CONFIRM_PHONE` | | +| Show Enter Email | `ENTER_EMAIL` | | +| Show Confirm Email | `CONFIRM_EMAIL` | | + +### WalletProvider + +The `Provider` property on the Wallet events: the wallet app the flow goes through. + +| Value sent | Name in code | Drift | +|---|---|---| +| Phantom | `PHANTOM` | | + +### TokenInfoSource + +Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2. + +| Value sent | Name in code | Drift | +|---|---|---| +| Deeplink | `DEEPLINK` | | +| Wallet | `WALLET` | | +| Discovery | `DISCOVERY` | | +| Chat | `CHAT` | | +| Chat Gate | `CHAT_GATE` | | + +### PurchaseMethod + +How a token is paid for, sent in the event name `Token Purchase With {method}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Reserves | `RESERVES` | | +| Phantom | `PHANTOM` | | +| Coinbase | `COINBASE` | | + +### DisplayNameSource + +The `Source` property on the Display Name events: the screen the name was set on. + +| Value sent | Name in code | Drift | +|---|---|---| +| Onboarding | `ONBOARDING` | | +| My Account | `MY_ACCOUNT` | | +| Tip Card Setup | `TIP_CARD_SETUP` | | + +### Button + +The button tapped, sent in the event name `Button: {button}`. + +| Value sent | Name in code | Drift | +|---|---|---| +| Create Account | `CREATE_ACCOUNT` | | +| Save Access Key | `SAVE_ACCESS_KEY` | | +| Wrote Access Key | `WROTE_ACCESS_KEY` | | +| Allow Push | `ALLOW_PUSH` | | +| Skip Push | `SKIP_PUSH` | | +| Allow Contacts | `ALLOW_CONTACTS` | | +| Skip Contacts | `SKIP_CONTACTS` | | +| Buy With Reserves | `BUY_WITH_RESERVES` | | +| Buy With Phantom | `BUY_WITH_PHANTOM` | | +| Buy With Coinbase | `BUY_WITH_COINBASE` | | +| Buy With Other Wallet | `BUY_WITH_OTHER_WALLET` | | +| Share Token Info | `SHARE_TOKEN_INFO` | | + +## People counters + +Cumulative per-user counters, stored as Mixpanel people properties. + +| Counter | Name in code | +|---|---| +| Tips Received | `TIPS` | +| Tips Received Value | `TIPS_VALUE` | +| Messages Received | `MESSAGES` | diff --git a/libs/analytics-events/events.toml b/libs/analytics-events/events.toml index 80f77d739e..090384e8fb 100644 --- a/libs/analytics-events/events.toml +++ b/libs/analytics-events/events.toml @@ -1,483 +1,529 @@ -# The analytics event catalogue: every event both apps can send, and the value -# vocabularies they use. The build generates the builder objects and value enums from -# this file into build/generated/analytics/commonMain (see -# build-logic/.../analytics/KotlinEmitter.kt); nothing generated is checked in. -# AnalyticsEvent.kt and Amount.kt stay hand-written. +# The analytics event catalogue: every event both apps can send to Mixpanel, and the +# properties each one carries. EVENTS.md, next to this file, is the same catalogue as a +# readable page. To add or change an event, edit this file in a pull request. If you'd +# rather not, ask an engineer to make the change. # -# To add an event: -# 1. Add a [[domains.Events.events]] entry: `builder`, `event`, `params`, -# `properties`, and `doc` or `drift` where they apply. `event` and a `format` -# value may name a parameter as {param}; an enum parameter sends its wire value. -# `{ group = "amount" }` and `{ group = "outcome" }` expand in place. -# 2. Add a test to src/commonTest that asserts the event name and properties as -# string literals. Never read the expected strings back from the enums: the -# literal is what stops a catalogue edit from renaming an event by accident. -# 3. A new domain or enum also needs a typealias in -# kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift. -# 4. iOS gets the event only from a flipcash-shared-core-spm release: after the -# change merges, run the Publish SharedCore workflow with the next minor version. +# To add an event, copy an [[event]] entry below and change it: +# +# [[event]] +# domain = "Transfer" the part of the app it belongs to +# name = "Grab Bill" the event name exactly as Mixpanel shows it +# properties = [ +# { name = "Grab Time", type = "duration", optional = true }, +# { group = "outcome" }, +# ] +# +# Each property has a `name` (the key Mixpanel shows) and a `type`: +# text words, such as a mint address or an error message +# yes/no true or false +# count a whole number +# decimal a number with a fraction, such as a zoom level or a fee +# duration a length of time, sent in milliseconds +# a list one value from a list in the Lists section, such as ChatType +# +# `optional = true` means the app may leave the property out; without it, the property +# is sent every time. `{ group = "outcome" }` and `{ group = "amount" }` add the same +# block of properties every outcome or amount event sends (see Groups). +# +# An event name can include a list value, such as "Token Purchase With {method}". Say +# which list with `placeholders = { method = "PurchaseMethod" }`; a {placeholder} that +# isn't listed there is text. A property can be fixed text around a value with +# `format`, such as { name = "Error", format = "Failed to parse deeplink => {url}" }. # -# A `drift` note records how iOS differs today and is emitted as a "DRIFT:" KDoc line. -# The generator rejects unknown keys, unsent parameters and duplicate properties or -# builders; its tests are in build-logic/convention/src/test. +# `description` explains the event. `drift` records how iOS differs today, until the two +# apps agree. +# +# The build checks every entry and stops with a message naming the event and what to +# change. One it may ask for is `builder`, the name engineers call the event by. It +# comes from the event name, so "Add Money: Opened" becomes `opened`; set `builder` +# when two events would get the same one, or when the name gives none. +# +# For engineers: the build generates the builder objects and value enums from this file +# into build/generated/analytics/commonMain (see build-logic/.../analytics). Nothing +# generated is checked in except EVENTS.md. AnalyticsEvent.kt and Amount.kt stay +# hand-written. `param` renames a property's parameter, and `order` lists the parameters +# in a different order than the properties give them. +# 1. Add a test to src/commonTest that asserts the event name and properties as string +# literals. Never read the expected strings back from the enums: the literal is what +# stops a catalogue edit from renaming an event by accident. +# 2. A new domain or list also needs a typealias in +# kmp/shared-core/spm/Sources/SharedCoreKit/AnalyticsEvents.swift. +# 3. iOS gets the event only from a flipcash-shared-core-spm release: after the change +# merges, run the Publish SharedCore workflow with the next minor version. +# The generator's tests are in build-logic/convention/src/test. package = "com.flipcash.analytics" -# Chat - -[enums.ChatType] -doc = "The `Chat Type` property." +# --------------------------------------------------------------------------------------- +# Lists: the values a list-typed property can take. The left side is the name engineers +# use; the right side is the text Mixpanel receives. +# --------------------------------------------------------------------------------------- -[enums.ChatType.cases] +[lists.ChatType] +description = "The `Chat Type` property." +[lists.ChatType.values] CONTACT = "Contact" TIP = "Tip" GROUP = "Group" UNKNOWN = "Unknown" -[[domains.ChatEvents.events]] -builder = "sentMessage" -event = "Sent Message" -params = ["chatType: ChatType", "error: String?"] -properties = [ - { key = "Chat Type", value = "chatType" }, - { key = "Error", value = "error" }, -] +[lists.State] +description = "The `State` property on an event that reports an outcome." +[lists.State.values] +SUCCESS = "Success" +FAILURE = "Failure" -[[domains.ChatEvents.events]] -builder = "tipReceived" -event = "Tip Received" -params = ["chatType: ChatType", "amount: Amount"] -properties = [ - { key = "Chat Type", value = "chatType" }, - { group = "amount" }, -] -drift = "iOS sends Exchange Rate and no USDC (see Amount)." +[lists.CashLinkChoice] +description = "The `Cash Link Choice` property on Send Cash Link: how the user passed the link on." +[lists.CashLinkChoice.values] +COPIED = "Copied to clipboard" +SHARED = "Shared to app" -[[domains.ChatEvents.events]] -builder = "messageReceived" -event = "Message Received" -params = ["chatType: ChatType"] -properties = [{ key = "Chat Type", value = "chatType" }] +[lists.AddMoneySource] +description = "The `Source` property on Add Money: Opened: where the user opened Add Money from." +[lists.AddMoneySource.values] +MENU = "Menu" +GIVE_SHORTFALL = "Give Shortfall" +BUY_SHORTFALL = "Buy Shortfall" +USERNAME_SHORTFALL = "Username Shortfall" +CHAT = "Chat" +SCANNER = "Scanner" +BALANCE = "Balance" -# Transfers +[lists.AddMoneyMethod] +description = "The `Method` property on the Add Money events: how the user adds money." +[lists.AddMoneyMethod.values] +COINBASE = "Coinbase" +PHANTOM = "Phantom" +OTHER_WALLET = "Other Wallet" +RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } -[enums.State] -doc = "The `State` property on an event that reports an outcome." +[lists.OnrampStep] +description = "The verification screen shown, sent in the event name `Onramp: {step}`." +[lists.OnrampStep.values] +SHOW_INFO = "Show Verification Info" +ENTER_PHONE = "Show Enter Phone" +CONFIRM_PHONE = "Show Confirm Phone" +ENTER_EMAIL = "Show Enter Email" +CONFIRM_EMAIL = "Show Confirm Email" -[enums.State.cases] -SUCCESS = "Success" -FAILURE = "Failure" +[lists.WalletProvider] +description = "The `Provider` property on the Wallet events: the wallet app the flow goes through." +[lists.WalletProvider.values] +PHANTOM = "Phantom" -[enums.CashLinkChoice.cases] -COPIED = "Copied to clipboard" -SHARED = "Shared to app" +[lists.TokenInfoSource] +description = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2." +[lists.TokenInfoSource.values] +DEEPLINK = "Deeplink" +WALLET = "Wallet" +DISCOVERY = "Discovery" +CHAT = "Chat" +CHAT_GATE = "Chat Gate" + +[lists.PurchaseMethod] +description = "How a token is paid for, sent in the event name `Token Purchase With {method}`." +[lists.PurchaseMethod.values] +RESERVES = "Reserves" +PHANTOM = "Phantom" +COINBASE = "Coinbase" + +[lists.DisplayNameSource] +description = "The `Source` property on the Display Name events: the screen the name was set on." +[lists.DisplayNameSource.values] +ONBOARDING = "Onboarding" +MY_ACCOUNT = "My Account" +TIP_CARD_SETUP = "Tip Card Setup" + +[lists.Button] +description = "The button tapped, sent in the event name `Button: {button}`." +[lists.Button.values] +CREATE_ACCOUNT = "Create Account" +SAVE_ACCESS_KEY = "Save Access Key" +WROTE_ACCESS_KEY = "Wrote Access Key" +ALLOW_PUSH = "Allow Push" +SKIP_PUSH = "Skip Push" +ALLOW_CONTACTS = "Allow Contacts" +SKIP_CONTACTS = "Skip Contacts" +BUY_WITH_RESERVES = "Buy With Reserves" +BUY_WITH_PHANTOM = "Buy With Phantom" +BUY_WITH_COINBASE = "Buy With Coinbase" +BUY_WITH_OTHER_WALLET = "Buy With Other Wallet" +SHARE_TOKEN_INFO = "Share Token Info" + +# --------------------------------------------------------------------------------------- +# Groups: blocks of properties several events send the same way. +# +# `amount` is built in. It sends Fiat, Currency, USDC and Quarks, plus Exchange Rate and +# Mint when the amount has them. +# --------------------------------------------------------------------------------------- # State, then the amount, then Error: the block every outcome event ends with. +[groups.outcome] +properties = [ + { name = "State", type = "State" }, + { group = "amount", optional = true }, + { name = "Error", type = "text", optional = true }, +] + +# --------------------------------------------------------------------------------------- # People counters: cumulative per-user totals, sent as Mixpanel people properties. +# --------------------------------------------------------------------------------------- + [counters] TIPS = "Tips Received" TIPS_VALUE = "Tips Received Value" MESSAGES = "Messages Received" -[groups.outcome] +# --------------------------------------------------------------------------------------- +# Domains that need a word of explanation. +# --------------------------------------------------------------------------------------- + +[domains] +Wallet = "The Phantom deeplink flow." +Swap = "Buying and selling a token." +Scan = "The gallery scan path, and the tip card." +Deeplink = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." + +# --------------------------------------------------------------------------------------- +# Events +# --------------------------------------------------------------------------------------- + +# Chat + +[[event]] +domain = "Chat" +name = "Sent Message" properties = [ - { key = "State", value = "state" }, + { name = "Chat Type", type = "ChatType" }, + { name = "Error", type = "text", optional = true }, +] + +[[event]] +domain = "Chat" +name = "Tip Received" +properties = [ + { name = "Chat Type", type = "ChatType" }, { group = "amount" }, - { key = "Error", value = "error" }, ] +drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "grabBillStart" -event = "Grab Bill Start" +[[event]] +domain = "Chat" +name = "Message Received" +properties = [{ name = "Chat Type", type = "ChatType" }] -[[domains.TransferEvents.events]] -builder = "giveBillStart" -event = "Give Bill Start" +# Transfers + +[[event]] +domain = "Transfer" +name = "Grab Bill Start" -[[domains.TransferEvents.events]] -builder = "grabBill" -event = "Grab Bill" -params = ["state: State", "amount: Amount?", "grabTimeMillis: Long?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Give Bill Start" + +[[event]] +domain = "Transfer" +name = "Grab Bill" +order = ["state", "amount", "grabTimeMillis", "error"] properties = [ - { key = "Grab Time", value = "grabTimeMillis" }, + { name = "Grab Time", type = "duration", optional = true }, { group = "outcome" }, ] drift = "iOS sends Grab Time in seconds, not milliseconds, and on failure sends only Fiat and Currency of the amount (see Amount)." -[[domains.TransferEvents.events]] -builder = "giveBill" -event = "Give Bill" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Give Bill" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "withdrawal" -event = "Withdrawal" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Withdrawal" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sendCashLink" -event = "Send Cash Link" -params = ["state: State", "amount: Amount?", "choice: CashLinkChoice?", "app: String?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Send Cash Link" +order = ["state", "amount", "choice", "app", "error"] properties = [ - { key = "Cash Link Choice", value = "choice" }, - { key = "App", value = "app" }, + { name = "Cash Link Choice", type = "CashLinkChoice", optional = true, param = "choice" }, + { name = "App", type = "text", optional = true }, { group = "outcome" }, ] drift = "iOS sends no Cash Link Choice or App, and sends Exchange Rate and no USDC (see Amount). Android never sends Failure." -[[domains.TransferEvents.events]] -builder = "receiveCashLink" -event = "Receive Cash Link" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Receive Cash Link" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sentCash" -event = "Sent Cash" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Sent Cash" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.TransferEvents.events]] -builder = "sentTip" -event = "Sent Tip" -params = ["state: State", "amount: Amount?", "error: String?"] +[[event]] +domain = "Transfer" +name = "Sent Tip" properties = [{ group = "outcome" }] drift = "iOS sends Exchange Rate and no USDC (see Amount)." # Add money -[enums.AddMoneySource.cases] -MENU = "Menu" -GIVE_SHORTFALL = "Give Shortfall" -BUY_SHORTFALL = "Buy Shortfall" -USERNAME_SHORTFALL = "Username Shortfall" -CHAT = "Chat" -SCANNER = "Scanner" -BALANCE = "Balance" - -[enums.AddMoneyMethod.cases] -COINBASE = "Coinbase" -PHANTOM = "Phantom" -OTHER_WALLET = "Other Wallet" -RESERVES = { value = "Reserves", drift = "iOS has no Reserves method." } +[[event]] +domain = "Add Money" +name = "Add Money: Opened" +properties = [{ name = "Source", type = "AddMoneySource" }] -[[domains.AddMoneyEvents.events]] -builder = "opened" -event = "Add Money: Opened" -params = ["source: AddMoneySource"] -properties = [{ key = "Source", value = "source" }] - -[[domains.AddMoneyEvents.events]] -builder = "methodSelected" -event = "Add Money: Method Selected" -params = ["method: AddMoneyMethod"] -properties = [{ key = "Method", value = "method" }] +[[event]] +domain = "Add Money" +name = "Add Money: Method Selected" +properties = [{ name = "Method", type = "AddMoneyMethod" }] drift = "iOS has no Reserves method." -[[domains.AddMoneyEvents.events]] -builder = "amountConfirmed" -event = "Add Money: Amount Confirmed" -params = ["method: AddMoneyMethod", "amount: Amount"] +[[event]] +domain = "Add Money" +name = "Add Money: Amount Confirmed" properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "amount" }, ] drift = "iOS sends Exchange Rate and no USDC (see Amount)." -[[domains.AddMoneyEvents.events]] -builder = "paymentInvoked" -event = "Add Money: Payment Invoked" -params = ["method: AddMoneyMethod", "amount: Amount"] +[[event]] +domain = "Add Money" +name = "Add Money: Payment Invoked" properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "amount" }, ] drift = "iOS sends Exchange Rate and no USDC (see Amount). Android's USDC carries the native amount here." -[[domains.AddMoneyEvents.events]] -builder = "addressCopied" -event = "Add Money: Address Copied" -params = ["mint: String"] -properties = [{ key = "Mint", value = "mint" }] +[[event]] +domain = "Add Money" +name = "Add Money: Address Copied" +properties = [{ name = "Mint", type = "text" }] -[[domains.AddMoneyEvents.events]] +[[event]] +domain = "Add Money" +name = "Add Money" builder = "result" -event = "Add Money" -params = ["method: AddMoneyMethod", "state: State", "amount: Amount?", "error: String?"] properties = [ - { key = "Method", value = "method" }, + { name = "Method", type = "AddMoneyMethod" }, { group = "outcome" }, ] -doc = "The outcome of an add-money flow, sent as the event named `Add Money`." +description = "The outcome of an add-money flow, sent as the event named `Add Money`." drift = "iOS sends Exchange Rate and no USDC (see Amount)." # Onramp -[enums.OnrampStep.cases] -SHOW_INFO = "Show Verification Info" -ENTER_PHONE = "Show Enter Phone" -CONFIRM_PHONE = "Show Confirm Phone" -ENTER_EMAIL = "Show Enter Email" -CONFIRM_EMAIL = "Show Confirm Email" - -[[domains.OnrampEvents.events]] +[[event]] +domain = "Onramp" +name = "Onramp: {step}" builder = "step" -event = "Onramp: {step}" -params = ["step: OnrampStep"] -doc = "Show Verification Info is Android only; part 2 decides whether iOS sends it." +placeholders = { step = "OnrampStep" } +description = "Show Verification Info is Android only; part 2 decides whether iOS sends it." # Wallet -[enums.WalletProvider.cases] -PHANTOM = "Phantom" - -[domains.WalletEvents] -doc = "The Phantom deeplink flow." - -[[domains.WalletEvents.events]] -builder = "connect" -event = "Wallet: Connect" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[event]] +domain = "Wallet" +name = "Wallet: Connect" +properties = [{ name = "Provider", type = "WalletProvider" }] drift = "iOS sends no Provider, and fires on the request rather than after connecting." -[[domains.WalletEvents.events]] -builder = "requestAmount" -event = "Wallet: Request Amount" -params = ["provider: WalletProvider", "amount: Amount"] +[[event]] +domain = "Wallet" +name = "Wallet: Request Amount" properties = [ - { key = "Provider", value = "provider" }, + { name = "Provider", type = "WalletProvider" }, { group = "amount" }, ] drift = "iOS sends no Provider, and sends only Fiat (the native amount) and Currency where Android sends the token amount's block (see Amount)." -[[domains.WalletEvents.events]] -builder = "transactionsFailed" -event = "Wallet: Transactions Failed" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[event]] +domain = "Wallet" +name = "Wallet: Transactions Failed" +properties = [{ name = "Provider", type = "WalletProvider" }] drift = "iOS declares this with no Provider and never sends it." -[[domains.WalletEvents.events]] -builder = "cancel" -event = "Wallet: Cancel" -params = ["provider: WalletProvider"] -properties = [{ key = "Provider", value = "provider" }] +[[event]] +domain = "Wallet" +name = "Wallet: Cancel" +properties = [{ name = "Provider", type = "WalletProvider" }] drift = "iOS sends no Provider, and fires on any error code where Android fires only on a user reject." # Token info -[enums.TokenInfoSource] -doc = "Where Token Info was opened from. Discovery, Chat and Chat Gate are new in part 1; Android previously sent Wallet for all three. iOS's Give and Send sources stay native until part 2." - -[enums.TokenInfoSource.cases] -DEEPLINK = "Deeplink" -WALLET = "Wallet" -DISCOVERY = "Discovery" -CHAT = "Chat" -CHAT_GATE = "Chat Gate" - -[[domains.TokenInfoEvents.events]] +[[event]] +domain = "Token Info" +name = "Token Info: Opened From {source}" builder = "opened" -event = "Token Info: Opened From {source}" -params = ["source: TokenInfoSource", "mint: String"] -properties = [{ key = "Mint", value = "mint" }] +placeholders = { source = "TokenInfoSource" } +properties = [{ name = "Mint", type = "text" }] # Swap -[enums.PurchaseMethod.cases] -RESERVES = "Reserves" -PHANTOM = "Phantom" -COINBASE = "Coinbase" - -[domains.SwapEvents] -doc = "Buying and selling a token." - -[[domains.SwapEvents.events]] +[[event]] +domain = "Swap" +name = "Token Purchase With {method}" builder = "purchase" -event = "Token Purchase With {method}" -params = ["method: PurchaseMethod", "mint: String", "amount: Amount", "error: String?"] +placeholders = { method = "PurchaseMethod" } properties = [ - { key = "Mint", value = "mint" }, + { name = "Mint", type = "text" }, { group = "amount" }, - { key = "Error", value = "error" }, + { name = "Error", type = "text", optional = true }, ] -doc = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them." +description = "Phantom and Coinbase are Android only; part 2 decides whether iOS sends them." drift = "(Reserves) iOS also sends State, Payment Mint and Payment Token Symbol, and sends only Fiat and Currency of the amount, with no USDC or Quarks." -[[domains.SwapEvents.events]] +[[event]] +domain = "Swap" +name = "Token Sell" builder = "sell" -event = "Token Sell" -params = ["mint: String", "amount: Amount", "fee: Double", "error: String?"] properties = [ - { key = "Mint", value = "mint" }, + { name = "Mint", type = "text" }, { group = "amount" }, - { key = "Fee", value = "fee" }, - { key = "Error", value = "error" }, + { name = "Fee", type = "decimal" }, + { name = "Error", type = "text", optional = true }, ] drift = "iOS also sends State, and sends only Fiat and Currency of the amount, with no USDC, Quarks or Fee." # Scan -[domains.ScanEvents] -doc = "The gallery scan path, and the tip card." - -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Image Picked" builder = "galleryImagePicked" -event = "Gallery Scan: Image Picked" -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Succeeded" builder = "gallerySucceeded" -event = "Gallery Scan: Succeeded" -params = ["tier: Int", "zoom: Double", "timeMillis: Long"] properties = [ - { key = "Tier", value = "tier" }, - { key = "Zoom", value = "zoom" }, - { key = "Time", value = "timeMillis" }, + { name = "Tier", type = "count" }, + { name = "Zoom", type = "decimal" }, + { name = "Time", type = "duration" }, ] -doc = "@param tier which rung of the crop ladder decoded, 1 to 3." +description = "@param tier which rung of the crop ladder decoded, 1 to 3." drift = "iOS sends this as Gallery Scan: Code Found, with Type {Kik, QR}, Tier as {wholeImage, quadrant, window}, Zoom, and Elapsed in seconds instead of Time in ms." -[[domains.ScanEvents.events]] +[[event]] +domain = "Scan" +name = "Gallery Scan: Failed" builder = "galleryFailed" -event = "Gallery Scan: Failed" -params = ["timeMillis: Long", "exhausted: Boolean"] properties = [ - { key = "Time", value = "timeMillis" }, - { key = "Exhausted", value = "exhausted" }, + { name = "Time", type = "duration" }, + { name = "Exhausted", type = "yes/no" }, ] -doc = "@param exhausted true when the budget ran out rather than the ladder ending." +description = "@param exhausted true when the budget ran out rather than the ladder ending." drift = "iOS sends this as Gallery Scan: Nothing Found, with State {Exhausted, Cancelled, Route Refused} and Elapsed in seconds instead of Time in ms and Exhausted." -[[domains.ScanEvents.events]] -builder = "tipCardScanned" -event = "Tip Card Scanned" +[[event]] +domain = "Scan" +name = "Tip Card Scanned" -[[domains.ScanEvents.events]] -builder = "tipCardPresented" -event = "Tip Card Presented" +[[event]] +domain = "Scan" +name = "Tip Card Presented" # Deeplink -[domains.DeeplinkEvents] -doc = "[url] arrives with its query and fragment already stripped. `type` is a string because the two apps' deeplink vocabularies differ." +[[event]] +domain = "Deeplink" +name = "Deeplink: Open" +properties = [{ name = "URL", type = "text" }] -[[domains.DeeplinkEvents.events]] -builder = "open" -event = "Deeplink: Open" -params = ["url: String"] -properties = [{ key = "URL", value = "url" }] - -[[domains.DeeplinkEvents.events]] +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" builder = "parsed" -event = "Deeplink: Parse" -params = ["type: String"] -properties = [{ key = "Type", value = "type" }] +properties = [{ name = "Type", type = "text" }] drift = "iOS's Type vocabulary is {Login, CashLink, EmailVerification, TokenInfo, Chat, ChatSendCash, Tip, Username, Wallet, DiscoverCurrencies, \"Sheet:\"}, and iOS sends this for every link where Android sends it only on the gallery QR path." -[[domains.DeeplinkEvents.events]] +[[event]] +domain = "Deeplink" +name = "Deeplink: Parse" builder = "parseFailed" -event = "Deeplink: Parse" -params = ["url: String"] -properties = [{ key = "Error", format = "Failed to parse deeplink => {url}" }] - -[[domains.DeeplinkEvents.events]] -builder = "routed" -event = "Deeplink: Routed" -params = ["type: String", "error: String?"] +properties = [{ name = "Error", format = "Failed to parse deeplink => {url}" }] + +[[event]] +domain = "Deeplink" +name = "Deeplink: Routed" properties = [ - { key = "Type", value = "type" }, - { key = "Error", value = "error" }, + { name = "Type", type = "text" }, + { name = "Error", type = "text", optional = true }, ] drift = "iOS never passes Error, and sends this for every kind where Android sends it only for a cash link." # Display name -[enums.DisplayNameSource.cases] -ONBOARDING = "Onboarding" -MY_ACCOUNT = "My Account" -TIP_CARD_SETUP = "Tip Card Setup" - -[[domains.DisplayNameEvents.events]] +[[event]] +domain = "Display Name" +name = "Display Name Set" builder = "set" -event = "Display Name Set" -params = ["source: DisplayNameSource"] -properties = [{ key = "Source", value = "source" }] -doc = "The user had no display name before this submission." +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user had no display name before this submission." -[[domains.DisplayNameEvents.events]] +[[event]] +domain = "Display Name" +name = "Display Name Updated" builder = "updated" -event = "Display Name Updated" -params = ["source: DisplayNameSource"] -properties = [{ key = "Source", value = "source" }] -doc = "The user replaced an existing display name." +properties = [{ name = "Source", type = "DisplayNameSource" }] +description = "The user replaced an existing display name." # Error modal -[[domains.ErrorModalEvents.events]] +[[event]] +domain = "Error Modal" +name = "Error Modal Displayed" builder = "displayed" -event = "Error Modal Displayed" -params = ["title: String", "message: String", "screen: String?", "callSite: String?"] properties = [ - { key = "Title", value = "title" }, - { key = "Message", value = "message" }, - { key = "Screen", value = "screen" }, - { key = "Call Site", value = "callSite" }, + { name = "Title", type = "text" }, + { name = "Message", type = "text" }, + { name = "Screen", type = "text", optional = true }, + { name = "Call Site", type = "text", optional = true }, ] drift = "iOS's Screen is `presentedSheet.description` or \"scan\" where Android's is the back stack top's `screenName()`, and iOS never sends Call Site." # Account -[[domains.AccountEvents.events]] -builder = "createAccountPayment" -event = "Create Account Payment" -params = ["price: Double", "currency: String", "owner: String"] +[[event]] +domain = "Account" +name = "Create Account Payment" properties = [ - { key = "Fiat", value = "price" }, - { key = "Currency", value = "currency" }, - { key = "Owner Public Key", value = "owner" }, + { name = "Fiat", type = "decimal", param = "price" }, + { name = "Currency", type = "text" }, + { name = "Owner Public Key", type = "text", param = "owner" }, ] -doc = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." +description = "Android only; part 2 decides whether iOS sends it. iOS's Create Account fires at registration and is a different moment." -[[domains.AccountEvents.events]] -builder = "enteredPhoneNumber" -event = "Entered Phone Number" +[[event]] +domain = "Account" +name = "Entered Phone Number" -[[domains.AccountEvents.events]] -builder = "verifiedPhoneNumber" -event = "Verified Phone Number" +[[event]] +domain = "Account" +name = "Verified Phone Number" -[[domains.AccountEvents.events]] -builder = "linkedPhoneNumber" -event = "Linked Phone Number" +[[event]] +domain = "Account" +name = "Linked Phone Number" -[[domains.AccountEvents.events]] -builder = "completeOnboarding" -event = "Complete Onboarding" +[[event]] +domain = "Account" +name = "Complete Onboarding" # Buttons -[enums.Button.cases] -CREATE_ACCOUNT = "Create Account" -SAVE_ACCESS_KEY = "Save Access Key" -WROTE_ACCESS_KEY = "Wrote Access Key" -ALLOW_PUSH = "Allow Push" -SKIP_PUSH = "Skip Push" -ALLOW_CONTACTS = "Allow Contacts" -SKIP_CONTACTS = "Skip Contacts" -BUY_WITH_RESERVES = "Buy With Reserves" -BUY_WITH_PHANTOM = "Buy With Phantom" -BUY_WITH_COINBASE = "Buy With Coinbase" -BUY_WITH_OTHER_WALLET = "Buy With Other Wallet" -SHARE_TOKEN_INFO = "Share Token Info" - -[[domains.ButtonEvents.events]] +[[event]] +domain = "Button" +name = "Button: {button}" builder = "tapped" -event = "Button: {button}" -params = ["button: Button"] +placeholders = { button = "Button" }