Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: app event manager and attribution id parameters #11318

Merged
merged 18 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions app/components/Nav/App/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
///: END:ONLY_INCLUDE_IF
import OptionsSheet from '../../UI/SelectOptionSheet/OptionsSheet';
import FoxLoader from '../../../components/UI/FoxLoader';
import { AppStateEventProcessor } from '../../../core/AppStateEventListener';

const clearStackNavigatorOptions = {
headerShown: false,
Expand Down Expand Up @@ -317,6 +318,7 @@
const dispatch = useDispatch();
const sdkInit = useRef();
const [onboarded, setOnboarded] = useState(false);

const triggerSetCurrentRoute = (route) => {
dispatch(setCurrentRoute(route));
if (route === 'Wallet' || route === 'BrowserView') {
Expand Down Expand Up @@ -369,6 +371,7 @@
const deeplink = params?.['+non_branch_link'] || uri || null;
try {
if (deeplink) {
AppStateEventProcessor.setCurrentDeeplink(deeplink);
SharedDeeplinkManager.parse(deeplink, {
origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK,
});
Expand All @@ -392,6 +395,7 @@
});
}, [handleDeeplink]);


useEffect(() => {
if (navigator) {
// Initialize deep link manager
Expand All @@ -406,6 +410,7 @@
},
dispatch,
});

if (!prevNavigator.current) {
// Setup navigator with Sentry instrumentation
routingInstrumentation.registerNavigationContainer(navigator);
Expand Down Expand Up @@ -540,7 +545,7 @@
}
};

const DetectedTokensFlow = () => (

Check warning on line 548 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator
mode={'modal'}
screenOptions={clearStackNavigatorOptions}
Expand All @@ -554,7 +559,7 @@
</Stack.Navigator>
);

const RootModalFlow = () => (

Check warning on line 562 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator mode={'modal'} screenOptions={clearStackNavigatorOptions}>
<Stack.Screen
name={Routes.MODAL.WALLET_ACTIONS}
Expand Down Expand Up @@ -693,7 +698,7 @@
</Stack.Navigator>
);

const ImportPrivateKeyView = () => (

Check warning on line 701 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator
screenOptions={{
headerShown: false,
Expand All @@ -714,7 +719,7 @@
</Stack.Navigator>
);

const ConnectQRHardwareFlow = () => (

Check warning on line 722 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator
screenOptions={{
headerShown: false,
Expand All @@ -724,7 +729,7 @@
</Stack.Navigator>
);

const LedgerConnectFlow = () => (

Check warning on line 732 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator
screenOptions={{
headerShown: false,
Expand All @@ -738,7 +743,7 @@
</Stack.Navigator>
);

const ConnectHardwareWalletFlow = () => (

Check warning on line 746 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator name="ConnectHardwareWallet">
<Stack.Screen
name={Routes.HW.SELECT_DEVICE}
Expand All @@ -748,14 +753,14 @@
</Stack.Navigator>
);

const EditAccountNameFlow = () => (

Check warning on line 756 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator>
<Stack.Screen name="EditAccountName" component={EditAccountName} />
</Stack.Navigator>
);

// eslint-disable-next-line react/prop-types
const AddNetworkFlow = ({ route }) => (

Check warning on line 763 in app/components/Nav/App/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “App” and pass data as props
<Stack.Navigator>
<Stack.Screen
name="AddNetwork"
Expand Down
4 changes: 4 additions & 0 deletions app/core/Analytics/MetaMetrics.events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const ONBOARDING_WIZARD_STEP_DESCRIPTION: { [key: number]: string } = {
* Analytics Tracking Events
*/
enum EVENT_NAME {
// App
APP_OPENED = 'App Opened',
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved

// Error
ERROR = 'Error occurred',
ERROR_SCREEN_VIEWED = 'Error Screen Viewed',
Expand Down Expand Up @@ -439,6 +442,7 @@ enum ACTIONS {
}

const events = {
APP_OPENED: generateOpt(EVENT_NAME.APP_OPENED),
ERROR: generateOpt(EVENT_NAME.ERROR),
ERROR_SCREEN_VIEWED: generateOpt(EVENT_NAME.ERROR_SCREEN_VIEWED),
APPROVAL_STARTED: generateOpt(EVENT_NAME.APPROVAL_STARTED),
Expand Down
168 changes: 168 additions & 0 deletions app/core/AppStateEventListener.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { AppState, AppStateStatus } from 'react-native';
import { store } from '../store';
import Logger from '../util/Logger';
import { MetaMetrics, MetaMetricsEvents } from './Analytics';
import { AppStateEventListener } from './AppStateEventListener';
import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams';

jest.mock('react-native', () => ({
AppState: {
addEventListener: jest.fn(),
currentState: 'active',
},
}));

jest.mock('./Analytics', () => ({
MetaMetrics: {
getInstance: jest.fn(),
},
MetaMetricsEvents: {
APP_OPENED: 'APP_OPENED',
},
}));

jest.mock('../store', () => ({
store: {
getState: jest.fn(),
},
}));

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

jest.mock('../util/Logger', () => ({
error: jest.fn(),
}));

describe('AppStateEventListener', () => {
let appStateManager: AppStateEventListener;
let mockAppStateListener: (state: AppStateStatus) => void;
let mockTrackEvent: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockTrackEvent = jest.fn();
(MetaMetrics.getInstance as jest.Mock).mockReturnValue({
trackEvent: mockTrackEvent,
});
(AppState.addEventListener as jest.Mock).mockImplementation((_, listener) => {
mockAppStateListener = listener;
return { remove: jest.fn() };
});
appStateManager = new AppStateEventListener();
appStateManager.init(store);
});

afterEach(() => {
jest.useRealTimers();
});

it('subscribes to AppState changes on instantiation', () => {
expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});

it('throws error if store is initialized more than once', () => {
expect(() => appStateManager.init(store)).toThrow('store is already initialized');
expect(Logger.error).toHaveBeenCalledWith(new Error('store is already initialized'));
});

it('tracks event when app becomes active and conditions are met', () => {
(store.getState as jest.Mock).mockReturnValue({
security: { dataCollectionForMarketing: true },
});
(extractURLParams as jest.Mock).mockReturnValue({ params: { attributionId: 'test123' } });

appStateManager.setCurrentDeeplink('metamask://connect?attributionId=test123');
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
{ attributionId: 'test123' },
true
);
});

it('does not track event when data collection is disabled', () => {
(store.getState as jest.Mock).mockReturnValue({
security: { dataCollectionForMarketing: false },
});

mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
{},
true
);
});

it('does not track event when there is no deeplink', () => {
(store.getState as jest.Mock).mockReturnValue({
security: { dataCollectionForMarketing: true },
});

mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
{ attributionId: undefined },
true
);
});

it('handles errors gracefully', () => {
(store.getState as jest.Mock).mockImplementation(() => {
throw new Error('Test error');
});

mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(Logger.error).toHaveBeenCalledWith(
expect.any(Error),
'AppStateManager: Error processing app state change'
);
expect(mockTrackEvent).not.toHaveBeenCalled();
});

it('cleans up the AppState listener on cleanup', () => {
const mockRemove = jest.fn();
(AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove });

appStateManager = new AppStateEventListener();
appStateManager.init(store);
appStateManager.cleanup();

expect(mockRemove).toHaveBeenCalled();
});

it('should not process app state change when app is not becoming active', () => {
mockAppStateListener('background');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).not.toHaveBeenCalled();
});

it('should not process app state change when app state has not changed', () => {
mockAppStateListener('active');
jest.advanceTimersByTime(2000);
mockTrackEvent.mockClear();

mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).not.toHaveBeenCalled();
});

it('should handle undefined store gracefully', () => {
appStateManager = new AppStateEventListener();
mockAppStateListener('active');
jest.advanceTimersByTime(2000);

expect(mockTrackEvent).not.toHaveBeenCalled();
expect(Logger.error).toHaveBeenCalledWith(new Error('store is not initialized'));
});
});
71 changes: 71 additions & 0 deletions app/core/AppStateEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { AppState, AppStateStatus } from 'react-native';
import { Store } from 'redux';
import { RootState } from '../reducers';
import Logger from '../util/Logger';
import { MetaMetrics, MetaMetricsEvents } from './Analytics';
import { processAttribution } from './processAttribution';
import DevLogger from './SDKConnect/utils/DevLogger';

export class AppStateEventListener {
private appStateSubscription: ReturnType<typeof AppState.addEventListener>;
private currentDeeplink: string | null = null;
private lastAppState: AppStateStatus = AppState.currentState;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private store: Store<RootState, any> | undefined;

constructor() {
this.lastAppState = AppState.currentState;
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
}

init(store: Store) {
if(this.store) {
Logger.error(new Error('store is already initialized'));
throw new Error('store is already initialized');
}
this.store = store;
}

public setCurrentDeeplink(deeplink: string | null) {
this.currentDeeplink = deeplink;
}

private handleAppStateChange = (nextAppState: AppStateStatus) => {
if (
nextAppState === 'active' &&
this.lastAppState !== nextAppState
) {
// delay to allow time for the deeplink to be set
setTimeout(() => {
this.processAppStateChange();
}, 2000);
}
this.lastAppState = nextAppState;
};

private processAppStateChange = () => {
if (!this.store) {
Logger.error(new Error('store is not initialized'));
return;
}

try {
const attributionId = processAttribution({ currentDeeplink: this.currentDeeplink, store: this.store });
DevLogger.log(`AppStateManager:: processAppStateChange:: sending event 'APP_OPENED' attributionId=${attributionId}`);
MetaMetrics.getInstance().trackEvent(
MetaMetricsEvents.APP_OPENED,
{ attributionId },
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
true
);
} catch (error) {
Logger.error(error as Error, 'AppStateManager: Error processing app state change');
}
};

public cleanup() {
this.appStateSubscription.remove();
}
}

export const AppStateEventProcessor = new AppStateEventListener();
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('extractURLParams', () => {
channelId: '123',
comm: 'test',
v: '2',
attributionId: '',
};

mockUrlParser.mockImplementation(
Expand Down Expand Up @@ -81,6 +82,7 @@ describe('extractURLParams', () => {
comm: '',
pubkey: '',
v: '',
attributionId: '',
});
});

Expand Down Expand Up @@ -113,6 +115,7 @@ describe('extractURLParams', () => {
comm: '',
pubkey: '',
v: '',
attributionId: '',
});

expect(alertSpy).toHaveBeenCalledWith(
Expand All @@ -133,6 +136,7 @@ describe('extractURLParams', () => {
rpc: '',
sdkVersion: '',
pubkey: 'xyz',
attributionId: '',
};

mockUrlParser.mockImplementation(
Expand Down
2 changes: 2 additions & 0 deletions app/core/DeeplinkManager/ParseManager/extractURLParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface DeeplinkUrlParams {
message?: string;
originatorInfo?: string;
request?: string;
attributionId?: string;
account?: string; // This is the format => "address@chainId"
}

Expand All @@ -39,6 +40,7 @@ function extractURLParams(url: string) {
originatorInfo: '',
channelId: '',
comm: '',
attributionId: '',
};

DevLogger.log(`extractParams:: urlObj`, urlObj);
Expand Down
Loading
Loading