-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(runway): cherry-pick feat: app event manager and attribution id…
… 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
1 parent
afc872f
commit 0520cfb
Showing
9 changed files
with
336 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.