diff --git a/__tests__/config/sentryConfig.test.ts b/__tests__/config/sentryConfig.test.ts index e4efa6513..9ca06e13b 100644 --- a/__tests__/config/sentryConfig.test.ts +++ b/__tests__/config/sentryConfig.test.ts @@ -5,11 +5,21 @@ import { PASSWORD_TYPO_MESSAGES, initializeSentry, scrubStrKeys, + syncSentryEnablement, updateSentryContext, } from "config/sentryConfig"; +// Shared client options object so tests can assert that opt-out flips +// `enabled` to false (the non-flushing disable) rather than calling close(). +const mockClientOptions: { enabled: boolean } = { enabled: true }; +const mockClientClose = jest.fn(); jest.mock("@sentry/react-native", () => ({ init: jest.fn(), + close: jest.fn(), + getClient: jest.fn(() => ({ + close: mockClientClose, + getOptions: () => mockClientOptions, + })), setContext: jest.fn(), setTag: jest.fn(), setUser: jest.fn(), @@ -34,8 +44,14 @@ const mockAnalyticsState: { isEnabled: boolean; userId: string | null } = { isEnabled: true, userId: null, }; +// Persisted-consent hydration flag. Default true so direct-call tests exercise +// the post-hydration path; a dedicated test flips it false to cover the race. +const mockHydration = { hydrated: true }; jest.mock("ducks/analytics", () => ({ - useAnalyticsStore: { getState: () => mockAnalyticsState }, + useAnalyticsStore: { + getState: () => mockAnalyticsState, + persist: { hasHydrated: () => mockHydration.hydrated }, + }, })); jest.mock("ducks/auth", () => ({ useAuthenticationStore: { @@ -84,6 +100,21 @@ const runBeforeSendWith = (event: Partial): ErrorEvent | null => { return initOpts.beforeSend(event as ErrorEvent, {}) as ErrorEvent | null; }; +// initializeSentry() is idempotent — it guards on an internal +// `isSentryInitialized` flag. Reset that module state before every test (drive +// it to "not initialized" via the public syncSentryEnablement path) so each +// test starts fresh and stays isolated. +beforeEach(() => { + mockHydration.hydrated = true; + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + mockAnalyticsState.isEnabled = true; + // Reset AFTER the syncSentryEnablement reset above, which flips enabled off + // when a prior test left the client initialized. + mockClientOptions.enabled = true; + jest.clearAllMocks(); +}); + describe("updateSentryContext user-identity consent gate", () => { beforeEach(() => { jest.clearAllMocks(); @@ -498,3 +529,87 @@ describe("sentryConfig.beforeSend filters", () => { }); }); }); + +// Mirrors the extension: the data-sharing toggle is the master switch for +// Sentry — off means the client is never initialized (cold start) and every +// event is dropped (runtime), and toggling flips the client on/off. +describe("data-sharing master switch", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAnalyticsState.isEnabled = true; + }); + + it("does NOT initialize Sentry when data sharing is off", () => { + mockAnalyticsState.isEnabled = false; + initializeSentry(); + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); + + it("initializes Sentry when data sharing is on", () => { + mockAnalyticsState.isEnabled = true; + initializeSentry(); + expect(mockedSentry.init).toHaveBeenCalledTimes(1); + }); + + it("beforeSend drops every event while data sharing is off, passes when on", () => { + mockAnalyticsState.isEnabled = true; + initializeSentry(); + const beforeSend = mockedSentry.init.mock.calls[0]?.[0]?.beforeSend; + expect(beforeSend).toBeDefined(); + + const event = { + exception: { values: [{ type: "Error", value: "a real bug" }] }, + } as unknown as ErrorEvent; + + // Enabled: a normal event passes through. + expect(beforeSend!(event, {})).not.toBeNull(); + + // Disabled: the same event is dropped. + mockAnalyticsState.isEnabled = false; + expect(beforeSend!(event, {})).toBeNull(); + }); + + it("syncSentryEnablement disables the client on toggle-off without flushing", () => { + // Bring the client up first so the internal flag is set. + mockAnalyticsState.isEnabled = true; + initializeSentry(); + jest.clearAllMocks(); + + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + expect(mockedSentry.setUser).toHaveBeenCalledWith(null); + // Disable by flipping enabled=false directly — NOT via close()/close(0), + // both of which full-drain the transport (PromiseBuffer.drain treats a + // falsy timeout as "wait for the whole queue"). We must not push out the + // backlog buffered under prior consent. + expect(mockClientOptions.enabled).toBe(false); + expect(mockClientClose).not.toHaveBeenCalled(); + expect(mockedSentry.close).not.toHaveBeenCalled(); + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); + + it("syncSentryEnablement re-initializes the client on toggle-on", () => { + // Drive to a shut-down state (init, then disable via sync). + mockAnalyticsState.isEnabled = true; + initializeSentry(); + mockAnalyticsState.isEnabled = false; + syncSentryEnablement(); + jest.clearAllMocks(); + + mockAnalyticsState.isEnabled = true; + syncSentryEnablement(); + expect(mockedSentry.init).toHaveBeenCalledTimes(1); + }); + + it("syncSentryEnablement is a no-op before persisted consent hydrates", () => { + // Returning opted-out user on Android: store default is `true`, but the + // persisted preference (still un-hydrated) is `false`. The subscription + // must not initialize Sentry off the pre-hydration default. + mockHydration.hydrated = false; + mockAnalyticsState.isEnabled = true; + + syncSentryEnablement(); + + expect(mockedSentry.init).not.toHaveBeenCalled(); + }); +}); diff --git a/android/app/build.gradle b/android/app/build.gradle index bf3def91d..e595b2741 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -141,7 +141,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1234567890 - versionName "1.20.27" + versionName "1.21.27" } buildTypes { diff --git a/ios/freighter-mobile.xcodeproj/project.pbxproj b/ios/freighter-mobile.xcodeproj/project.pbxproj index 4aa51913c..0dd191005 100644 --- a/ios/freighter-mobile.xcodeproj/project.pbxproj +++ b/ios/freighter-mobile.xcodeproj/project.pbxproj @@ -505,7 +505,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.27; + MARKETING_VERSION = 1.21.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -542,7 +542,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.27; + MARKETING_VERSION = 1.21.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -740,7 +740,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.27; + MARKETING_VERSION = 1.21.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -775,7 +775,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.20.27; + MARKETING_VERSION = 1.21.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/freighter-mobile/Info-Dev.plist b/ios/freighter-mobile/Info-Dev.plist index a7506988c..5d5829401 100644 --- a/ios/freighter-mobile/Info-Dev.plist +++ b/ios/freighter-mobile/Info-Dev.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.20.27 + 1.21.27 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/freighter-mobile/Info.plist b/ios/freighter-mobile/Info.plist index 3ef0fd0b9..07185ae24 100644 --- a/ios/freighter-mobile/Info.plist +++ b/ios/freighter-mobile/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.20.27 + 1.21.27 CFBundleSignature ???? CFBundleURLTypes diff --git a/package.json b/package.json index c4a936af7..869b75dfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freighter-mobile", - "version": "1.20.27", + "version": "1.21.27", "license": "Apache-2.0", "scripts": { "android": "yarn android-dev", diff --git a/src/components/App.tsx b/src/components/App.tsx index 66c16eab3..234c9ca58 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -88,7 +88,19 @@ export const App = (): React.JSX.Element => { } }; - initSentry(); + // Defer until the persisted data-sharing preference has hydrated from + // AsyncStorage. Zustand's pre-hydration default is `true` (Android), so + // running initSentry() before hydration could initialize Sentry for a + // returning opted-out user in the brief window before the stored `false` + // restores — breaking the cold-start opt-out. Mirrors the analytics + // module's onFinishHydration handling in services/analytics/core.ts. + if (useAnalyticsStore.persist.hasHydrated()) { + initSentry(); + return undefined; + } + return useAnalyticsStore.persist.onFinishHydration(() => { + initSentry(); + }); }, []); return ( diff --git a/src/components/screens/SettingsScreen/PreferencesScreen/PreferencesScreen.tsx b/src/components/screens/SettingsScreen/PreferencesScreen/PreferencesScreen.tsx index 7feb590a8..c31972d0f 100644 --- a/src/components/screens/SettingsScreen/PreferencesScreen/PreferencesScreen.tsx +++ b/src/components/screens/SettingsScreen/PreferencesScreen/PreferencesScreen.tsx @@ -98,9 +98,9 @@ const PreferencesScreen: React.FC = () => { const preferencesItems: PreferenceListItem[] = useMemo( () => [ { - title: t("preferences.anonymousDataSharing.title"), + title: t("preferences.usageDataSharing.title"), titleColor: themeColors.text.primary, - description: t("preferences.anonymousDataSharing.description"), + description: t("preferences.usageDataSharing.description"), trailingContent: renderAnalyticsToggle(), testID: "anonymous-data-sharing-item", }, diff --git a/src/config/sentryConfig.ts b/src/config/sentryConfig.ts index 93b798823..903e6eb6c 100644 --- a/src/config/sentryConfig.ts +++ b/src/config/sentryConfig.ts @@ -179,8 +179,21 @@ export const updateSentryContext = (): void => { } }; +// Tracks whether the Sentry client is currently running, so the data-sharing +// toggle can (re)initialize or shut it down idempotently (see +// syncSentryEnablement). +let isSentryInitialized = false; + /** - * Initialize Sentry with privacy-conscious configuration + * Initialize Sentry with privacy-conscious configuration. + * + * No-ops (does not call Sentry.init) in three cases: + * - during e2e tests; + * - if Sentry is already initialized (idempotent — safe to call from both + * App's startup effect and the analytics-store subscription regardless of + * order); + * - if data sharing is currently OFF (master switch; mirrors the extension). + * syncSentryEnablement() re-invokes this when the user turns sharing back on. */ export const initializeSentry = (): void => { // Disable Sentry during e2e tests @@ -188,6 +201,21 @@ export const initializeSentry = (): void => { return; } + // Idempotent: never run Sentry.init() twice. Both App's startup effect and + // the analytics-store subscription (via syncSentryEnablement) can reach here, + // and their order isn't guaranteed — guard the initializer itself so whichever + // runs second is a no-op. + if (isSentryInitialized) { + return; + } + + // Master switch: with data sharing OFF, do not initialize Sentry at all — + // mirrors the extension (no init when sharing is disabled), so nothing is + // reported. syncSentryEnablement() re-initializes if the user turns it on. + if (!useAnalyticsStore.getState().isEnabled) { + return; + } + Sentry.init({ dsn: SENTRY_CONFIG.DSN, sendDefaultPii: false, @@ -208,6 +236,13 @@ export const initializeSentry = (): void => { appHangTimeoutInterval: 5, beforeSend(event) { + // Master switch (defense-in-depth): if data sharing is off, drop every + // event. Covers the window between a runtime toggle-off and client + // teardown, and any event from a lingering or native-layer client. + if (!useAnalyticsStore.getState().isEnabled) { + return null; + } + // Drop or downgrade known-noise patterns before any PII scrubbing // or context updates. Each entry should describe a noise source // we've seen in production (third-party SDK quirks, native auth @@ -328,6 +363,51 @@ export const initializeSentry = (): void => { }, }); + isSentryInitialized = true; + // Set initial context and tags updateSentryContext(); }; + +/** + * Reconcile Sentry with the current data-sharing preference. Idempotent and + * safe to call on any analytics-store change: when sharing is ON it + * (re)initializes Sentry; when sharing is OFF it clears the user and disables + * the client so nothing further is reported. Mirrors the extension's + * init-when-allowed / disable-on-opt-out behavior. + */ +export const syncSentryEnablement = (): void => { + // Consent (isEnabled) is persisted to AsyncStorage and hydrates + // asynchronously; before hydration the store holds its default, which is + // `true` on Android (ANALYTICS_CONFIG.DEFAULT_ENABLED). This runs from the + // analytics-store subscription, which can fire pre-hydration (e.g. setUserId + // during identify), so reading isEnabled now could initialize Sentry for a + // returning opted-out user. Treat persisted consent as authoritative and + // skip until hydration completes — App's startup effect performs the initial + // reconcile from onFinishHydration. Mirrors syncIdentifyTraits in + // services/analytics/core.ts. + if (!useAnalyticsStore.persist.hasHydrated()) return; + + const { isEnabled } = useAnalyticsStore.getState(); + + if (isEnabled && !isSentryInitialized) { + initializeSentry(); + } else if (!isEnabled && isSentryInitialized) { + // Drop the identity, then disable the client WITHOUT flushing. Neither + // Sentry.close() nor client.close(0) work here: close() always runs + // flush(timeout) first, and PromiseBuffer.drain treats a falsy timeout + // (0 or undefined) as "wait until the whole queue drains" — so both would + // push out events buffered under prior consent (e.g. from an offline + // window), contradicting "off means off". Flip `enabled = false` directly + // (what close() does after its flush): captureEvent's `_isEnabled()` guard + // then blocks every future send, and beforeSend hard-drops anything caught + // in the gap. In-flight network requests already handed off can't be + // recalled, but nothing new is drained. + Sentry.setUser(null); + const client = Sentry.getClient(); + if (client) { + client.getOptions().enabled = false; + } + isSentryInitialized = false; + } +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index a774a525b..02514ace2 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -85,19 +85,19 @@ "deleteAccountConfirmMessage": "Make sure you have your recovery phrase in a safe place. You will not be able to recover your wallet without it." }, "preferences": { - "anonymousDataSharing": { - "title": "Anonymous data sharing", - "description": "Allow Freighter to collect limited information about usage. We will collect public keys, transaction amounts, and balances. This information will not be used for marketing or to identify you personally. We will not collect information such as name, address, email addresses, phone numbers, etc." + "usageDataSharing": { + "title": "Usage data sharing", + "description": "Help us improve Freighter by sharing usage, device, and activity data, including your public keys, IP address, and a persistent ID that links your wallet across extension and mobile, with our analytics and crash-reporting providers. You can turn this off at any time. See our Privacy Policy for details." }, "permissionModal": { "enable": { "title": "Enable Analytics", - "description": "To enable anonymous data sharing, you'll need to allow tracking permissions in your device settings.", + "description": "To enable usage data sharing, you'll need to allow tracking permissions in your device settings.", "instruction": "Tap \"Open Settings\" below, then find Freighter and enable \"Allow Tracking\"." }, "disable": { "title": "Disable Analytics", - "description": "To disable anonymous data sharing, you'll need to change tracking permissions in your device settings.", + "description": "To disable usage data sharing, you'll need to change tracking permissions in your device settings.", "instruction": "Tap \"Open Settings\" below, then find Freighter and disable \"Allow Tracking\"." }, "openSettings": "Open Settings", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 7f167fabd..95ce6607a 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -85,19 +85,19 @@ "deleteAccountConfirmMessage": "Certifique-se de que você tem sua frase de recuperação em um local seguro. Você não conseguirá recuperar sua carteira sem ela." }, "preferences": { - "anonymousDataSharing": { - "title": "Compartilhamento de dados anônimos", - "description": "Permitir que o Freighter colete informações limitadas sobre o uso. Coletaremos chaves públicas, valores de transações e saldos. Essas informações não serão usadas para marketing ou para identificar você pessoalmente. Não coletaremos informações como nome, endereço, e-mails, números de telefone, etc." + "usageDataSharing": { + "title": "Compartilhamento de dados de uso", + "description": "Ajude-nos a melhorar o Freighter compartilhando dados de uso, do dispositivo e de atividade — incluindo suas chaves públicas, endereço IP e um ID persistente que vincula sua carteira entre a extensão e o aplicativo móvel — com nossos provedores de análise de dados e de relatórios de falhas. Você pode desativar essa opção a qualquer momento. Consulte nossa Política de Privacidade para mais detalhes." }, "permissionModal": { "enable": { "title": "Habilitar Analytics", - "description": "Para habilitar o compartilhamento de dados anônimos, você precisará permitir permissões de rastreamento nas configurações do seu dispositivo.", + "description": "Para habilitar o compartilhamento de dados de uso, você precisará permitir permissões de rastreamento nas configurações do seu dispositivo.", "instruction": "Toque em \"Abrir Configurações\" abaixo, depois encontre Freighter e habilite \"Permitir Rastreamento\"." }, "disable": { "title": "Desabilitar Analytics", - "description": "Para desabilitar o compartilhamento de dados anônimos, você precisará alterar as permissões de rastreamento nas configurações do seu dispositivo.", + "description": "Para desabilitar o compartilhamento de dados de uso, você precisará alterar as permissões de rastreamento nas configurações do seu dispositivo.", "instruction": "Toque em \"Abrir Configurações\" abaixo, depois encontre Freighter e desabilite \"Permitir Rastreamento\"." }, "openSettings": "Abrir Configurações", diff --git a/src/services/analytics/core.ts b/src/services/analytics/core.ts index 0baaf179c..c0fded40b 100644 --- a/src/services/analytics/core.ts +++ b/src/services/analytics/core.ts @@ -3,6 +3,7 @@ import { Experiment } from "@amplitude/experiment-react-native-client"; import { hash } from "@stellar/stellar-sdk"; import { AnalyticsEvent, isScreenViewEvent } from "config/analyticsConfig"; import { logger } from "config/logger"; +import { syncSentryEnablement } from "config/sentryConfig"; import { useAnalyticsStore } from "ducks/analytics"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; @@ -505,6 +506,19 @@ useAnalyticsStore.subscribe((state) => { ); } + // Turn Sentry fully on/off with the same toggle (mirrors the extension): + // (re)initialize when sharing is enabled, shut the client down when disabled. + // Also covers consent that hydrates/enables AFTER the initial init ran. + try { + syncSentryEnablement(); + } catch (error) { + logger.error( + DEBUG_CONFIG.LOG_PREFIX, + "Failed to sync Sentry enablement", + error, + ); + } + // Consent may hydrate/enable AFTER init runs, so the in-init sync can // correctly skip (opted-out, nothing cached). Re-sync here so traits reach // Amplitude once consent becomes allowed. The dirty-check + consent-gate in