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

chore: Download state logs on login screen #10289

Merged
merged 3 commits into from
Jul 12, 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
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
Loading