Skip to content

Commit

Permalink
chore: Download state logs on login screen (#10289)
Browse files Browse the repository at this point in the history
## **Description**

Add magic button that download state logs on login screen after pressing
mm logo for 10 seconds




## **Related issues**

Fixes:

## **Manual testing steps**

1. Go to login screen
2. Press metamask image for 10 seconds
3. State logs were downloaded

## **Screenshots/Recordings**


https://github.com/user-attachments/assets/abff17ff-ad27-4112-b424-22ff3911c24d

### **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
- [ ] 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**

- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] 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.
  • Loading branch information
tommasini authored Jul 12, 2024
1 parent b35d00e commit 96e7d74
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 57 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ exports[`AnimatedFox renders correctly and matches snapshot 1`] = `
bounces={false}
injectedJavaScript="document.body.style.background="black""
javaScriptEnabled={true}
pointerEvents="none"
scrollEnabled={false}
source={
{
Expand Down
1 change: 1 addition & 0 deletions app/components/Base/AnimatedFox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const AnimatedFox: React.FC<AnimatedFoxProps> = ({ bgColor }) => {

return (
<WebView
pointerEvents="none"
ref={webviewRef}
style={styles}
source={{
Expand Down
40 changes: 29 additions & 11 deletions app/components/Views/Login/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Image,
InteractionManager,
BackHandler,
TouchableWithoutFeedback,
} from 'react-native';
import Text, {
TextColor,
Expand Down Expand Up @@ -58,6 +59,7 @@ import { RevealSeedViewSelectorsIDs } from '../../../../e2e/selectors/Settings/S
import { LoginViewSelectors } from '../../../../e2e/selectors/LoginView.selectors';
import { withMetricsAwareness } from '../../../components/hooks/useMetrics';
import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics';
import { downloadStateLogs } from '../../../util/logs';

const deviceHeight = Device.getDeviceHeight();
const breakPoint = deviceHeight < 700;
Expand Down Expand Up @@ -218,6 +220,10 @@ class Login extends PureComponent {
* Metrics injected by withMetricsAwareness HOC
*/
metrics: PropTypes.object,
/**
* Full state of the app
*/
fullState: PropTypes.object,
};

state = {
Expand Down Expand Up @@ -486,6 +492,11 @@ class Login extends PureComponent {
InteractionManager.runAfterInteractions(this.toggleDeleteModal);
};

handleDownloadStateLogs = () => {
const { fullState } = this.props;
downloadStateLogs(fullState, false);
};

render = () => {
const colors = this.context.colors || mockTheme.colors;
const themeAppearance = this.context.themeAppearance || 'light';
Expand All @@ -505,17 +516,23 @@ class Login extends PureComponent {
style={styles.wrapper}
>
<View testID={LoginViewSelectors.CONTAINER}>
<View style={styles.foxWrapper}>
{Device.isAndroid() ? (
<Image
source={require('../../../images/fox.png')}
style={styles.image}
resizeMethod={'auto'}
/>
) : (
<AnimatedFox bgColor={colors.background.default} />
)}
</View>
<TouchableWithoutFeedback
onLongPress={this.handleDownloadStateLogs}
delayLongPress={10 * 1000} // 10 seconds
style={styles.foxWrapper}
>
<View style={styles.foxWrapper}>
{Device.isAndroid() ? (
<Image
source={require('../../../images/fox.png')}
style={styles.image}
resizeMethod={'auto'}
/>
) : (
<AnimatedFox bgColor={colors.background.default} />
)}
</View>
</TouchableWithoutFeedback>
<Text
style={styles.title}
testID={LoginViewSelectors.LOGIN_VIEW_TITLE_ID}
Expand Down Expand Up @@ -608,6 +625,7 @@ Login.contextType = ThemeContext;

const mapStateToProps = (state) => ({
userLoggedIn: state.user.userLoggedIn,
fullState: state,
});

const mapDispatchToProps = (dispatch) => ({
Expand Down
46 changes: 3 additions & 43 deletions app/components/Views/Settings/AdvancedSettings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,7 @@ import { Linking, SafeAreaView, StyleSheet, Switch, View } from 'react-native';
import { connect } from 'react-redux';
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import { isTokenDetectionSupportedForNetwork } from '@metamask/assets-controllers';
import {
getApplicationName,
getBuildNumber,
getVersion,
} from 'react-native-device-info';
import Share from 'react-native-share'; // eslint-disable-line import/default
import RNFS from 'react-native-fs';
// eslint-disable-next-line import/no-nodejs-modules
import { Buffer } from 'buffer';

import { typography } from '@metamask/design-tokens';

// External dependencies.
Expand All @@ -27,8 +19,6 @@ import {
setShowHexData,
} from '../../../../actions/settings';
import { strings } from '../../../../../locales/i18n';
import Logger from '../../../../util/Logger';
import { generateStateLogs } from '../../../../util/logs';
import Device from '../../../../util/device';
import { mockTheme, ThemeContext } from '../../../../util/theme';
import { selectChainId } from '../../../../selectors/networkController';
Expand Down Expand Up @@ -58,6 +48,7 @@ import Banner, {
import { withMetricsAwareness } from '../../../../components/hooks/useMetrics';
import { wipeTransactions } from '../../../../util/transaction-controller';
import AppConstants from '../../../../../app/core/AppConstants';
import { downloadStateLogs } from '../../../../util/logs';

const createStyles = (colors) =>
StyleSheet.create({
Expand Down Expand Up @@ -273,38 +264,7 @@ class AdvancedSettings extends PureComponent {

downloadStateLogs = async () => {
const { fullState } = this.props;
const appName = await getApplicationName();
const appVersion = await getVersion();
const buildNumber = await getBuildNumber();
const path =
RNFS.DocumentDirectoryPath +
`/state-logs-v${appVersion}-(${buildNumber}).json`;
// A not so great way to copy objects by value

try {
const stateLogsWithReleaseDetails = generateStateLogs({
...fullState,
appVersion,
buildNumber,
});

let url = `data:text/plain;base64,${new Buffer(
stateLogsWithReleaseDetails,
).toString('base64')}`;
// // Android accepts attachements as BASE64
if (Device.isIos()) {
await RNFS.writeFile(path, stateLogsWithReleaseDetails, 'utf8');
url = path;
}

await Share.open({
subject: `${appName} State logs - v${appVersion} (${buildNumber})`,
title: `${appName} State logs - v${appVersion} (${buildNumber})`,
url,
});
} catch (err) {
Logger.error(err, 'State log error');
}
downloadStateLogs(fullState);
};

onEthSignSettingChangeAttempt = (enabled) => {
Expand Down
201 changes: 199 additions & 2 deletions app/util/logs/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
import { generateStateLogs } from '.';
import { backgroundState } from '../../util/test/initial-root-state';
import { generateStateLogs, downloadStateLogs } from '.';
import RNFS from 'react-native-fs';
import Share from 'react-native-share';
import {
getApplicationName,
getBuildNumber,
getVersion,
} from 'react-native-device-info';
import Device from '../../util/device';
import Logger from '../../util/Logger';
import initialRootState, {
backgroundState,
} from '../../util/test/initial-root-state';
import { merge } from 'lodash';

jest.mock('react-native-fs', () => ({
DocumentDirectoryPath: '/mock/path',
writeFile: jest.fn(),
}));

jest.mock('react-native-share', () => ({
open: jest.fn(),
}));

jest.mock('react-native-device-info', () => ({
getApplicationName: jest.fn(),
getBuildNumber: jest.fn(),
getVersion: jest.fn(),
}));

jest.mock('../../util/device', () => ({
isIos: jest.fn(),
isAndroid: jest.fn(),
}));

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

jest.mock('../../core/Engine', () => ({
context: {
Expand Down Expand Up @@ -60,3 +96,164 @@ describe('logs :: generateStateLogs', () => {
expect(logs.includes('buildNumber')).toBe(true);
});
});

describe('logs :: downloadStateLogs', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should generate and share logs successfully on iOS', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(true);

const mockStateInput = merge({}, initialRootState, {
engine: {
backgroundState: {
...backgroundState,
KeyringController: {
vault: 'vault mock',
},
},
},
});

await downloadStateLogs(mockStateInput);

expect(RNFS.writeFile).toHaveBeenCalledWith(
'/mock/path/state-logs-v1.0.0-(100).json',
expect.any(String),
'utf8',
);
expect(Share.open).toHaveBeenCalledWith({
subject: 'TestApp State logs - v1.0.0 (100)',
title: 'TestApp State logs - v1.0.0 (100)',
url: '/mock/path/state-logs-v1.0.0-(100).json',
});
});

it('should generate and share logs successfully on Android', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(false);

const mockStateInput = merge({}, initialRootState, {
engine: {
backgroundState: {
...backgroundState,
KeyringController: {
vault: 'vault mock',
},
},
},
});

await downloadStateLogs(mockStateInput);

expect(RNFS.writeFile).not.toHaveBeenCalled();
expect(Share.open).toHaveBeenCalledWith({
subject: 'TestApp State logs - v1.0.0 (100)',
title: 'TestApp State logs - v1.0.0 (100)',
url: expect.stringContaining('data:text/plain;base64,'),
});
});

it('should handle errors during log generation', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(true);

const mockStateInput = null;

//@ts-expect-error - the test case is to test the input being not the expected
await downloadStateLogs(mockStateInput);

expect(Logger.error).toHaveBeenCalledWith(
expect.any(Error),
'State log error',
);
});

it('should handle errors during file writing on iOS', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(true);
(RNFS.writeFile as jest.Mock).mockRejectedValue(
new Error('File write error'),
);

const mockStateInput = merge({}, initialRootState, {
engine: {
backgroundState: {
...backgroundState,
KeyringController: {
vault: 'vault mock',
},
},
},
});

await downloadStateLogs(mockStateInput);

expect(Logger.error).toHaveBeenCalledWith(
expect.any(Error),
'State log error',
);
});

it('should handle errors during sharing', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(false);
(Share.open as jest.Mock).mockRejectedValue(new Error('Share error'));

const mockStateInput = merge({}, initialRootState, {
engine: {
backgroundState: {
...backgroundState,
KeyringController: {
vault: 'vault mock',
},
},
},
});

await downloadStateLogs(mockStateInput);

expect(Logger.error).toHaveBeenCalledWith(
expect.any(Error),
'State log error',
);
});

it('should handle loggedIn as false', async () => {
(getApplicationName as jest.Mock).mockResolvedValue('TestApp');
(getVersion as jest.Mock).mockResolvedValue('1.0.0');
(getBuildNumber as jest.Mock).mockResolvedValue('100');
(Device.isIos as jest.Mock).mockReturnValue(false);

const mockStateInput = merge({}, initialRootState, {
engine: {
backgroundState: {
...backgroundState,
KeyringController: {
vault: 'vault mock',
},
},
},
});

await downloadStateLogs(mockStateInput, false);

expect(Share.open).toHaveBeenCalledWith({
subject: 'TestApp State logs - v1.0.0 (100)',
title: 'TestApp State logs - v1.0.0 (100)',
url: expect.stringContaining('data:text/plain;base64,'),
});
});
});
Loading

0 comments on commit 96e7d74

Please sign in to comment.