diff --git a/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap b/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap index cba50627e80..b00de69953c 100644 --- a/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap +++ b/app/components/Base/AnimatedFox/__snapshots__/index.test.tsx.snap @@ -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={ { diff --git a/app/components/Base/AnimatedFox/index.tsx b/app/components/Base/AnimatedFox/index.tsx index 51eb1afc844..7fbc6749588 100644 --- a/app/components/Base/AnimatedFox/index.tsx +++ b/app/components/Base/AnimatedFox/index.tsx @@ -70,6 +70,7 @@ const AnimatedFox: React.FC = ({ bgColor }) => { return ( { + const { fullState } = this.props; + downloadStateLogs(fullState, false); + }; + render = () => { const colors = this.context.colors || mockTheme.colors; const themeAppearance = this.context.themeAppearance || 'light'; @@ -505,17 +516,23 @@ class Login extends PureComponent { style={styles.wrapper} > - - {Device.isAndroid() ? ( - - ) : ( - - )} - + + + {Device.isAndroid() ? ( + + ) : ( + + )} + + ({ userLoggedIn: state.user.userLoggedIn, + fullState: state, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Views/Settings/AdvancedSettings/index.js b/app/components/Views/Settings/AdvancedSettings/index.js index 53d0cc62734..3fa30515e38 100644 --- a/app/components/Views/Settings/AdvancedSettings/index.js +++ b/app/components/Views/Settings/AdvancedSettings/index.js @@ -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. @@ -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'; @@ -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({ @@ -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) => { diff --git a/app/util/logs/index.test.ts b/app/util/logs/index.test.ts index 7ed9c986b98..f6c79401545 100644 --- a/app/util/logs/index.test.ts +++ b/app/util/logs/index.test.ts @@ -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: { @@ -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,'), + }); + }); +}); diff --git a/app/util/logs/index.ts b/app/util/logs/index.ts index 459a0a3a546..64bfa2088b5 100644 --- a/app/util/logs/index.ts +++ b/app/util/logs/index.ts @@ -1,8 +1,20 @@ import Engine from '../../core/Engine'; +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 Logger from '../../util/Logger'; +import { RootState } from '../../reducers'; +import Device from '../../util/device'; // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any, import/prefer-default-export -export const generateStateLogs = (state: any): string => { +export const generateStateLogs = (state: any, loggedIn = true): string => { const fullState = JSON.parse(JSON.stringify(state)); delete fullState.engine.backgroundState.NftController; @@ -15,6 +27,9 @@ export const generateStateLogs = (state: any): string => { // Remove encrypted vault from logs delete fullState.engine.backgroundState.KeyringController.vault; + if (!loggedIn) { + return JSON.stringify(fullState); + } // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any const { KeyringController } = Engine.context as any; @@ -34,3 +49,45 @@ export const generateStateLogs = (state: any): string => { return JSON.stringify(newState); }; + +export const downloadStateLogs = async ( + fullState: RootState, + loggedIn = true, +) => { + 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, + }, + loggedIn, + ); + + 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) { + const e = err as Error; + Logger.error(e, 'State log error'); + } +};