Skip to content

Commit a140d04

Browse files
github-actions[bot]piyalbasuclaude
authored
v1.21.27 (#952)
* chore: bump app version to v1.21.27 * fix(sentry): turn Sentry fully off when data sharing is off (mirror extension) (#955) Squashed for the v1.21.27 release branch (retargeted from main). Consent now controls Sentry initialization, event delivery, and runtime shutdown: - initializeSentry() no-ops during e2e, when already initialized (idempotent), or when data sharing is OFF (master switch). - beforeSend hard-drops every event while sharing is off. - syncSentryEnablement() reconciles on toggle: inits when turned on, and on opt-out clears the user and disables the client by flipping enabled=false (NOT close()/close(0), which full-drain the transport backlog); guarded on persist.hasHydrated() so the store subscription can't init off the pre-hydration Android default. - App startup + updateSentryContext consent-gate the Sentry user identity. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * copy(preferences): "Usage data sharing" toggle + accurate disclosure (#956) * copy(preferences): rename data-sharing toggle to "Usage data sharing" + accurate disclosure The toggle was labeled "Anonymous data sharing" and claimed we collect "public keys, transaction amounts, and balances" only. Relabel to "Usage data sharing" (i18n key anonymousDataSharing -> usageDataSharing) and replace the description with an accurate disclosure covering usage/device/activity data, public keys, IP address, and the persistent cross-platform ID sent to our analytics and crash-reporting providers. Also aligns the ATT permission-modal wording. Removes the stale pt strings so they fall back to English until a professional pt translation is added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(pt): translate usage-data-sharing copy + drop "anônimos" from ATT modal Addresses review feedback on #956, which caught that removing only the pt `anonymousDataSharing` block left the ATT permission modal saying "compartilhamento de dados anônimos" — the exact term this PR retires. A Portuguese user toggling the setting saw the modal contradict the (English fallback) toggle description, and still saw "anonymous", which is the inaccuracy the change exists to fix. Fixed by translating rather than deleting, so Portuguese users stay in Portuguese: - Restored `preferences.usageDataSharing` in pt with Brazilian Portuguese for title + description. - `permissionModal.enable.description` and `.disable.description`: "compartilhamento de dados anônimos" -> "compartilhamento de dados de uso", matching the en strings this PR already aligned. The reviewer's suggested fix was to delete the two modal descriptions too and let `fallbackLng: "en"` cover them. That resolves the contradiction but regresses two already-translated strings to English; since the toggle copy needed pt anyway, translating all four keeps the screen in one language. Copy is byte-identical to stellar/freighter#2922 (extension) for all four en/pt strings, verified programmatically — these two PRs exist to make the disclosure consistent across platforms, so drift between them would defeat the point. Terminology follows the existing catalog: "Política de Privacidade", "chave pública", "dispositivo", "carteira", "extensão", and the "o Freighter" article convention. Verified: 940/940 en/pt leaf-key parity (was 938/940 on this branch, so the gap this PR opened is closed); zero occurrences of "anônim" left in pt and zero of "anonymous" in en; `yarn lint:translations` reports no missing-translations errors; `yarn lint:ts` clean; prettier clean; 13 tests pass across PreferencesScreen.test.tsx and ducks/preferences.test.ts. The 6 `import/order` errors from `lint:translations` are pre-existing on main in parseTransaction.ts / buildAuthJwt.ts / deriveAuthKeypair.ts and unrelated to this change — confirmed by running eslint on those files at main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Piyal Basu <pbasu235@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c145f8 commit a140d04

12 files changed

Lines changed: 244 additions & 23 deletions

File tree

__tests__/config/sentryConfig.test.ts

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@ import {
55
PASSWORD_TYPO_MESSAGES,
66
initializeSentry,
77
scrubStrKeys,
8+
syncSentryEnablement,
89
updateSentryContext,
910
} from "config/sentryConfig";
1011

12+
// Shared client options object so tests can assert that opt-out flips
13+
// `enabled` to false (the non-flushing disable) rather than calling close().
14+
const mockClientOptions: { enabled: boolean } = { enabled: true };
15+
const mockClientClose = jest.fn();
1116
jest.mock("@sentry/react-native", () => ({
1217
init: jest.fn(),
18+
close: jest.fn(),
19+
getClient: jest.fn(() => ({
20+
close: mockClientClose,
21+
getOptions: () => mockClientOptions,
22+
})),
1323
setContext: jest.fn(),
1424
setTag: jest.fn(),
1525
setUser: jest.fn(),
@@ -34,8 +44,14 @@ const mockAnalyticsState: { isEnabled: boolean; userId: string | null } = {
3444
isEnabled: true,
3545
userId: null,
3646
};
47+
// Persisted-consent hydration flag. Default true so direct-call tests exercise
48+
// the post-hydration path; a dedicated test flips it false to cover the race.
49+
const mockHydration = { hydrated: true };
3750
jest.mock("ducks/analytics", () => ({
38-
useAnalyticsStore: { getState: () => mockAnalyticsState },
51+
useAnalyticsStore: {
52+
getState: () => mockAnalyticsState,
53+
persist: { hasHydrated: () => mockHydration.hydrated },
54+
},
3955
}));
4056
jest.mock("ducks/auth", () => ({
4157
useAuthenticationStore: {
@@ -84,6 +100,21 @@ const runBeforeSendWith = (event: Partial<ErrorEvent>): ErrorEvent | null => {
84100
return initOpts.beforeSend(event as ErrorEvent, {}) as ErrorEvent | null;
85101
};
86102

103+
// initializeSentry() is idempotent — it guards on an internal
104+
// `isSentryInitialized` flag. Reset that module state before every test (drive
105+
// it to "not initialized" via the public syncSentryEnablement path) so each
106+
// test starts fresh and stays isolated.
107+
beforeEach(() => {
108+
mockHydration.hydrated = true;
109+
mockAnalyticsState.isEnabled = false;
110+
syncSentryEnablement();
111+
mockAnalyticsState.isEnabled = true;
112+
// Reset AFTER the syncSentryEnablement reset above, which flips enabled off
113+
// when a prior test left the client initialized.
114+
mockClientOptions.enabled = true;
115+
jest.clearAllMocks();
116+
});
117+
87118
describe("updateSentryContext user-identity consent gate", () => {
88119
beforeEach(() => {
89120
jest.clearAllMocks();
@@ -498,3 +529,87 @@ describe("sentryConfig.beforeSend filters", () => {
498529
});
499530
});
500531
});
532+
533+
// Mirrors the extension: the data-sharing toggle is the master switch for
534+
// Sentry — off means the client is never initialized (cold start) and every
535+
// event is dropped (runtime), and toggling flips the client on/off.
536+
describe("data-sharing master switch", () => {
537+
beforeEach(() => {
538+
jest.clearAllMocks();
539+
mockAnalyticsState.isEnabled = true;
540+
});
541+
542+
it("does NOT initialize Sentry when data sharing is off", () => {
543+
mockAnalyticsState.isEnabled = false;
544+
initializeSentry();
545+
expect(mockedSentry.init).not.toHaveBeenCalled();
546+
});
547+
548+
it("initializes Sentry when data sharing is on", () => {
549+
mockAnalyticsState.isEnabled = true;
550+
initializeSentry();
551+
expect(mockedSentry.init).toHaveBeenCalledTimes(1);
552+
});
553+
554+
it("beforeSend drops every event while data sharing is off, passes when on", () => {
555+
mockAnalyticsState.isEnabled = true;
556+
initializeSentry();
557+
const beforeSend = mockedSentry.init.mock.calls[0]?.[0]?.beforeSend;
558+
expect(beforeSend).toBeDefined();
559+
560+
const event = {
561+
exception: { values: [{ type: "Error", value: "a real bug" }] },
562+
} as unknown as ErrorEvent;
563+
564+
// Enabled: a normal event passes through.
565+
expect(beforeSend!(event, {})).not.toBeNull();
566+
567+
// Disabled: the same event is dropped.
568+
mockAnalyticsState.isEnabled = false;
569+
expect(beforeSend!(event, {})).toBeNull();
570+
});
571+
572+
it("syncSentryEnablement disables the client on toggle-off without flushing", () => {
573+
// Bring the client up first so the internal flag is set.
574+
mockAnalyticsState.isEnabled = true;
575+
initializeSentry();
576+
jest.clearAllMocks();
577+
578+
mockAnalyticsState.isEnabled = false;
579+
syncSentryEnablement();
580+
expect(mockedSentry.setUser).toHaveBeenCalledWith(null);
581+
// Disable by flipping enabled=false directly — NOT via close()/close(0),
582+
// both of which full-drain the transport (PromiseBuffer.drain treats a
583+
// falsy timeout as "wait for the whole queue"). We must not push out the
584+
// backlog buffered under prior consent.
585+
expect(mockClientOptions.enabled).toBe(false);
586+
expect(mockClientClose).not.toHaveBeenCalled();
587+
expect(mockedSentry.close).not.toHaveBeenCalled();
588+
expect(mockedSentry.init).not.toHaveBeenCalled();
589+
});
590+
591+
it("syncSentryEnablement re-initializes the client on toggle-on", () => {
592+
// Drive to a shut-down state (init, then disable via sync).
593+
mockAnalyticsState.isEnabled = true;
594+
initializeSentry();
595+
mockAnalyticsState.isEnabled = false;
596+
syncSentryEnablement();
597+
jest.clearAllMocks();
598+
599+
mockAnalyticsState.isEnabled = true;
600+
syncSentryEnablement();
601+
expect(mockedSentry.init).toHaveBeenCalledTimes(1);
602+
});
603+
604+
it("syncSentryEnablement is a no-op before persisted consent hydrates", () => {
605+
// Returning opted-out user on Android: store default is `true`, but the
606+
// persisted preference (still un-hydrated) is `false`. The subscription
607+
// must not initialize Sentry off the pre-hydration default.
608+
mockHydration.hydrated = false;
609+
mockAnalyticsState.isEnabled = true;
610+
611+
syncSentryEnablement();
612+
613+
expect(mockedSentry.init).not.toHaveBeenCalled();
614+
});
615+
});

android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ android {
141141
minSdkVersion rootProject.ext.minSdkVersion
142142
targetSdkVersion rootProject.ext.targetSdkVersion
143143
versionCode 1234567890
144-
versionName "1.20.27"
144+
versionName "1.21.27"
145145
}
146146

147147
buildTypes {

ios/freighter-mobile.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@
505505
"$(inherited)",
506506
"@executable_path/Frameworks",
507507
);
508-
MARKETING_VERSION = 1.20.27;
508+
MARKETING_VERSION = 1.21.27;
509509
OTHER_LDFLAGS = (
510510
"$(inherited)",
511511
"-ObjC",
@@ -542,7 +542,7 @@
542542
"$(inherited)",
543543
"@executable_path/Frameworks",
544544
);
545-
MARKETING_VERSION = 1.20.27;
545+
MARKETING_VERSION = 1.21.27;
546546
OTHER_LDFLAGS = (
547547
"$(inherited)",
548548
"-ObjC",
@@ -740,7 +740,7 @@
740740
"$(inherited)",
741741
"@executable_path/Frameworks",
742742
);
743-
MARKETING_VERSION = 1.20.27;
743+
MARKETING_VERSION = 1.21.27;
744744
OTHER_LDFLAGS = (
745745
"$(inherited)",
746746
"-ObjC",
@@ -775,7 +775,7 @@
775775
"$(inherited)",
776776
"@executable_path/Frameworks",
777777
);
778-
MARKETING_VERSION = 1.20.27;
778+
MARKETING_VERSION = 1.21.27;
779779
OTHER_LDFLAGS = (
780780
"$(inherited)",
781781
"-ObjC",

ios/freighter-mobile/Info-Dev.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>1.20.27</string>
20+
<string>1.21.27</string>
2121
<key>CFBundleSignature</key>
2222
<string>????</string>
2323
<key>CFBundleURLTypes</key>

ios/freighter-mobile/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>1.20.27</string>
20+
<string>1.21.27</string>
2121
<key>CFBundleSignature</key>
2222
<string>????</string>
2323
<key>CFBundleURLTypes</key>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "freighter-mobile",
3-
"version": "1.20.27",
3+
"version": "1.21.27",
44
"license": "Apache-2.0",
55
"scripts": {
66
"android": "yarn android-dev",

src/components/App.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,19 @@ export const App = (): React.JSX.Element => {
8888
}
8989
};
9090

91-
initSentry();
91+
// Defer until the persisted data-sharing preference has hydrated from
92+
// AsyncStorage. Zustand's pre-hydration default is `true` (Android), so
93+
// running initSentry() before hydration could initialize Sentry for a
94+
// returning opted-out user in the brief window before the stored `false`
95+
// restores — breaking the cold-start opt-out. Mirrors the analytics
96+
// module's onFinishHydration handling in services/analytics/core.ts.
97+
if (useAnalyticsStore.persist.hasHydrated()) {
98+
initSentry();
99+
return undefined;
100+
}
101+
return useAnalyticsStore.persist.onFinishHydration(() => {
102+
initSentry();
103+
});
92104
}, []);
93105

94106
return (

src/components/screens/SettingsScreen/PreferencesScreen/PreferencesScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,9 @@ const PreferencesScreen: React.FC<PreferencesScreenProps> = () => {
9898
const preferencesItems: PreferenceListItem[] = useMemo(
9999
() => [
100100
{
101-
title: t("preferences.anonymousDataSharing.title"),
101+
title: t("preferences.usageDataSharing.title"),
102102
titleColor: themeColors.text.primary,
103-
description: t("preferences.anonymousDataSharing.description"),
103+
description: t("preferences.usageDataSharing.description"),
104104
trailingContent: renderAnalyticsToggle(),
105105
testID: "anonymous-data-sharing-item",
106106
},

src/config/sentryConfig.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,15 +179,43 @@ export const updateSentryContext = (): void => {
179179
}
180180
};
181181

182+
// Tracks whether the Sentry client is currently running, so the data-sharing
183+
// toggle can (re)initialize or shut it down idempotently (see
184+
// syncSentryEnablement).
185+
let isSentryInitialized = false;
186+
182187
/**
183-
* Initialize Sentry with privacy-conscious configuration
188+
* Initialize Sentry with privacy-conscious configuration.
189+
*
190+
* No-ops (does not call Sentry.init) in three cases:
191+
* - during e2e tests;
192+
* - if Sentry is already initialized (idempotent — safe to call from both
193+
* App's startup effect and the analytics-store subscription regardless of
194+
* order);
195+
* - if data sharing is currently OFF (master switch; mirrors the extension).
196+
* syncSentryEnablement() re-invokes this when the user turns sharing back on.
184197
*/
185198
export const initializeSentry = (): void => {
186199
// Disable Sentry during e2e tests
187200
if (isE2ETest) {
188201
return;
189202
}
190203

204+
// Idempotent: never run Sentry.init() twice. Both App's startup effect and
205+
// the analytics-store subscription (via syncSentryEnablement) can reach here,
206+
// and their order isn't guaranteed — guard the initializer itself so whichever
207+
// runs second is a no-op.
208+
if (isSentryInitialized) {
209+
return;
210+
}
211+
212+
// Master switch: with data sharing OFF, do not initialize Sentry at all —
213+
// mirrors the extension (no init when sharing is disabled), so nothing is
214+
// reported. syncSentryEnablement() re-initializes if the user turns it on.
215+
if (!useAnalyticsStore.getState().isEnabled) {
216+
return;
217+
}
218+
191219
Sentry.init({
192220
dsn: SENTRY_CONFIG.DSN,
193221
sendDefaultPii: false,
@@ -208,6 +236,13 @@ export const initializeSentry = (): void => {
208236
appHangTimeoutInterval: 5,
209237

210238
beforeSend(event) {
239+
// Master switch (defense-in-depth): if data sharing is off, drop every
240+
// event. Covers the window between a runtime toggle-off and client
241+
// teardown, and any event from a lingering or native-layer client.
242+
if (!useAnalyticsStore.getState().isEnabled) {
243+
return null;
244+
}
245+
211246
// Drop or downgrade known-noise patterns before any PII scrubbing
212247
// or context updates. Each entry should describe a noise source
213248
// we've seen in production (third-party SDK quirks, native auth
@@ -328,6 +363,51 @@ export const initializeSentry = (): void => {
328363
},
329364
});
330365

366+
isSentryInitialized = true;
367+
331368
// Set initial context and tags
332369
updateSentryContext();
333370
};
371+
372+
/**
373+
* Reconcile Sentry with the current data-sharing preference. Idempotent and
374+
* safe to call on any analytics-store change: when sharing is ON it
375+
* (re)initializes Sentry; when sharing is OFF it clears the user and disables
376+
* the client so nothing further is reported. Mirrors the extension's
377+
* init-when-allowed / disable-on-opt-out behavior.
378+
*/
379+
export const syncSentryEnablement = (): void => {
380+
// Consent (isEnabled) is persisted to AsyncStorage and hydrates
381+
// asynchronously; before hydration the store holds its default, which is
382+
// `true` on Android (ANALYTICS_CONFIG.DEFAULT_ENABLED). This runs from the
383+
// analytics-store subscription, which can fire pre-hydration (e.g. setUserId
384+
// during identify), so reading isEnabled now could initialize Sentry for a
385+
// returning opted-out user. Treat persisted consent as authoritative and
386+
// skip until hydration completes — App's startup effect performs the initial
387+
// reconcile from onFinishHydration. Mirrors syncIdentifyTraits in
388+
// services/analytics/core.ts.
389+
if (!useAnalyticsStore.persist.hasHydrated()) return;
390+
391+
const { isEnabled } = useAnalyticsStore.getState();
392+
393+
if (isEnabled && !isSentryInitialized) {
394+
initializeSentry();
395+
} else if (!isEnabled && isSentryInitialized) {
396+
// Drop the identity, then disable the client WITHOUT flushing. Neither
397+
// Sentry.close() nor client.close(0) work here: close() always runs
398+
// flush(timeout) first, and PromiseBuffer.drain treats a falsy timeout
399+
// (0 or undefined) as "wait until the whole queue drains" — so both would
400+
// push out events buffered under prior consent (e.g. from an offline
401+
// window), contradicting "off means off". Flip `enabled = false` directly
402+
// (what close() does after its flush): captureEvent's `_isEnabled()` guard
403+
// then blocks every future send, and beforeSend hard-drops anything caught
404+
// in the gap. In-flight network requests already handed off can't be
405+
// recalled, but nothing new is drained.
406+
Sentry.setUser(null);
407+
const client = Sentry.getClient();
408+
if (client) {
409+
client.getOptions().enabled = false;
410+
}
411+
isSentryInitialized = false;
412+
}
413+
};

src/i18n/locales/en/translations.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,19 @@
8585
"deleteAccountConfirmMessage": "Make sure you have your recovery phrase in a safe place. You will not be able to recover your wallet without it."
8686
},
8787
"preferences": {
88-
"anonymousDataSharing": {
89-
"title": "Anonymous data sharing",
90-
"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."
88+
"usageDataSharing": {
89+
"title": "Usage data sharing",
90+
"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."
9191
},
9292
"permissionModal": {
9393
"enable": {
9494
"title": "Enable Analytics",
95-
"description": "To enable anonymous data sharing, you'll need to allow tracking permissions in your device settings.",
95+
"description": "To enable usage data sharing, you'll need to allow tracking permissions in your device settings.",
9696
"instruction": "Tap \"Open Settings\" below, then find Freighter and enable \"Allow Tracking\"."
9797
},
9898
"disable": {
9999
"title": "Disable Analytics",
100-
"description": "To disable anonymous data sharing, you'll need to change tracking permissions in your device settings.",
100+
"description": "To disable usage data sharing, you'll need to change tracking permissions in your device settings.",
101101
"instruction": "Tap \"Open Settings\" below, then find Freighter and disable \"Allow Tracking\"."
102102
},
103103
"openSettings": "Open Settings",

0 commit comments

Comments
 (0)