diff --git a/src/CONST.ts b/src/CONST.ts index 687f7728fd77..f7de601a36c7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -3223,6 +3223,14 @@ const CONST = { REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + DEBUG_CONSOLE: { + LEVELS: { + INFO: 'INFO', + ERROR: 'ERROR', + RESULT: 'RESULT', + DEBUG: 'DEBUG', + }, + }, REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX: { BANK_ACCOUNT: { ACCOUNT_NUMBERS: 0, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5e41e08d0c78..5755296f3bb5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -260,6 +260,12 @@ const ONYXKEYS = { /** Indicates whether an forced upgrade is required */ UPDATE_REQUIRED: 'updateRequired', + /** Stores the logs of the app for debugging purposes */ + LOGS: 'logs', + + /** Indicates whether we should store logs or not */ + SHOULD_STORE_LOGS: 'shouldStoreLogs', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -541,6 +547,8 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; [ONYXKEYS.PLAID_CURRENT_EVENT]: string; + [ONYXKEYS.LOGS]: Record; + [ONYXKEYS.SHOULD_STORE_LOGS]: boolean; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ae0803ff401f..95d9c5172518 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -154,6 +154,11 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', + SETTINGS_CONSOLE: 'settings/troubleshoot/console', + SETTINGS_SHARE_LOG: { + route: 'settings/troubleshoot/console/share-log', + getRoute: (source: string) => `settings/troubleshoot/console/share-log?source=${encodeURI(source)}` as const, + }, KEYBOARD_SHORTCUTS: 'keyboard-shortcuts', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index aa6ae42d1ee1..f80cdec0f33f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -36,6 +36,8 @@ const SCREENS = { TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', TROUBLESHOOT: 'Settings_Troubleshoot', + CONSOLE: 'Settings_Console', + SHARE_LOG: 'Share_Log', PROFILE: { ROOT: 'Settings_Profile', diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 1961829b6aa7..1777b239e714 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -320,7 +320,7 @@ function Button( shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined, + 'text' in rest && rest?.shouldShowRightIcon ? styles.alignItemsStretch : undefined, innerStyles, ]} hoverStyle={[ diff --git a/src/languages/en.ts b/src/languages/en.ts index 12d451655d72..0098eb517b15 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -28,6 +28,7 @@ import type { InstantSummaryParams, LocalTimeParams, LoggedInAsParams, + LogSizeParams, ManagerApprovedAmountParams, ManagerApprovedParams, MaxParticipantsReachedParams, @@ -826,11 +827,20 @@ export default { troubleshoot: { clearCacheAndRestart: 'Clear cache and restart', viewConsole: 'View debug console', + debugConsole: 'Debug console', description: 'Use the tools below to help troubleshoot the Expensify experience. If you encounter any issues, please', submitBug: 'submit a bug', confirmResetDescription: 'All unsent draft messages will be lost, but the rest of your data is safe.', resetAndRefresh: 'Reset and refresh', }, + debugConsole: { + saveLog: 'Save log', + shareLog: 'Share log', + enterCommand: 'Enter command', + execute: 'Execute', + noLogsAvailable: 'No logs available', + logSizeTooLarge: ({size}: LogSizeParams) => `Log size exceeds the limit of ${size} MB. Please use "Save log" to download the log file instead.`, + }, goToExpensifyClassic: 'Go to Expensify Classic', security: 'Security', signOut: 'Sign out', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2ae60966dd2d..a61df46d5eaa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -27,6 +27,7 @@ import type { InstantSummaryParams, LocalTimeParams, LoggedInAsParams, + LogSizeParams, ManagerApprovedAmountParams, ManagerApprovedParams, MaxParticipantsReachedParams, @@ -821,11 +822,20 @@ export default { troubleshoot: { clearCacheAndRestart: 'Borrar caché y reiniciar', viewConsole: 'Ver la consola de depuración', + debugConsole: 'Consola de depuración', description: 'Utilice las herramientas que aparecen a continuación para solucionar los problemas de Expensify. Si tiene algún problema, por favor', submitBug: 'envíe un error', confirmResetDescription: 'Todos los borradores no enviados se perderán, pero el resto de tus datos estarán a salvo.', resetAndRefresh: 'Restablecer y actualizar', }, + debugConsole: { + saveLog: 'Guardar registro', + shareLog: 'Compartir registro', + enterCommand: 'Introducir comando', + execute: 'Ejecutar', + noLogsAvailable: 'No hay registros disponibles', + logSizeTooLarge: ({size}: LogSizeParams) => `El tamaño del registro excede el límite de ${size} MB. Utilice "Guardar registro" para descargar el archivo de registro.`, + }, security: 'Seguridad', signOut: 'Desconectar', signOutConfirmationText: 'Si cierras sesión perderás los cambios hechos mientras estabas desconectado', diff --git a/src/languages/types.ts b/src/languages/types.ts index ca98e23a4c07..fb5026684c67 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -289,6 +289,8 @@ type TermsParams = {amount: string}; type ElectronicFundsParams = {percentage: string; amount: string}; +type LogSizeParams = {size: number}; + export type { ApprovedAmountParams, AddressLineParams, @@ -389,4 +391,5 @@ export type { WelcomeNoteParams, WelcomeToRoomParams, ZipCodeExampleFormatParams, + LogSizeParams, }; diff --git a/src/libs/Console/index.ts b/src/libs/Console/index.ts new file mode 100644 index 000000000000..0dc9e1208888 --- /dev/null +++ b/src/libs/Console/index.ts @@ -0,0 +1,104 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {addLog} from '@libs/actions/Console'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Log} from '@src/types/onyx'; + +/* store the original console.log function so we can call it */ +// eslint-disable-next-line no-console +const originalConsoleLog = console.log; + +/* List of patterns to ignore in logs. "logs" key always needs to be ignored because otherwise it will cause infinite loop */ +const logPatternsToIgnore = [`merge() called for key: ${ONYXKEYS.LOGS}`]; + +/** + * Check if the log should be attached to the console + * @param message the message to check + * @returns true if the log should be attached to the console + */ +function shouldAttachLog(message: string) { + return !logPatternsToIgnore.some((pattern) => message.includes(pattern)); +} + +/** + * Goes through all the arguments passed the console, parses them to a string and adds them to the logs + * @param args the arguments to log + */ +function logMessage(args: unknown[]) { + const message = args + .map((arg) => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg, null, 2); // Indent for better readability + } catch (e) { + return 'Unserializable Object'; + } + } + + return String(arg); + }) + .join(' '); + const newLog = {time: new Date(), level: CONST.DEBUG_CONSOLE.LEVELS.INFO, message}; + addLog(newLog); +} + +/** + * Override the console.log function to add logs to the store + * @param args arguments passed to the console.log function + */ +// eslint-disable-next-line no-console +console.log = (...args) => { + logMessage(args); + originalConsoleLog.apply(console, args); +}; + +const charsToSanitize = /[\u2018\u2019\u201C\u201D\u201E\u2026]/g; + +const charMap: Record = { + '\u2018': "'", + '\u2019': "'", + '\u201C': '"', + '\u201D': '"', + '\u201E': '"', + '\u2026': '...', +}; + +/** + * Sanitize the input to the console + * @param text the text to sanitize + * @returns the sanitized text + */ +function sanitizeConsoleInput(text: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return text.replace(charsToSanitize, (match) => charMap[match]); +} + +/** + * Run an arbitrary JS code and create a log from the output + * @param text the JS code to run + * @returns an array of logs created by eval call + */ +function createLog(text: string) { + const time = new Date(); + try { + // @ts-expect-error Any code inside `sanitizedInput` that gets evaluated by `eval()` will be executed in the context of the current this value. + // eslint-disable-next-line no-eval, no-invalid-this + const result = eval.call(this, text); + + if (result !== undefined) { + return [ + {time, level: CONST.DEBUG_CONSOLE.LEVELS.INFO, message: `> ${text}`}, + {time, level: CONST.DEBUG_CONSOLE.LEVELS.RESULT, message: String(result)}, + ]; + } + return [{time, level: CONST.DEBUG_CONSOLE.LEVELS.INFO, message: `> ${text}`}]; + } catch (error) { + return [ + {time, level: CONST.DEBUG_CONSOLE.LEVELS.ERROR, message: `> ${text}`}, + {time, level: CONST.DEBUG_CONSOLE.LEVELS.ERROR, message: `Error: ${(error as Error).message}`}, + ]; + } +} + +export {sanitizeConsoleInput, createLog, shouldAttachLog}; +export type {Log}; diff --git a/src/libs/Log.ts b/src/libs/Log.ts index 9916cdd23b76..101996870e1d 100644 --- a/src/libs/Log.ts +++ b/src/libs/Log.ts @@ -3,13 +3,30 @@ /* eslint-disable rulesdir/no-api-in-views */ import Logger from 'expensify-common/lib/Logger'; +import Onyx from 'react-native-onyx'; import type {Merge} from 'type-fest'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import pkg from '../../package.json'; +import {addLog} from './actions/Console'; +import {shouldAttachLog} from './Console'; import getPlatform from './getPlatform'; import * as Network from './Network'; import requireParameters from './requireParameters'; let timeout: NodeJS.Timeout; +let shouldCollectLogs = false; + +Onyx.connect({ + key: ONYXKEYS.SHOULD_STORE_LOGS, + callback: (val) => { + if (!val) { + shouldCollectLogs = false; + } + + shouldCollectLogs = Boolean(val); + }, +}); type LogCommandParameters = { expensifyCashAppVersion: string; @@ -50,7 +67,15 @@ function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOpti const Log = new Logger({ serverLoggingCallback, clientLoggingCallback: (message) => { + if (!shouldAttachLog(message)) { + return; + } + console.debug(message); + + if (shouldCollectLogs) { + addLog({time: new Date(), level: CONST.DEBUG_CONSOLE.LEVELS.DEBUG, message}); + } }, isDebug: true, }); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b0b7fed47930..43a0ea6f8506 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -221,6 +221,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/AppDownloadLinks').default as React.ComponentType, [SCREENS.SETTINGS.LOUNGE_ACCESS]: () => require('../../../pages/settings/Profile/LoungeAccessPage').default as React.ComponentType, [SCREENS.SETTINGS.TROUBLESHOOT]: () => require('../../../pages/settings/AboutPage/TroubleshootPage').default as React.ComponentType, + [SCREENS.SETTINGS.CONSOLE]: () => require('../../../pages/settings/AboutPage/ConsolePage').default as React.ComponentType, + [SCREENS.SETTINGS.SHARE_LOG]: () => require('../../../pages/settings/AboutPage/ShareLogPage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 190bdf3d332c..6f791f2ede8d 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -180,6 +180,11 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_TROUBLESHOOT, exact: true, }, + [SCREENS.SETTINGS.CONSOLE]: { + path: ROUTES.SETTINGS_CONSOLE, + exact: true, + }, + [SCREENS.SETTINGS.SHARE_LOG]: ROUTES.SETTINGS_SHARE_LOG.route, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 76ed943aa3f1..7087d90fe689 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -104,6 +104,11 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.ABOUT]: undefined; [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS]: undefined; [SCREENS.SETTINGS.TROUBLESHOOT]: undefined; + [SCREENS.SETTINGS.CONSOLE]: undefined; + [SCREENS.SETTINGS.SHARE_LOG]: { + /** URL of the generated file to share logs in a report */ + source: string; + }; [SCREENS.SETTINGS.LOUNGE_ACCESS]: undefined; [SCREENS.SETTINGS.WALLET.ROOT]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 346cc71953e6..bbd7678a9679 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1718,6 +1718,19 @@ function getSearchOptions(reports: Record, personalDetails: Onyx return options; } +function getShareLogOptions(reports: OnyxCollection, personalDetails: OnyxEntry, searchValue = '', betas: Beta[] = []): GetOptions { + return getOptions(reports, personalDetails, { + betas, + searchInputValue: searchValue.trim(), + includeRecentReports: true, + includeMultipleParticipantReports: true, + sortByReportTypeInSearch: true, + includePersonalDetails: true, + forcePolicyNamePreview: true, + includeOwnedWorkspaceChats: true, + }); +} + /** * Build the IOUConfirmation options for showing the payee personalDetail */ @@ -2015,6 +2028,7 @@ export { formatMemberForList, formatSectionsFromSearchTerm, transformedTaxRates, + getShareLogOptions, }; -export type {MemberForList, CategorySection}; +export type {MemberForList, CategorySection, GetOptions}; diff --git a/src/libs/actions/Console.ts b/src/libs/actions/Console.ts new file mode 100644 index 000000000000..79276d3307ac --- /dev/null +++ b/src/libs/actions/Console.ts @@ -0,0 +1,31 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Log} from '@src/types/onyx'; + +/** + * Merge the new log into the existing logs in Onyx + * @param log the log to add + */ +function addLog(log: Log) { + Onyx.merge(ONYXKEYS.LOGS, { + [log.time.getTime()]: log, + }); +} + +/** + * Set whether or not to store logs in Onyx + * @param store whether or not to store logs + */ +function setShouldStoreLogs(store: boolean) { + Onyx.set(ONYXKEYS.SHOULD_STORE_LOGS, store); +} + +/** + * Disable logging and flush the logs from Onyx + */ +function disableLoggingAndFlushLogs() { + setShouldStoreLogs(false); + Onyx.set(ONYXKEYS.LOGS, null); +} + +export {addLog, setShouldStoreLogs, disableLoggingAndFlushLogs}; diff --git a/src/libs/localFileCreate/index.native.ts b/src/libs/localFileCreate/index.native.ts new file mode 100644 index 000000000000..418701ae3ff5 --- /dev/null +++ b/src/libs/localFileCreate/index.native.ts @@ -0,0 +1,19 @@ +import RNFetchBlob from 'react-native-blob-util'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import type LocalFileCreate from './types'; + +/** + * Creates a blob file using RN Fetch Blob + * @param fileName name of the file + * @param textContent content of the file + * @returns path, filename and size of the newly created file + */ +const localFileCreate: LocalFileCreate = (fileName, textContent) => { + const newFileName = FileUtils.appendTimeToFileName(fileName); + const dir = RNFetchBlob.fs.dirs.DocumentDir; + const path = `${dir}/${newFileName}.txt`; + + return RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => RNFetchBlob.fs.stat(path).then(({size}) => ({path, newFileName, size}))); +}; + +export default localFileCreate; diff --git a/src/libs/localFileCreate/index.ts b/src/libs/localFileCreate/index.ts new file mode 100644 index 000000000000..0178a6c76f7c --- /dev/null +++ b/src/libs/localFileCreate/index.ts @@ -0,0 +1,18 @@ +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import type LocalFileCreate from './types'; + +/** + * Creates a Blob file + * @param fileName name of the file + * @param textContent content of the file + * @returns path, filename and size of the newly created file + */ +const localFileCreate: LocalFileCreate = (fileName, textContent) => { + const newFileName = FileUtils.appendTimeToFileName(fileName); + const blob = new Blob([textContent], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + + return Promise.resolve({path: url, newFileName, size: blob.size}); +}; + +export default localFileCreate; diff --git a/src/libs/localFileCreate/types.ts b/src/libs/localFileCreate/types.ts new file mode 100644 index 000000000000..e8e8084cb567 --- /dev/null +++ b/src/libs/localFileCreate/types.ts @@ -0,0 +1,3 @@ +type LocalFileCreate = (fileName: string, textContent: string) => Promise<{path: string; newFileName: string; size: number}>; + +export default LocalFileCreate; diff --git a/src/libs/localFileDownload/index.android.ts b/src/libs/localFileDownload/index.android.ts index dd266d3be405..6573006154d4 100644 --- a/src/libs/localFileDownload/index.android.ts +++ b/src/libs/localFileDownload/index.android.ts @@ -1,5 +1,6 @@ import RNFetchBlob from 'react-native-blob-util'; import * as FileUtils from '@libs/fileDownload/FileUtils'; +import localFileCreate from '@libs/localFileCreate'; import type LocalFileDownload from './types'; /** @@ -8,11 +9,7 @@ import type LocalFileDownload from './types'; * After the file is copied, it is removed from the internal dir. */ const localFileDownload: LocalFileDownload = (fileName, textContent, successMessage) => { - const newFileName = FileUtils.appendTimeToFileName(fileName); - const dir = RNFetchBlob.fs.dirs.DocumentDir; - const path = `${dir}/${newFileName}.txt`; - - RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => { + localFileCreate(fileName, textContent).then(({path, newFileName}) => { RNFetchBlob.MediaCollection.copyToMediaStore( { name: newFileName, diff --git a/src/libs/localFileDownload/index.ios.ts b/src/libs/localFileDownload/index.ios.ts index 892ab29d21f5..778d19d9449b 100644 --- a/src/libs/localFileDownload/index.ios.ts +++ b/src/libs/localFileDownload/index.ios.ts @@ -1,6 +1,6 @@ import {Share} from 'react-native'; import RNFetchBlob from 'react-native-blob-util'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; +import localFileCreate from '@libs/localFileCreate'; import type LocalFileDownload from './types'; /** @@ -9,11 +9,7 @@ import type LocalFileDownload from './types'; * After the file is shared, it is removed from the internal dir. */ const localFileDownload: LocalFileDownload = (fileName, textContent) => { - const newFileName = FileUtils.appendTimeToFileName(fileName); - const dir = RNFetchBlob.fs.dirs.DocumentDir; - const path = `${dir}/${newFileName}.txt`; - - RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => { + localFileCreate(fileName, textContent).then(({path, newFileName}) => { Share.share({url: path, title: newFileName}).finally(() => { RNFetchBlob.fs.unlink(path); }); diff --git a/src/libs/localFileDownload/index.ts b/src/libs/localFileDownload/index.ts index ba038b8853ad..a1a20a0e3d4a 100644 --- a/src/libs/localFileDownload/index.ts +++ b/src/libs/localFileDownload/index.ts @@ -1,4 +1,4 @@ -import * as FileUtils from '@libs/fileDownload/FileUtils'; +import localFileCreate from '@libs/localFileCreate'; import type LocalFileDownload from './types'; /** @@ -7,12 +7,12 @@ import type LocalFileDownload from './types'; * is downloaded by the browser. */ const localFileDownload: LocalFileDownload = (fileName, textContent) => { - const blob = new Blob([textContent], {type: 'text/plain'}); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.download = FileUtils.appendTimeToFileName(`${fileName}.txt`); - link.href = url; - link.click(); + localFileCreate(`${fileName}.txt`, textContent).then(({path, newFileName}) => { + const link = document.createElement('a'); + link.download = newFileName; + link.href = path; + link.click(); + }); }; export default localFileDownload; diff --git a/src/pages/settings/AboutPage/ConsolePage.tsx b/src/pages/settings/AboutPage/ConsolePage.tsx new file mode 100644 index 000000000000..5c5e9ba91874 --- /dev/null +++ b/src/pages/settings/AboutPage/ConsolePage.tsx @@ -0,0 +1,201 @@ +import {format} from 'date-fns'; +import isEmpty from 'lodash/isEmpty'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {FlatList, View} from 'react-native'; +import type {ListRenderItem, ListRenderItemInfo} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Button from '@components/Button'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {addLog} from '@libs/actions/Console'; +import {createLog, sanitizeConsoleInput} from '@libs/Console'; +import type {Log} from '@libs/Console'; +import localFileCreate from '@libs/localFileCreate'; +import localFileDownload from '@libs/localFileDownload'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +type CapturedLogs = Record; + +type ConsolePageOnyxProps = { + /** Logs captured on the current device */ + capturedLogs: OnyxEntry; + + /** Whether or not logs should be stored */ + shouldStoreLogs: OnyxEntry; +}; + +type ConsolePageProps = ConsolePageOnyxProps; + +/** + * Loops through all the logs and parses the message if it's a stringified JSON + * @param logs Logs captured on the current device + * @returns CapturedLogs with parsed messages + */ +const parseStringifyMessages = (logs: Log[]) => { + if (isEmpty(logs)) { + return; + } + + return logs.map((log) => { + try { + const parsedMessage = JSON.parse(log.message); + return { + ...log, + message: parsedMessage, + }; + } catch { + // If the message can't be parsed, just return the original log + return log; + } + }); +}; + +function ConsolePage({capturedLogs, shouldStoreLogs}: ConsolePageProps) { + const [input, setInput] = useState(''); + const [logs, setLogs] = useState(capturedLogs); + const [isGeneratingLogsFile, setIsGeneratingLogsFile] = useState(false); + const [isLimitModalVisible, setIsLimitModalVisible] = useState(false); + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const logsList = useMemo(() => (logs ? Object.values(logs).reverse() : []), [logs]); + + useEffect(() => { + if (!shouldStoreLogs) { + return; + } + + setLogs((prevLogs) => ({...prevLogs, ...capturedLogs})); + }, [capturedLogs, shouldStoreLogs]); + + const executeArbitraryCode = () => { + const sanitizedInput = sanitizeConsoleInput(input); + + const output = createLog(sanitizedInput); + output.forEach((log) => addLog(log)); + setInput(''); + }; + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, executeArbitraryCode); + + const saveLogs = () => { + const logsWithParsedMessages = parseStringifyMessages(logsList); + + localFileDownload('logs', JSON.stringify(logsWithParsedMessages, null, 2)); + }; + + const shareLogs = () => { + setIsGeneratingLogsFile(true); + const logsWithParsedMessages = parseStringifyMessages(logsList); + + // Generate a file with the logs and pass its path to the list of reports to share it with + localFileCreate('logs', JSON.stringify(logsWithParsedMessages, null, 2)).then(({path, size}) => { + setIsGeneratingLogsFile(false); + + // if the file size is too large to send it as an attachment, show a modal and return + if (size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setIsLimitModalVisible(true); + + return; + } + + Navigation.navigate(ROUTES.SETTINGS_SHARE_LOG.getRoute(path)); + }); + }; + + const renderItem: ListRenderItem = useCallback( + ({item}: ListRenderItemInfo) => { + if (!item) { + return null; + } + + return ( + + {`${format(new Date(item.time), CONST.DATE.FNS_DB_FORMAT_STRING)} ${item.message}`} + + ); + }, + [styles.mb2], + ); + + return ( + + Navigation.goBack(ROUTES.SETTINGS_TROUBLESHOOT)} + /> + + {translate('initialSettingsPage.debugConsole.noLogsAvailable')}} + /> + + +