Skip to content

Commit

Permalink
chore(runway): cherry-pick feat: app event manager and attribution id…
Browse files Browse the repository at this point in the history
… parameters (#11382)

- feat: app event manager and attribution id parameters (#11318)

## **Description**

When a user is redirected to MetaMask Mobile app thanks to a deep link,
including an attributionId parameter:

metamask://open.browser/website_url?attributionId=xyz
(deep link above is just a placeholder)

Then MM Mobile app should attach attributionId as a property to an event
emitted right after Mobile app opens.
Given Mobile app would open and directly redirect the user to the in-app
browser, the "App Opened" event might be a good candidate.


## **Related issues**

Files: [jira issue:

](https://id.atlassian.com/login?continue=https%3A%2F%2Fid.atlassian.com%2Fjoin%2Fuser-access%3Fresource%3Dari%253Acloud%253Ajira%253A%253Asite%252F8831f492-a12c-460e-ac1b-400f1b09e935%26continue%3Dhttps%253A%252F%252Fconsensyssoftware.atlassian.net%252Fbrowse%252FSDK-18%253FatlOrigin%253DeyJpIjoiNzI2OWEwNTczYTZmNGEyZjgxYmFjYjkxMzJlZmE4MTYiLCJwIjoiaiJ9&application=jira)

## **Manual testing steps**

1. Open deeplink container url and check event on segment

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding

Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling

guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

---------

Co-authored-by: Cal Leung <cal.leung@consensys.net>
[e99b5ca](e99b5ca)

Co-authored-by: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Co-authored-by: Cal Leung <cal.leung@consensys.net>
  • Loading branch information
3 people authored Sep 23, 2024
1 parent afc872f commit 0520cfb
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 0 deletions.
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 @@ import { SnapsExecutionWebView } from '../../../lib/snaps';
///: 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 App = (props) => {
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 App = (props) => {
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 @@ const App = (props) => {
});
}, [handleDeeplink]);


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

if (!prevNavigator.current) {
// Setup navigator with Sentry instrumentation
routingInstrumentation.registerNavigationContainer(navigator);
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',

// 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 },
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

0 comments on commit 0520cfb

Please sign in to comment.