From 2f74af0812c0b98c86a017b4577f17ebfcb5d57a Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Thu, 18 Dec 2025 12:53:01 +0700 Subject: [PATCH 001/271] Add modifyAmount check --- src/libs/SearchUIUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 111bf3722b0c..14d03a692943 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1161,8 +1161,12 @@ function getToFieldValueForTransaction( const isIOUReport = report?.type === CONST.REPORT.TYPE.IOU; if (isIOUReport) { return ( - getIOUPayerAndReceiver(report?.managerID ?? CONST.DEFAULT_NUMBER_ID, report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, personalDetailsList, transactionItem.amount)?.to ?? - emptyPersonalDetails + getIOUPayerAndReceiver( + report?.managerID ?? CONST.DEFAULT_NUMBER_ID, + report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, + personalDetailsList, + transactionItem.modifiedAmount || transactionItem.amount, + )?.to ?? emptyPersonalDetails ); } return personalDetailsList?.[report?.managerID] ?? emptyPersonalDetails; From 5bcfae7064a33b51959464d005fd2060a712a4c5 Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Thu, 18 Dec 2025 13:00:52 +0700 Subject: [PATCH 002/271] disable eslint rule --- src/libs/SearchUIUtils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 14d03a692943..75204d3ef97c 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1165,6 +1165,7 @@ function getToFieldValueForTransaction( report?.managerID ?? CONST.DEFAULT_NUMBER_ID, report?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID, personalDetailsList, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing transactionItem.modifiedAmount || transactionItem.amount, )?.to ?? emptyPersonalDetails ); From 9151aaf145f732c33e263644b79e18a96228d13c Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Sun, 21 Dec 2025 12:41:33 +0700 Subject: [PATCH 003/271] add test to ensure the fix is reliable in other cases and wouldn't regress --- src/libs/SearchUIUtils.ts | 1 + tests/unit/Search/SearchUIUtilsTest.ts | 214 +++++++++++++++++++++++++ 2 files changed, 215 insertions(+) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 75204d3ef97c..3ee76b082de5 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3211,5 +3211,6 @@ export { getTableMinWidth, getCustomColumns, getCustomColumnDefault, + getToFieldValueForTransaction, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, ArchivedReportsIDSet}; diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index fe248c2a58dd..02c77762c78e 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -52,6 +52,8 @@ jest.mock('@userActions/Search', () => ({ const adminAccountID = 18439984; const adminEmail = 'admin@policy.com'; +const receiverAccountID = 18439985; +const receiverEmail = 'receiver@policy.com'; const emptyPersonalDetails = { accountID: 0, @@ -3108,4 +3110,216 @@ describe('SearchUIUtils', () => { expect(transactionThread).toBeTruthy(); }); }); + + describe('getToFieldValueForTransaction', () => { + const mockTransaction: OnyxTypes.Transaction = { + transactionID: '1', + amount: 1000, + currency: 'USD', + reportID: reportID, + accountID: adminAccountID, + created: '2024-12-21 13:05:20', + merchant: 'Test Merchant', + } as OnyxTypes.Transaction; + + const mockPersonalDetails: OnyxTypes.PersonalDetailsList = { + [adminAccountID]: { + accountID: adminAccountID, + displayName: 'Admin User', + login: adminEmail, + avatar: 'https://example.com/avatar.png', + }, + [receiverAccountID]: { + accountID: receiverAccountID, + displayName: 'Receiver User', + login: receiverEmail, + avatar: 'https://example.com/avatar2.png', + }, + }; + + test('Should return emptyPersonalDetails when report is undefined', () => { + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, undefined, mockPersonalDetails, undefined); + expect(result).toEqual(emptyPersonalDetails); + }); + + test('Should return emptyPersonalDetails when report is an open expense report', () => { + const openExpenseReport: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, openExpenseReport, mockPersonalDetails, undefined); + expect(result).toEqual(emptyPersonalDetails); + }); + + test('Should return ownerAccountID personal details when reportAction is PAY type and report has ownerAccountID', () => { + const payReportAction: OnyxTypes.ReportAction = { + ...reportAction1, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.PAY, + IOUTransactionID: mockTransaction.transactionID, + IOUReportID: report1.reportID, + }, + } as OnyxTypes.ReportAction; + + const nonOpenReport: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + ownerAccountID: adminAccountID, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonOpenReport, mockPersonalDetails, payReportAction); + expect(result).toEqual(mockPersonalDetails[adminAccountID]); + }); + + test('Should return managerID personal details when reportAction is not a money request action', () => { + const nonMoneyRequestAction: OnyxTypes.ReportAction = { + ...reportAction1, + actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, + originalMessage: undefined, + } as OnyxTypes.ReportAction; + + const nonOpenReport: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: receiverAccountID, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonOpenReport, mockPersonalDetails, nonMoneyRequestAction); + expect(result).toEqual(mockPersonalDetails[receiverAccountID]); + }); + + test('Should return getIOUPayerAndReceiver result for IOU report with managerID', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: receiverAccountID, + ownerAccountID: adminAccountID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const transactionWithNegativeAmount: OnyxTypes.Transaction = { + ...mockTransaction, + amount: -1000, + modifiedAmount: 1000, + } as OnyxTypes.Transaction; + + const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithNegativeAmount, iouReport, mockPersonalDetails, undefined); + expect(result).toEqual(mockPersonalDetails[receiverAccountID]); + }); + + test('Should return getIOUPayerAndReceiver result for IOU report with positive amount', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: receiverAccountID, + ownerAccountID: adminAccountID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const transactionWithPositiveAmount: OnyxTypes.Transaction = { + ...mockTransaction, + amount: 1000, + } as OnyxTypes.Transaction; + + const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithPositiveAmount, iouReport, mockPersonalDetails, undefined); + expect(result).toEqual(mockPersonalDetails[receiverAccountID]); + }); + + test('Should use modifiedAmount when available for IOU report', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: receiverAccountID, + ownerAccountID: adminAccountID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const transactionWithModifiedAmount: OnyxTypes.Transaction = { + ...mockTransaction, + amount: 1000, + modifiedAmount: -2000, + } as OnyxTypes.Transaction; + + const result = SearchUIUtils.getToFieldValueForTransaction(transactionWithModifiedAmount, iouReport, mockPersonalDetails, undefined); + expect(result).toEqual(mockPersonalDetails[adminAccountID]); + }); + + test('Should return managerID personal details for non-IOU report with managerID', () => { + const nonIOUReport: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: receiverAccountID, + type: CONST.REPORT.TYPE.EXPENSE, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonIOUReport, mockPersonalDetails, undefined); + expect(result).toEqual(mockPersonalDetails[receiverAccountID]); + }); + + test('Should return emptyPersonalDetails when managerID personal details are not found', () => { + const nonIOUReport: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: 999999, + type: CONST.REPORT.TYPE.EXPENSE, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, nonIOUReport, mockPersonalDetails, undefined); + expect(result).toEqual(emptyPersonalDetails); + }); + + test('Should return emptyPersonalDetails when report has no managerID', () => { + const reportWithoutManager: OnyxTypes.Report = { + ...report1, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + managerID: undefined, + type: CONST.REPORT.TYPE.EXPENSE, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, reportWithoutManager, mockPersonalDetails, undefined); + expect(result).toEqual(emptyPersonalDetails); + }); + + test('Should return emptyPersonalDetails when getIOUPayerAndReceiver returns undefined for IOU report', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: receiverAccountID, + ownerAccountID: adminAccountID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const emptyPersonalDetailsList: OnyxTypes.PersonalDetailsList = {}; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, emptyPersonalDetailsList, undefined); + expect(result).toEqual(emptyPersonalDetails); + }); + + test('Should handle IOU report with DEFAULT_NUMBER_ID for managerID', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: CONST.DEFAULT_NUMBER_ID, + ownerAccountID: adminAccountID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, mockPersonalDetails, undefined); + expect(result).toBeDefined(); + }); + + test('Should handle IOU report with DEFAULT_NUMBER_ID for ownerAccountID', () => { + const iouReport: OnyxTypes.Report = { + ...report3, + managerID: receiverAccountID, + ownerAccountID: CONST.DEFAULT_NUMBER_ID, + type: CONST.REPORT.TYPE.IOU, + } as OnyxTypes.Report; + + const result = SearchUIUtils.getToFieldValueForTransaction(mockTransaction, iouReport, mockPersonalDetails, undefined); + expect(result).toBeDefined(); + }); + }); }); From 43caa9ccaae9cb08da6dc740bf7092bc7b26ce1d Mon Sep 17 00:00:00 2001 From: cretadn22 Date: Sun, 21 Dec 2025 12:46:38 +0700 Subject: [PATCH 004/271] Using short-hand syntax --- tests/unit/Search/SearchUIUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 02c77762c78e..ce7e07efb97c 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -3116,7 +3116,7 @@ describe('SearchUIUtils', () => { transactionID: '1', amount: 1000, currency: 'USD', - reportID: reportID, + reportID, accountID: adminAccountID, created: '2024-12-21 13:05:20', merchant: 'Test Merchant', From cb5311b3e017e95a5c5f2a312423b57e05970737 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski <31442502+szymonzalarski98@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:27:31 +0100 Subject: [PATCH 005/271] Track memory usage in Sentry attributes --- src/CONST/index.ts | 8 ++ .../BaseRecordTroubleshootDataToolMenu.tsx | 13 +-- src/libs/telemetry/TelemetrySynchronizer.ts | 95 +++++++++++++++++++ src/libs/telemetry/getMemoryInfo.ts | 47 +++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 src/libs/telemetry/getMemoryInfo.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e1b5247c7136..39173b37d741 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1689,7 +1689,15 @@ const CONST = { }, TELEMETRY: { CONTEXT_FULLSTORY: 'Fullstory', + CONTEXT_MEMORY: 'Memory', CONTEXT_POLICIES: 'Policies', + // Breadcrumb names + BREADCRUMB_CATEGORY_MEMORY: 'system.memory', + BREADCRUMB_MEMORY_PERIODIC: 'Periodic memory check', + BREADCRUMB_MEMORY_FOREGROUND: 'App foreground - memory check', + // Memory Thresholds (in MB) + MEMORY_THRESHOLD_WARNING: 120, + MEMORY_THRESHOLD_CRITICAL: 50, TAG_ACTIVE_POLICY: 'active_policy_id', TAG_NUDGE_MIGRATION_COHORT: 'nudge_migration_cohort', TAG_AUTHENTICATION_FUNCTION: 'authentication_function', diff --git a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx index ca4b46d94a9a..7e8462ec9c56 100644 --- a/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx +++ b/src/components/RecordTroubleshootDataToolMenu/BaseRecordTroubleshootDataToolMenu.tsx @@ -2,7 +2,6 @@ import type JSZip from 'jszip'; import type {RefObject} from 'react'; import React, {useEffect, useState} from 'react'; import {Alert} from 'react-native'; -import DeviceInfo from 'react-native-device-info'; import Button from '@components/Button'; import Switch from '@components/Switch'; import TestToolRow from '@components/TestToolRow'; @@ -14,6 +13,7 @@ import {cleanupAfterDisable, disableRecording, enableRecording, stopProfilingAnd import type {ProfilingData} from '@libs/actions/Troubleshoot'; import {parseStringifiedMessages} from '@libs/Console'; import getPlatform from '@libs/getPlatform'; +import getMemoryInfo from '@libs/telemetry/getMemoryInfo'; import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Log as OnyxLog} from '@src/types/onyx'; @@ -49,7 +49,7 @@ type BaseRecordTroubleshootDataToolMenuProps = { displayPath?: string; }; -function formatBytes(bytes: number, decimals = 2) { +function formatBytes(bytes: number, decimals = 2): string { if (!+bytes) { return '0 Bytes'; } @@ -59,8 +59,9 @@ function formatBytes(bytes: number, decimals = 2) { const sizes = ['Bytes', 'KiB', 'MiB', 'GiB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); + const sizeIndex = Math.min(i, sizes.length - 1); - return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes.at(i)}`; + return `${parseFloat((bytes / k ** sizeIndex).toFixed(dm))} ${sizes.at(sizeIndex)}`; } // WARNING: When changing this name make sure that the "scripts/symbolicate-profile.ts" script is still working! @@ -86,13 +87,13 @@ function BaseRecordTroubleshootDataToolMenu({ const [profileTracePath, setProfileTracePath] = useState(); const getAppInfo = async (profilingData: ProfilingData) => { - const [totalMemory, usedMemory] = await Promise.all([DeviceInfo.getTotalMemory(), DeviceInfo.getUsedMemory()]); + const memoryInfo = await getMemoryInfo(); return JSON.stringify({ appVersion: pkg.version, environment: CONFIG.ENVIRONMENT, platform: getPlatform(), - totalMemory: formatBytes(totalMemory, 2), - usedMemory: formatBytes(usedMemory, 2), + totalMemory: memoryInfo.totalMemoryBytes !== null ? formatBytes(memoryInfo.totalMemoryBytes, 2) : null, + usedMemory: memoryInfo.usedMemoryBytes !== null ? formatBytes(memoryInfo.usedMemoryBytes, 2) : null, memoizeStats: profilingData.memoizeStats, performance: profilingData.performanceMeasures, }); diff --git a/src/libs/telemetry/TelemetrySynchronizer.ts b/src/libs/telemetry/TelemetrySynchronizer.ts index a8de074dd5d2..b4be4db84050 100644 --- a/src/libs/telemetry/TelemetrySynchronizer.ts +++ b/src/libs/telemetry/TelemetrySynchronizer.ts @@ -6,10 +6,13 @@ import * as Sentry from '@sentry/react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import AppStateMonitor from '@libs/AppStateMonitor'; +import Log from '@libs/Log'; import {getActivePolicies} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Session, TryNewDot} from '@src/types/onyx'; +import getMemoryInfo from './getMemoryInfo'; /** * Connect to Onyx to retrieve information about the user's active policies. @@ -77,3 +80,95 @@ function sendTryNewDotCohortTag() { } Sentry.setTag(CONST.TELEMETRY.TAG_NUDGE_MIGRATION_COHORT, cohort); } +/** + * Send memory usage context to Sentry + * Called on app start, when app comes to foreground, and periodically every 2 minutes + */ +function sendMemoryContext() { + getMemoryInfo() + .then((memoryInfo) => { + const totalOrMax = memoryInfo.maxMemoryBytes ?? memoryInfo.totalMemoryBytes; + const freeMemoryMB = totalOrMax && memoryInfo.usedMemoryBytes ? Math.round((totalOrMax - memoryInfo.usedMemoryBytes) / (1024 * 1024)) : null; + const usedMemoryMB = memoryInfo.usedMemoryBytes ? Math.round(memoryInfo.usedMemoryBytes / (1024 * 1024)) : null; + + let logLevel: Sentry.SeverityLevel = 'info'; + /** + * Log Level Thresholds (Based on OS resource management): + * * 1. < 50MB (Error): Critical memory exhaustion. The OS's memory killer + * (Jetsam on iOS / Low Memory Killer on Android) is likely to terminate the process immediately. + * * 2. < 120MB (Warning): System starts sending 'didReceiveMemoryWarning' signals. + * The app is unstable and any sudden allocation spike will lead to a crash. + * * 3. > 120MB (Info): Safe operational zone for most modern mobile devices. + */ + if (freeMemoryMB !== null) { + if (freeMemoryMB < CONST.TELEMETRY.MEMORY_THRESHOLD_CRITICAL) { + logLevel = 'error'; + } else if (freeMemoryMB < CONST.TELEMETRY.MEMORY_THRESHOLD_WARNING) { + logLevel = 'warning'; + } + } else if (memoryInfo.usagePercentage && memoryInfo.usagePercentage > 90) { + logLevel = 'error'; + } + + Sentry.addBreadcrumb({ + category: 'system.memory', + message: `RAM Check: ${usedMemoryMB ?? '?'}MB used / ${freeMemoryMB ?? '?'}MB free`, + level: logLevel, + data: { + ...memoryInfo, + freeMemoryMB, + usedMemoryMB, + }, + }); + + Sentry.setContext(CONST.TELEMETRY.CONTEXT_MEMORY, { + ...memoryInfo, + freeMemoryMB, + lowMemoryThreat: logLevel !== 'info', + lastUpdated: new Date().toISOString(), + }); + }) + .catch((error) => { + Log.hmmm('[SentrySync] Failed to get memory info', { + error, + }); + }); +} + +/** + * Store interval ID and cleanup function for cleanup on hot reload (development only) + * In production, this will be cleaned up when the app terminates + */ +let memoryTrackingIntervalID: ReturnType | undefined; +let memoryTrackingListenerCleanup: (() => void) | undefined; + +/** + * Clear existing interval and listener before creating new ones + * This prevents interval/listener stacking on hot reload in development + */ +function initializeMemoryTracking() { + // Cleanup existing interval + if (memoryTrackingIntervalID) { + clearInterval(memoryTrackingIntervalID); + memoryTrackingIntervalID = undefined; + } + + // Cleanup existing listener + if (memoryTrackingListenerCleanup) { + memoryTrackingListenerCleanup(); + memoryTrackingListenerCleanup = undefined; + } + + // Initialize memory tracking + sendMemoryContext(); + + // Update memory when app comes to foreground + memoryTrackingListenerCleanup = AppStateMonitor.addBecameActiveListener(sendMemoryContext); + + // Update memory periodically (every 2 minutes) + // This is safe given Android's 5-second rate limit on getUsedMemory() + memoryTrackingIntervalID = setInterval(sendMemoryContext, 2 * 60 * 1000); +} + +// Initialize memory tracking +initializeMemoryTracking(); diff --git a/src/libs/telemetry/getMemoryInfo.ts b/src/libs/telemetry/getMemoryInfo.ts new file mode 100644 index 000000000000..d1b74f6457a4 --- /dev/null +++ b/src/libs/telemetry/getMemoryInfo.ts @@ -0,0 +1,47 @@ +import {Platform} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import Log from '@libs/Log'; + +type MemoryInfo = { + usedMemoryBytes: number | null; + totalMemoryBytes: number | null; + maxMemoryBytes: number | null; + usagePercentage: number | null; + platform: string; +}; + +/** + * Gets memory usage information for telemetry + * Used by both Sentry tracking and troubleshooting tools + * Returns raw byte values - use formatBytes() from NumberUtils for display formatting + */ +const getMemoryInfo = async (): Promise => { + try { + // getTotalMemory has a sync version - use it for performance + const totalMemoryBytes = DeviceInfo.getTotalMemorySync?.() ?? null; + + const [usedMemory, maxMemory] = await Promise.allSettled([DeviceInfo.getUsedMemory(), Platform.OS === 'android' ? DeviceInfo.getMaxMemory() : Promise.resolve(null)]); + + const usedMemoryBytes = usedMemory.status === 'fulfilled' ? usedMemory.value : null; + const maxMemoryBytes = maxMemory.status === 'fulfilled' ? maxMemory.value : null; + + return { + usedMemoryBytes, + totalMemoryBytes, + maxMemoryBytes, + usagePercentage: usedMemoryBytes !== null && totalMemoryBytes !== null && totalMemoryBytes > 0 ? Math.round((usedMemoryBytes / totalMemoryBytes) * 100 * 100) / 100 : null, + platform: Platform.OS, + }; + } catch (error) { + Log.hmmm('[getMemoryInfo] Failed to get memory info', {error}); + return { + usedMemoryBytes: null, + totalMemoryBytes: null, + maxMemoryBytes: null, + usagePercentage: null, + platform: Platform.OS, + }; + } +}; + +export default getMemoryInfo; From bc61967dad89a942505829e437c90d7e494109fd Mon Sep 17 00:00:00 2001 From: Szymon Zalarski <31442502+szymonzalarski98@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:53:50 +0100 Subject: [PATCH 006/271] Add memory usage data for spans --- src/libs/telemetry/middlewares/scopeTagsEnricher.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/telemetry/middlewares/scopeTagsEnricher.ts b/src/libs/telemetry/middlewares/scopeTagsEnricher.ts index 4edfb1227079..09f823b5ccc2 100644 --- a/src/libs/telemetry/middlewares/scopeTagsEnricher.ts +++ b/src/libs/telemetry/middlewares/scopeTagsEnricher.ts @@ -29,6 +29,9 @@ const scopeTagsEnricher: TelemetryBeforeSend = (event: TransactionEvent): Transa ...(scopeData.contexts?.[CONST.TELEMETRY.CONTEXT_POLICIES] && { [CONST.TELEMETRY.CONTEXT_POLICIES]: scopeData.contexts[CONST.TELEMETRY.CONTEXT_POLICIES], }), + ...(scopeData.contexts?.[CONST.TELEMETRY.CONTEXT_MEMORY] && { + [CONST.TELEMETRY.CONTEXT_MEMORY]: scopeData.contexts[CONST.TELEMETRY.CONTEXT_MEMORY], + }), }, }; From cd41de345021f5972a460a8c9972856813a99bf7 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 14 Jan 2026 17:33:59 +0700 Subject: [PATCH 007/271] fix: refactor modals --- src/pages/settings/InitialSettingsPage.tsx | 50 +++----- .../Security/LockAccount/LockAccountPage.tsx | 114 +++++++++--------- .../Security/TwoFactorAuth/DisablePage.tsx | 31 +++-- .../Security/TwoFactorAuth/EnabledPage.tsx | 30 ++--- 4 files changed, 107 insertions(+), 118 deletions(-) diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index d3be766079bf..15b366a9aa81 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -1,15 +1,15 @@ import {findFocusedRoute, useNavigationState, useRoute} from '@react-navigation/native'; import {differenceInDays} from 'date-fns'; -import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, ScrollView as RNScrollView, ScrollViewProps, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import AccountSwitcher from '@components/AccountSwitcher'; import AccountSwitcherSkeletonView from '@components/AccountSwitcherSkeletonView'; -import ConfirmModal from '@components/ConfirmModal'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import {PressableWithFeedback} from '@components/Pressable'; @@ -20,6 +20,7 @@ import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -138,8 +139,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const previousUserPersonalDetails = usePrevious(currentUserPersonalDetails); const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {canBeMissing: true}); - const shouldLogout = useRef(false); - const freeTrialText = getFreeTrialText(translate, policies, introSelected, firstDayFreeTrial, lastDayFreeTrial); const shouldDisplayLHB = !shouldUseNarrowLayout; @@ -165,8 +164,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr return undefined; }, [allCards, bankAccountList, fundList, hasBrokenFeedConnection, hasPendingCardAction, unsharedBankAccount?.errors, userWallet?.errors, walletTerms?.errors]); - const [shouldShowSignoutConfirmModal, setShouldShowSignoutConfirmModal] = useState(false); - const hasAccountBeenSwitched = useMemo( () => currentUserPersonalDetails.accountID !== previousUserPersonalDetails.accountID, [currentUserPersonalDetails.accountID, previousUserPersonalDetails.accountID], @@ -185,18 +182,30 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr confirmReadyToOpenApp(); }, []); - const toggleSignoutConfirmModal = (value: boolean) => { - setShouldShowSignoutConfirmModal(value); - }; + const {showConfirmModal} = useConfirmModal(); + const showSignOutModal = useCallback(() => { + return showConfirmModal({ + title: translate('common.areYouSure'), + prompt: translate('initialSettingsPage.signOutConfirmationText'), + confirmText: translate('initialSettingsPage.signOut'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + danger: true, + }); + }, [showConfirmModal, translate]); const signOut = useCallback( - (shouldForceSignout = false) => { + async (shouldForceSignout = false) => { if (!network.isOffline || shouldForceSignout) { return signOutAndRedirectToSignIn(); } // When offline, warn the user that any actions they took while offline will be lost if they sign out - toggleSignoutConfirmModal(true); + const result = await showSignOutModal(); + if (result.action !== ModalActions.CONFIRM) { + return; + } + signOut(true); }, [network.isOffline], ); @@ -534,25 +543,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr > {accountMenuItems} {generalMenuItems} - { - toggleSignoutConfirmModal(false); - shouldLogout.current = true; - }} - onCancel={() => toggleSignoutConfirmModal(false)} - onModalHide={() => { - if (!shouldLogout.current) { - return; - } - signOut(true); - }} - /> {shouldDisplayLHB && } diff --git a/src/pages/settings/Security/LockAccount/LockAccountPage.tsx b/src/pages/settings/Security/LockAccount/LockAccountPage.tsx index 13810cc287cf..c7de70ef14d6 100644 --- a/src/pages/settings/Security/LockAccount/LockAccountPage.tsx +++ b/src/pages/settings/Security/LockAccount/LockAccountPage.tsx @@ -1,9 +1,10 @@ -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; -import ConfirmModal from '@components/ConfirmModal'; import HeaderPageLayout from '@components/HeaderPageLayout'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -16,12 +17,49 @@ import ROUTES from '@src/ROUTES'; function LockAccountPage() { const {translate} = useLocalize(); - const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const [isLoading, setIsLoading] = useState(false); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); + const {showConfirmModal} = useConfirmModal(); + const showReportSuspiciousActivityModal = useCallback(async () => { + const result = await showConfirmModal({ + title: translate('lockAccountPage.reportSuspiciousActivity'), + prompt: ( + <> + {translate('lockAccountPage.areYouSure')} + {translate('lockAccountPage.onceLocked')} + + ), + confirmText: translate('lockAccountPage.lockAccount'), + cancelText: translate('common.cancel'), + shouldShowCancelButton: true, + danger: true, + shouldDisableConfirmButtonWhenOffline: true, + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + // If there is no user accountID yet (because the app isn't fully setup yet), so return early + if (session?.accountID === -1) { + return; + } + setIsLoading(true); + lockAccount().then((response) => { + setIsLoading(false); + if (!response?.jsonCode) { + return; + } + + if (response.jsonCode === CONST.JSON_CODE.SUCCESS) { + Navigation.navigate(ROUTES.SETTINGS_UNLOCK_ACCOUNT); + } else { + Navigation.navigate(ROUTES.SETTINGS_FAILED_TO_LOCK_ACCOUNT); + } + }); + }, [showConfirmModal, translate, session?.accountID, styles.mb5]); + const lockAccountButton = (