Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/components/Views/ChoosePassword/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
Checkbox,
} from '@metamask/design-system-react-native';
import StorageWrapper from '../../../store/storage-wrapper';
import { useDispatch } from 'react-redux';
import { useDispatch, useStore } from 'react-redux';
import { saveOnboardingEvent as saveEvent } from '../../../actions/onboarding';
import {
passwordSet as passwordSetAction,
Expand Down Expand Up @@ -95,6 +95,8 @@ import { UserProfileProperty } from '../../../util/metrics/UserSettingsAnalytics
import generateDeviceAnalyticsMetaData, {
UserSettingsAnalyticsMetaData as generateUserSettingsAnalyticsMetaData,
} from '../../../util/metrics';
import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';
import type { RootState } from '../../../reducers';

interface KeyringState {
type: string;
Expand Down Expand Up @@ -125,6 +127,7 @@ const ChoosePassword = () => {
useRoute<RouteProp<{ params: ChoosePasswordRouteParams }, 'params'>>();

const dispatch = useDispatch();
const store = useStore<RootState>();
const metrics = useAnalytics();

const [isSelected, setIsSelected] = useState(false);
Expand Down Expand Up @@ -489,6 +492,7 @@ const ChoosePassword = () => {
wallet_setup_type: 'new',
new_wallet: true,
account_type: accountType,
...getWalletSetupCompletedAttributionProperties(store.getState()),
});
endTrace({ name: TraceName.OnboardingSRPAccountCreationTime });
} catch (err) {
Expand All @@ -505,6 +509,7 @@ const ChoosePassword = () => {
handlePostWalletCreation,
handleWalletCreationError,
metrics,
store,
]);

const onPasswordChange = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { connect, useStore } from 'react-redux';
import {
KeyboardAwareScrollView,
KeyboardProvider,
Expand Down Expand Up @@ -97,6 +97,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import SrpInputGrid from '../../UI/SrpInputGrid';
import SrpWordSuggestions from '../../UI/SrpWordSuggestions';
import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';

const SCREEN_WIDTH = Dimensions.get('window').width;

Expand All @@ -115,6 +116,8 @@ const ImportFromSecretRecoveryPhrase = ({
}) => {
const { colors, themeAppearance } = useTheme();
const tw = useTailwind();
/** @type {import('redux').Store<import('../../../reducers').RootState>} */
const store = useStore();

const confirmPasswordInput = useRef();

Expand Down Expand Up @@ -464,6 +467,7 @@ const ImportFromSecretRecoveryPhrase = ({
wallet_setup_type: 'import',
new_wallet: false,
account_type: AccountType.Imported,
...getWalletSetupCompletedAttributionProperties(store.getState()),
});

fetchAccountsWithActivity();
Expand Down
128 changes: 128 additions & 0 deletions app/components/Views/Onboarding/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,13 @@ jest.mock('../../../core/Analytics/MetaMetrics', () => ({
.MetaMetricsEvents,
}));

jest.mock(
'../../../util/analytics/getWalletSetupCompletedAttributionProperties',
() => ({
getWalletSetupCompletedAttributionProperties: jest.fn().mockReturnValue({}),
}),
);

interface EventBuilder {
addProperties: () => EventBuilder;
build: () => Record<string, unknown>;
Expand Down Expand Up @@ -3056,6 +3063,127 @@ describe('Onboarding', () => {

Platform.OS = 'ios';
});

describe('Social Login Completed attribution', () => {
const mockGetWalletSetupCompletedAttributionProperties = jest.requireMock(
'../../../util/analytics/getWalletSetupCompletedAttributionProperties',
).getWalletSetupCompletedAttributionProperties;

beforeEach(() => {
mockSeedlessOnboardingEnabled.mockReturnValue(true);
(StorageWrapper.getItem as jest.Mock).mockResolvedValue(null);
(Device.isIos as jest.Mock).mockReturnValue(false);
(Device.comparePlatformVersionTo as jest.Mock).mockReturnValue(1);
Platform.OS = 'android';
mockGetWalletSetupCompletedAttributionProperties.mockClear();
});

afterEach(() => {
jest.clearAllMocks();
mockNavigate.mockReset();
mockSeedlessOnboardingEnabled.mockReset();
Platform.OS = 'ios';
});

it('reads attribution properties from store when Social Login Completed fires with persisted attribution', async () => {
const mockUtmProperties = {
utm_source: 'google_ads',
utm_medium: 'cpc',
utm_campaign: 'mobile_acquisition',
};
mockGetWalletSetupCompletedAttributionProperties.mockReturnValue(
mockUtmProperties,
);

const mockCreateLoginHandlerLocal = jest.requireMock(
'../../../core/OAuthService/OAuthLoginHandlers',
).createLoginHandler;
const mockOAuthServiceLocal = jest.requireMock(
'../../../core/OAuthService/OAuthService',
).default;

mockCreateLoginHandlerLocal.mockReturnValue('mockGoogleHandler');
mockOAuthServiceLocal.handleOAuthLogin.mockResolvedValue({
type: 'success',
existingUser: false,
accountName: 'test@example.com',
});

const { getByTestId } = renderScreen(
Onboarding,
{ name: 'Onboarding' },
{ state: mockInitialState },
);

const createWalletButton = getByTestId(
OnboardingSelectorIDs.NEW_WALLET_BUTTON,
);
await act(async () => {
fireEvent.press(createWalletButton);
});

const navCall = mockNavigate.mock.calls.find(
(call) =>
call[0] === Routes.MODAL.ROOT_MODAL_FLOW &&
call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET,
);
const googleOAuthFunction = navCall[1].params.onPressContinueWithGoogle;

await act(async () => {
await googleOAuthFunction(true);
});

expect(
mockGetWalletSetupCompletedAttributionProperties,
).toHaveBeenCalledWith(mockInitialState);
});

it('reads attribution properties from store when Apple Social Login Completed fires', async () => {
mockGetWalletSetupCompletedAttributionProperties.mockReturnValue({});

const mockCreateLoginHandlerLocal = jest.requireMock(
'../../../core/OAuthService/OAuthLoginHandlers',
).createLoginHandler;
const mockOAuthServiceLocal = jest.requireMock(
'../../../core/OAuthService/OAuthService',
).default;

mockCreateLoginHandlerLocal.mockReturnValue('mockAppleHandler');
mockOAuthServiceLocal.handleOAuthLogin.mockResolvedValue({
type: 'success',
existingUser: false,
accountName: 'test@icloud.com',
});

const { getByTestId } = renderScreen(
Onboarding,
{ name: 'Onboarding' },
{ state: mockInitialState },
);

const createWalletButton = getByTestId(
OnboardingSelectorIDs.NEW_WALLET_BUTTON,
);
await act(async () => {
fireEvent.press(createWalletButton);
});

const navCall = mockNavigate.mock.calls.find(
(call) =>
call[0] === Routes.MODAL.ROOT_MODAL_FLOW &&
call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET,
);
const appleOAuthFunction = navCall[1].params.onPressContinueWithApple;

await act(async () => {
await appleOAuthFunction(true);
});

expect(
mockGetWalletSetupCompletedAttributionProperties,
).toHaveBeenCalledWith(mockInitialState);
});
});
});

describe('Error Report Sent Notification', () => {
Expand Down
2 changes: 2 additions & 0 deletions app/components/Views/Onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
presentIosGoogleLoginVersionWarningSheet,
} from './OnboardingIosPrompt';
import { SafeAreaView } from 'react-native-safe-area-context';
import { getWalletSetupCompletedAttributionProperties } from '../../../util/analytics/getWalletSetupCompletedAttributionProperties';
import FoxAnimation from '../../UI/FoxAnimation/FoxAnimation';
import OnboardingAnimation from '../../UI/OnboardingAnimation/OnboardingAnimation';
import {
Expand Down Expand Up @@ -460,6 +461,7 @@ const Onboarding = () => {

track(MetaMetricsEvents.SOCIAL_LOGIN_COMPLETED, {
account_type: accountType,
...getWalletSetupCompletedAttributionProperties(store.getState()),
});
if (createWallet) {
if (result.existingUser) {
Expand Down
87 changes: 79 additions & 8 deletions app/core/AppStateEventListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { processAttribution } from './processAttribution';
import { AnalyticsEventBuilder } from '../util/analytics/AnalyticsEventBuilder';
import { analytics } from '../util/analytics/analytics';
import ReduxService, { ReduxStore } from './redux';
import { saveAttribution } from './redux/slices/attribution';

function createMockReduxStore(
dataCollectionForMarketing: boolean | null = true,
): ReduxStore {
return {
dispatch: jest.fn(),
getState: jest.fn(() => ({
security: { dataCollectionForMarketing },
})),
} as unknown as ReduxStore;
}

jest.mock('./DeeplinkManager/utils/extractURLParams', () => jest.fn());

Expand Down Expand Up @@ -86,9 +98,8 @@ describe('AppStateEventListener', () => {
});

it('tracks event when app becomes active and attribution data is available', () => {
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
const mockStore = createMockReduxStore();
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
const mockAttribution = {
attributionId: 'test123',
utm_source: 'source',
Expand All @@ -104,6 +115,14 @@ describe('AppStateEventListener', () => {
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockStore.dispatch).toHaveBeenCalledWith(
saveAttribution({
attribution_id: 'test123',
utm_source: 'source',
utm_medium: 'medium',
utm_campaign: 'campaign',
}),
);
expect(AnalyticsEventBuilder.createEventBuilder).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
);
Expand All @@ -114,12 +133,64 @@ describe('AppStateEventListener', () => {
expect(mockAnalytics.trackEvent).toHaveBeenCalledWith(
mockEventBuilder.build(),
);
expect(appStateManager.currentDeeplink).toBeNull();
});

it('does not persist attribution when marketing consent is not opted in', () => {
const mockStore = createMockReduxStore(false);
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
(processAttribution as jest.Mock).mockReturnValue({
attributionId: 'test123',
utm_source: 'source',
});

appStateManager.setCurrentDeeplink('metamask://connect');
mockAppStateListener('background');
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockStore.dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: saveAttribution.type }),
);
});

it('clears currentDeeplink after processing so a later resume does not re-save attribution', () => {
const mockStore = createMockReduxStore();
jest.spyOn(ReduxService, 'store', 'get').mockReturnValue(mockStore);
(processAttribution as jest.Mock)
.mockReturnValueOnce({
attributionId: 'x',
utm_source: 'y',
})
.mockReturnValue(undefined);

appStateManager.setCurrentDeeplink('metamask://x');
mockAppStateListener('background');
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(appStateManager.currentDeeplink).toBeNull();
expect(mockStore.dispatch).toHaveBeenCalledWith(
saveAttribution({
attribution_id: 'x',
utm_source: 'y',
}),
);

(mockStore.dispatch as jest.Mock).mockClear();
mockAppStateListener('background');
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockStore.dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: saveAttribution.type }),
);
});

it('tracks event when app becomes active without attribution data', () => {
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
.mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);

mockAppStateListener('background');
Expand Down Expand Up @@ -164,7 +235,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
.mockReturnValue(createMockReduxStore());
const testError = new Error('Test error');
(processAttribution as jest.Mock).mockImplementation(() => {
throw testError;
Expand Down Expand Up @@ -206,7 +277,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
.mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);

mockAppStateListener('background');
Expand All @@ -225,7 +296,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
.mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);

// Simulate iOS background → inactive → active sequence
Expand All @@ -244,7 +315,7 @@ describe('AppStateEventListener', () => {
jest.clearAllMocks();
jest
.spyOn(ReduxService, 'store', 'get')
.mockReturnValue({} as unknown as ReduxStore);
.mockReturnValue(createMockReduxStore());
(processAttribution as jest.Mock).mockReturnValue(undefined);

// Simulate iOS system permission dialog: active → inactive → active
Expand Down
Loading
Loading