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 8 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
13 changes: 13 additions & 0 deletions app/components/Nav/App/index.js
Cal-L marked this conversation as resolved.
Show resolved Hide resolved
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 AppStateManager from '../../../core/AppStateManager';

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

const triggerSetCurrentRoute = (route) => {
dispatch(setCurrentRoute(route));
if (route === 'Wallet' || route === 'BrowserView') {
Expand All @@ -325,6 +328,8 @@
}
};

useEffect(() => () => appStateManager.current.cleanup(), []);

useEffect(() => {
if (prevNavigator.current || !navigator) return;
const appTriggeredAuth = async () => {
Expand Down Expand Up @@ -369,6 +374,7 @@
const deeplink = params?.['+non_branch_link'] || uri || null;
try {
if (deeplink) {
appStateManager.current.setCurrentDeeplink(deeplink);
SharedDeeplinkManager.parse(deeplink, {
origin: AppConstants.DEEPLINKS.ORIGIN_DEEPLINK,
});
Expand All @@ -392,6 +398,12 @@
});
}, [handleDeeplink]);

useEffect(() => {
if (navigator && !appStateManager.current) {
appStateManager.current = new AppStateManager();
}
}, [navigator]);

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

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

const DetectedTokensFlow = () => (

Check warning on line 556 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 +567,7 @@
</Stack.Navigator>
);

const RootModalFlow = () => (

Check warning on line 570 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 +706,7 @@
</Stack.Navigator>
);

const ImportPrivateKeyView = () => (

Check warning on line 709 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 +727,7 @@
</Stack.Navigator>
);

const ConnectQRHardwareFlow = () => (

Check warning on line 730 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 +737,7 @@
</Stack.Navigator>
);

const LedgerConnectFlow = () => (

Check warning on line 740 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 +751,7 @@
</Stack.Navigator>
);

const ConnectHardwareWalletFlow = () => (

Check warning on line 754 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 +761,14 @@
</Stack.Navigator>
);

const EditAccountNameFlow = () => (

Check warning on line 764 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 771 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
152 changes: 152 additions & 0 deletions app/core/AppStateManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { AppState, AppStateStatus } from 'react-native';
import { MetaMetrics, MetaMetricsEvents } from './Analytics';
import { store } from '../store';
import AppStateManager from './AppStateManager';
import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams';
import Logger from '../util/Logger';

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('AppStateManager', () => {
let appStateManager: AppStateManager;
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 AppStateManager();
});

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

it('should subscribe to AppState changes on instantiation', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
});

it('should track event when app becomes active and conditions are met', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
(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('should not track event when data collection is disabled', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
(store.getState as jest.Mock).mockReturnValue({
security: { dataCollectionForMarketing: false },
});

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

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

it('should not track event when there is no deeplink', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
(store.getState as jest.Mock).mockReturnValue({
security: { dataCollectionForMarketing: true },
});

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

expect(mockTrackEvent).toHaveBeenCalledWith(
MetaMetricsEvents.APP_OPENED,
{ attributionId: undefined },
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
true
);
});

it('should handle errors gracefully', () => {
abretonc7s marked this conversation as resolved.
Show resolved Hide resolved
(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('should clean up the AppState listener on cleanup', () => {
abretonc7s marked this conversation as resolved.
Show resolved Hide resolved
const mockRemove = jest.fn();
(AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove });

appStateManager = new AppStateManager();
appStateManager.cleanup();

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

it('should not process app state change when app is not becoming active', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
mockAppStateListener('background');
jest.advanceTimersByTime(2000);

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

it('should not process app state change when app state has not changed', () => {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
mockAppStateListener('active');
jest.advanceTimersByTime(2000);
mockTrackEvent.mockClear();

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

expect(mockTrackEvent).not.toHaveBeenCalled();
});
});
61 changes: 61 additions & 0 deletions app/core/AppStateManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { AppState, AppStateStatus } from 'react-native';
import { store } from '../store';
import Logger from '../util/Logger';
import { MetaMetrics, MetaMetricsEvents } from './Analytics';
import extractURLParams from './DeeplinkManager/ParseManager/extractURLParams';
import DevLogger from './SDKConnect/utils/DevLogger';

class AppStateManager {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
private appStateSubscription: ReturnType<typeof AppState.addEventListener>;
private currentDeeplink: string | null = null;
private lastAppState: AppStateStatus = AppState.currentState;

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

public setCurrentDeeplink(deeplink: string | null) {
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
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 = () => {
try {
const state = store.getState();
const isMarketingEnabled = state.security.dataCollectionForMarketing;

let attributionId: string | undefined;
if (isMarketingEnabled && this.currentDeeplink) {
const { params } = extractURLParams(this.currentDeeplink);
attributionId = params.attributionId;
}
DevLogger.log(`AppStateManager:: processAppStateChange:: sending event 'APP_OPENED' isMarketingEnabled=${isMarketingEnabled} attributionId=${attributionId}`);
MetaMetrics.getInstance().trackEvent(
MetaMetricsEvents.APP_OPENED,
{ attributionId },
true
);
} catch (error) {
Logger.error(error as Error, 'AppStateManager: Error processing app state change');
}
NicolasMassart marked this conversation as resolved.
Show resolved Hide resolved
};

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

export default AppStateManager;
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