From 9553f1faab3f1d12162c3061fcb4d0d6cabc95fd Mon Sep 17 00:00:00 2001 From: peachbits Date: Tue, 25 Aug 2026 14:16:29 -0700 Subject: [PATCH] Track marketing notification opens The push server attaches a campaignId (and an optional deep link URL) to every marketing notification. Parse that payload when the user opens the app from one: report the campaign to analytics, and navigate to the URL. A campaign link goes wherever its URL points, the same as a link tapped in a browser or a message. Both reach the app through `parseDeepLink` and the same handler, so a campaign can send users anywhere the app already goes - including both the new and legacy fiat ramp spellings - without a list here to keep in step with the rest of the app. A URL the parser does not recognize tracks the open without navigating. Sends are gated behind a marketer API key that is not shipped in the apps, so a campaign URL is not reachable by an outsider. --- CHANGELOG.md | 1 + src/__tests__/PushMessageParser.test.ts | 110 ++++++++++++++++++++++++ src/actions/DeepLinkingActions.tsx | 27 ++++++ src/types/DeepLinkTypes.ts | 7 ++ src/util/PushMessageParser.ts | 34 +++++++- src/util/tracking.ts | 2 + 6 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/PushMessageParser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68120eca1f7..cbf3f9030ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message. - added: `edge://buy` and `edge://sell` deep links (and their `https://deep.edge.app` equivalents) that open the buy/sell flow, optionally pinning a provider and payment method to the top of the quote options for that visit. - added: Provider priority in the buy/sell options for affiliated accounts, configured through the info server promo card data. +- added: Track when a user opens the app from a marketing push notification, reporting the campaign to analytics and navigating to an optional deep link. - changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before. - changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade. - changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in". diff --git a/src/__tests__/PushMessageParser.test.ts b/src/__tests__/PushMessageParser.test.ts new file mode 100644 index 00000000000..18039dbcb44 --- /dev/null +++ b/src/__tests__/PushMessageParser.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from '@jest/globals' +import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging' + +import { parsePushMessage } from '../util/PushMessageParser' + +// The parser only uses `showDevError` from AirshipInstance; stub it so the test +// avoids pulling in the native Airship module. +jest.mock('../components/services/AirshipInstance', () => ({ + showDevError: () => undefined +})) + +/** + * Builds a minimal RemoteMessage; the parser only reads `data` and + * `notification`. + */ +function makeMessage( + data: Record | undefined, + notification?: { title?: string; body?: string } +): FirebaseMessagingTypes.RemoteMessage { + return { + data, + notification + } as unknown as FirebaseMessagingTypes.RemoteMessage +} + +describe('parsePushMessage', () => { + it('captures the campaignId from a marketing payload', () => { + expect( + parsePushMessage(makeMessage({ type: 'marketing', campaignId: 'abc123' })) + ).toEqual({ + type: 'marketing', + campaignId: 'abc123', + link: undefined + }) + }) + + it('parses an optional deep-link url into a nested navigation link', () => { + expect( + parsePushMessage( + makeMessage({ + type: 'marketing', + campaignId: 'abc123', + url: 'edge://swap' + }) + ) + ).toEqual({ + type: 'marketing', + campaignId: 'abc123', + link: { type: 'swap' } + }) + }) + + it('navigates wherever the campaign url points', () => { + // A campaign link reaches the same places a tapped link does, including + // both the new and legacy fiat ramp spellings: + const destinations: Array<[string, string]> = [ + ['edge://scene/edgeTabs?screen=home', 'scene'], // Home + ['edge://scene/walletsTab?screen=walletList', 'scene'], // Wallet list + ['edge://scene/earnScene', 'scene'], // Earn + ['edge://swap', 'swap'], + ['edge://buy/paybis/credit', 'rampCreate'], // Quote scene, pinned + ['edge://plugin/creditcard/buy/moonpay/credit', 'fiatPlugin'], // Native ramp + ['edge://plugin/moonpay/buy', 'plugin'], // Legacy ramp + ['edge://promotion/summer50', 'promotion'], + ['edge://modal/fundAccount', 'modal'] + ] + for (const [url, expectedType] of destinations) { + const result = parsePushMessage( + makeMessage({ type: 'marketing', campaignId: 'abc123', url }) + ) + if (result?.type !== 'marketing') throw new Error(`not marketing: ${url}`) + expect(result.link?.type).toBe(expectedType) + } + }) + + it('degrades to track-only when the url is unparseable', () => { + expect( + parsePushMessage( + makeMessage({ + type: 'marketing', + campaignId: 'abc123', + url: 'not a url' + }) + ) + ).toEqual({ + type: 'marketing', + campaignId: 'abc123', + link: undefined + }) + }) + + it('ignores a non-marketing payload', () => { + expect(parsePushMessage(makeMessage({ foo: 'bar' }))).toBeUndefined() + }) + + it('still parses a price-change payload', () => { + expect( + parsePushMessage( + makeMessage( + { type: 'price-change', pluginId: 'bitcoin' }, + { body: 'BTC is up' } + ) + ) + ).toEqual({ + type: 'price-change', + pluginId: 'bitcoin', + body: 'BTC is up' + }) + }) +}) diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 1c0568768e3..b648d057a65 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -93,6 +93,16 @@ export function getDeepLinkReadiness(link: DeepLink): DeepLinkReadiness { : 'referral' } + // Tracking the open needs the account; navigation then waits for whatever + // the campaign's own link needs, if it carries one: + case 'marketing': { + const inner = + link.link == null ? 'account' : getDeepLinkReadiness(link.link) + return deepLinkReadinessRank[inner] > deepLinkReadinessRank.account + ? inner + : 'account' + } + // These search `account.currencyWallets` or open a wallet picker, so a // half-loaded account would show an incomplete list or no match at all. // `walletConnect` belongs here because `WcConnectionsScene` opens the @@ -449,6 +459,23 @@ async function handleLink( break } + case 'marketing': { + // The user opened the app from a marketing push. Report it so the + // campaign's open rate can be tracked. The send UI lives in the internal + // tools project; the campaignId rides in the push notification payload. + dispatch( + logEvent('Push_Notification_Opened', { + campaignId: link.campaignId + }) + ) + // Optional navigation: delegate to the shared handler, mirroring the + // affiliate link. Unsupported targets fall through its existing guards. + if (link.link != null) { + await handleLink(navigation, dispatch, state, link.link) + } + break + } + case 'other': { const matchingWalletIdsAndUris: Array<{ walletId: string diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index a87b73f309b..0a299ebd5f0 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -141,6 +141,12 @@ export interface RampCreateLink { paymentType?: FiatPaymentType } +export interface MarketingLink { + type: 'marketing' + campaignId: string // Correlates notification opens to a marketing campaign + link?: DeepLink // Optional navigation target parsed from the payload URL +} + /** * A provider return link (e.g. Simplex or Paybis sending the user back into the * app once their session finishes). Handled by the ramp deeplink manager, not @@ -210,6 +216,7 @@ export type DeepLink = | EdgeLoginLink | FiatPluginLink | FiatProviderLink + | MarketingLink | ModalLink | NoopLink | PasswordRecoveryLink diff --git a/src/util/PushMessageParser.ts b/src/util/PushMessageParser.ts index 0fd97c797c6..26d8684649f 100644 --- a/src/util/PushMessageParser.ts +++ b/src/util/PushMessageParser.ts @@ -1,7 +1,9 @@ import type { FirebaseMessagingTypes } from '@react-native-firebase/messaging' -import { asMaybe, asObject, asString, asValue } from 'cleaners' +import { asMaybe, asObject, asOptional, asString, asValue } from 'cleaners' +import { showDevError } from '../components/services/AirshipInstance' import type { DeepLink } from '../types/DeepLinkTypes' +import { parseDeepLink } from './DeepLinkParser' /** * Extracts a deep link from a push message, if present. @@ -17,9 +19,39 @@ export function parsePushMessage( body: asString(message.notification?.body) } } + + const marketing = asMaybe(asMarketingPayloadData)(message.data) + if (marketing != null) { + let link: DeepLink | undefined + if (marketing.url != null) { + try { + const parsed = parseDeepLink(marketing.url) + // A campaign navigates wherever its URL points, the same as a link the + // user taps anywhere else. `parseDeepLink` is lenient and answers + // `other` for anything it does not recognize, so a malformed campaign + // URL tracks the open without navigating. + if (parsed.type !== 'other') link = parsed + } catch (error: unknown) { + // parseDeepLink can still throw on some malformed input; never let that + // drop the push, or we would also lose the open tracking. + showDevError(error) + } + } + return { + type: 'marketing', + campaignId: marketing.campaignId, + link + } + } } const asPriceChangePayloadData = asObject({ type: asValue('price-change'), pluginId: asString }) + +const asMarketingPayloadData = asObject({ + type: asValue('marketing'), + campaignId: asString, + url: asOptional(asString) +}) diff --git a/src/util/tracking.ts b/src/util/tracking.ts index 4b68ef364b2..eeb5a9b703e 100644 --- a/src/util/tracking.ts +++ b/src/util/tracking.ts @@ -44,6 +44,7 @@ export type TrackingEventName = | 'Fio_Handle_Bundled_Tx' | 'Load_Install_Reason_Match' | 'Load_Install_Reason_Fail' + | 'Push_Notification_Opened' | 'Sell_Quote' | 'Sell_Quote_Next' | 'Sell_Success' @@ -152,6 +153,7 @@ export interface TrackingValues extends LoginTrackingValues { surveyCategory2?: string // User's answer to a survey (first tier response) surveyResponse2?: string // User's answer to a survey appleAdsKeywordId?: string // Apple Search Ads attribution keyword ID + campaignId?: string // Marketing push campaign identifier (notification opens) // Conversion values conversionValues?: