From 70179ece2e9d992c3097e5e3a537850c4c829964 Mon Sep 17 00:00:00 2001 From: Yauheni Pasiukevich Date: Tue, 23 Dec 2025 18:42:08 +0100 Subject: [PATCH] feat: Add background receipt upload functionality and related permissions - Implemented `UploadWorker` for handling background receipt uploads using WorkManager. - Added `startReceiptUpload` method to the React Native module for initiating uploads. - Updated AndroidManifest.xml to include `FOREGROUND_SERVICE` permission. - Enhanced `ReactNativeBackgroundTaskModule` to register a receiver for upload results. - Updated dependencies in build.gradle for WorkManager and OkHttp. - Introduced logging for upload processes and results. - Added constants for upload-related keys and notification management. --- modules/background-task/android/build.gradle | 2 + .../android/src/main/AndroidManifest.xml | 1 + .../ReactNativeBackgroundTaskModule.kt | 86 ++++++++++ .../reactnativebackgroundtask/UploadWorker.kt | 159 ++++++++++++++++++ .../oldarch/ReactNativeBackgroundTaskSpec.kt | 3 + .../src/NativeReactNativeBackgroundTask.ts | 1 + modules/background-task/src/index.ts | 4 + src/CONST/index.ts | 1 + src/libs/Network/BackgroundReceiptUpload.ts | 67 ++++++++ src/libs/Network/SequentialQueue.ts | 21 ++- src/libs/actions/IOU.ts | 45 +++++ src/libs/actions/OnyxUpdates.ts | 11 ++ src/libs/actions/PersistedRequests.ts | 24 ++- 13 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/UploadWorker.kt create mode 100644 src/libs/Network/BackgroundReceiptUpload.ts diff --git a/modules/background-task/android/build.gradle b/modules/background-task/android/build.gradle index ffb17153aae3..d72f0ed4c2ca 100644 --- a/modules/background-task/android/build.gradle +++ b/modules/background-task/android/build.gradle @@ -93,6 +93,8 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation "com.squareup.okhttp3:okhttp:4.12.0" } if (isNewArchitectureEnabled()) { diff --git a/modules/background-task/android/src/main/AndroidManifest.xml b/modules/background-task/android/src/main/AndroidManifest.xml index cf2838eb09ee..f4a008cdd2c1 100644 --- a/modules/background-task/android/src/main/AndroidManifest.xml +++ b/modules/background-task/android/src/main/AndroidManifest.xml @@ -1,3 +1,4 @@ + diff --git a/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/ReactNativeBackgroundTaskModule.kt b/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/ReactNativeBackgroundTaskModule.kt index 6b0f81c0e92d..7d038175fc0c 100644 --- a/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/ReactNativeBackgroundTaskModule.kt +++ b/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/ReactNativeBackgroundTaskModule.kt @@ -4,6 +4,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Callback +import com.facebook.react.bridge.ReadableMap import android.app.job.JobScheduler import android.app.job.JobInfo import android.content.BroadcastReceiver @@ -17,6 +18,10 @@ import android.util.Log import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Arguments +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplicationContext) : ReactNativeBackgroundTaskSpec(context) { @@ -37,6 +42,22 @@ class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplica } } + private val uploadResultReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != UploadWorker.ACTION_UPLOAD_RESULT) return + val params = Arguments.createMap() + params.putString("transactionID", intent.getStringExtra(UploadWorker.KEY_TRANSACTION_ID)) + params.putBoolean("success", intent.getBooleanExtra(UploadWorker.KEY_RESULT_SUCCESS, false)) + params.putInt("code", intent.getIntExtra(UploadWorker.KEY_RESULT_CODE, -1)) + val message = intent.getStringExtra(UploadWorker.KEY_RESULT_MESSAGE) + if (message != null) { + params.putString("message", message) + } + Log.d(NAME, "Upload result received ${params.toString()}") + sendEvent("onReceiptUploadResult", params) + } + } + init { val filter = IntentFilter("com.expensify.reactnativebackgroundtask.TASK_ACTION") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -45,6 +66,15 @@ class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplica filter, Context.RECEIVER_EXPORTED ) + } else { + reactApplicationContext.registerReceiver(taskReceiver, filter) + } + + val uploadFilter = IntentFilter(UploadWorker.ACTION_UPLOAD_RESULT) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + reactApplicationContext.registerReceiver(uploadResultReceiver, uploadFilter, Context.RECEIVER_NOT_EXPORTED) + } else { + reactApplicationContext.registerReceiver(uploadResultReceiver, uploadFilter) } } @@ -60,6 +90,12 @@ class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplica } catch (e: IllegalArgumentException) { Log.w("ReactNativeBackgroundTaskModule", "Receiver not registered or already unregistered") } + try { + reactApplicationContext.unregisterReceiver(uploadResultReceiver) + Log.d("ReactNativeBackgroundTaskModule", "Upload result receiver unregistered") + } catch (e: IllegalArgumentException) { + Log.w("ReactNativeBackgroundTaskModule", "Upload result receiver not registered or already unregistered") + } } @ReactMethod @@ -81,6 +117,7 @@ class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplica val resultCode = jobScheduler.schedule(jobInfo) if (resultCode == JobScheduler.RESULT_SUCCESS) { + Log.d(NAME, "Scheduled background task: $taskName") promise.resolve(null); } else { @@ -99,6 +136,55 @@ class ReactNativeBackgroundTaskModule internal constructor(context: ReactApplica // no-op } + @ReactMethod + override fun startReceiptUpload(options: ReadableMap, promise: Promise) { + try { + val url = options.getString("url") ?: run { + promise.reject("INVALID_ARGS", "Missing url") + return + } + val filePath = options.getString("filePath") ?: run { + promise.reject("INVALID_ARGS", "Missing filePath") + return + } + val fileName = options.getString("fileName") ?: "" + val mimeType = options.getString("mimeType") ?: "application/octet-stream" + val transactionId = options.getString("transactionID") ?: "" + val fields = options.getMap("fields")?.toHashMap()?.mapValues { it.value.toString() } ?: emptyMap() + val headers = options.getMap("headers")?.toHashMap()?.mapValues { it.value.toString() } ?: emptyMap() + + Log.d(NAME, "Enqueue receipt upload tx=$transactionId file=$filePath url=$url") + + val inputData: Data = UploadWorker.buildInputData( + url = url, + filePath = filePath, + fileName = fileName, + mimeType = mimeType, + transactionId = transactionId, + fields = fields, + headers = headers + ) + + val request = OneTimeWorkRequestBuilder() + .setInputData(inputData) + .addTag("receipt_upload") + .addTag(transactionId.ifEmpty { "receipt_upload_generic" }) + .build() + + WorkManager.getInstance(reactApplicationContext) + .enqueueUniqueWork( + "receipt_upload_$transactionId", + ExistingWorkPolicy.REPLACE, + request + ) + + promise.resolve(null) + } catch (e: Exception) { + Log.e(NAME, "Failed to enqueue receipt upload", e) + promise.reject("UPLOAD_ENQUEUE_FAILED", e) + } + } + companion object { const val NAME = "ReactNativeBackgroundTask" } diff --git a/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/UploadWorker.kt b/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/UploadWorker.kt new file mode 100644 index 000000000000..5ee5808e0d27 --- /dev/null +++ b/modules/background-task/android/src/main/java/com/expensify/reactnativebackgroundtask/UploadWorker.kt @@ -0,0 +1,159 @@ +package com.expensify.reactnativebackgroundtask + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import com.facebook.react.bridge.ReactApplicationContext +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import java.io.File + +/** + * Worker responsible for uploading a receipt file in the background. + * It performs a multipart upload using OkHttp to mirror the RequestMoney payload. + */ +class UploadWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val url = inputData.getString(KEY_URL) ?: return@withContext Result.failure() + val rawFilePath = inputData.getString(KEY_FILE_PATH) ?: return@withContext Result.failure() + // WorkManager cannot read "file://" URIs directly; normalize to a plain path so File() works. + val filePath = rawFilePath.removePrefix("file://") + val fileName = inputData.getString(KEY_FILE_NAME) ?: File(filePath).name + val mimeType = inputData.getString(KEY_MIME_TYPE) ?: "application/octet-stream" + val transactionId = inputData.getString(KEY_TRANSACTION_ID) ?: "" + val fields = inputData.getKeyValueMap().filterKeys { it.startsWith(KEY_FIELD_PREFIX) } + val headers = inputData.getKeyValueMap().filterKeys { it.startsWith(KEY_HEADER_PREFIX) } + + Log.d(TAG, "Starting UploadWorker for transactionId=$transactionId file=$filePath url=$url") + setForeground(createForegroundInfo(transactionId)) + + val file = File(filePath) + if (!file.exists()) { + Log.w(TAG, "UploadWorker file not found: $filePath") + return@withContext Result.failure() + } + + val bodyBuilder = MultipartBody.Builder().setType(MultipartBody.FORM) + fields.forEach { (key, value) -> + val fieldName = key.removePrefix(KEY_FIELD_PREFIX) + bodyBuilder.addFormDataPart(fieldName, value.toString()) + } + val fileRequestBody = RequestBody.create(mimeType.toMediaTypeOrNull(), file) + bodyBuilder.addFormDataPart("receipt", fileName, fileRequestBody) + + val requestBuilder = Request.Builder().url(url).post(bodyBuilder.build()) + headers.forEach { (key, value) -> + val headerName = key.removePrefix(KEY_HEADER_PREFIX) + requestBuilder.addHeader(headerName, value.toString()) + } + + return@withContext try { + val client = OkHttpClient() + val response = client.newCall(requestBuilder.build()).execute() + if (response.isSuccessful) { + Log.d(TAG, "UploadWorker success transactionId=$transactionId") + broadcastResult(success = true, code = response.code, transactionId = transactionId) + Result.success() + } else { + val bodyString = response.body?.string()?.take(200) ?: "no_body" + Log.w(TAG, "UploadWorker response failure code=${response.code} transactionId=$transactionId body=$bodyString") + broadcastResult(success = false, code = response.code, transactionId = transactionId) + Result.retry() + } + } catch (e: Exception) { + Log.e(TAG, "UploadWorker exception transactionId=$transactionId", e) + broadcastResult(success = false, code = -1, transactionId = transactionId, message = e.message ?: "Exception") + Result.retry() + } + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = createNotification("Uploading receipt") + return ForegroundInfo(NOTIFICATION_ID, notification) + } + + private fun createForegroundInfo(transactionId: String): ForegroundInfo { + val notification = createNotification("Uploading receipt $transactionId") + return ForegroundInfo(NOTIFICATION_ID, notification) + } + + private fun createNotification(contentText: String): Notification { + val channelId = NOTIFICATION_CHANNEL_ID + val channelName = "Receipt Uploads" + val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + manager.createNotificationChannel(channel) + } + return NotificationCompat.Builder(applicationContext, channelId) + .setContentTitle("Uploading receipt") + .setContentText(contentText) + .setSmallIcon(android.R.drawable.stat_sys_upload) + .setOngoing(true) + .build() + } + + companion object { + const val KEY_URL = "upload_url" + const val KEY_FILE_PATH = "upload_file_path" + const val KEY_FILE_NAME = "upload_file_name" + const val KEY_MIME_TYPE = "upload_mime_type" + const val KEY_TRANSACTION_ID = "upload_transaction_id" + const val KEY_RESULT_CODE = "upload_result_code" + const val KEY_RESULT_SUCCESS = "upload_result_success" + const val KEY_RESULT_MESSAGE = "upload_result_message" + const val KEY_FIELD_PREFIX = "field_" + const val KEY_HEADER_PREFIX = "header_" + const val NOTIFICATION_CHANNEL_ID = "receipt_upload_channel" + const val NOTIFICATION_ID = 9237 + const val TAG = "UploadWorker" + const val ACTION_UPLOAD_RESULT = "com.expensify.reactnativebackgroundtask.UPLOAD_RESULT" + + fun buildInputData( + url: String, + filePath: String, + fileName: String, + mimeType: String, + transactionId: String, + fields: Map, + headers: Map + ): Data { + val builder = Data.Builder() + .putString(KEY_URL, url) + .putString(KEY_FILE_PATH, filePath) + .putString(KEY_FILE_NAME, fileName) + .putString(KEY_MIME_TYPE, mimeType) + .putString(KEY_TRANSACTION_ID, transactionId) + fields.forEach { (key, value) -> builder.putString("$KEY_FIELD_PREFIX$key", value) } + headers.forEach { (key, value) -> builder.putString("$KEY_HEADER_PREFIX$key", value) } + return builder.build() + } + } + + private fun broadcastResult(success: Boolean, code: Int, transactionId: String, message: String? = null) { + val intent = Intent(ACTION_UPLOAD_RESULT).apply { + putExtra(KEY_TRANSACTION_ID, transactionId) + putExtra(KEY_RESULT_SUCCESS, success) + putExtra(KEY_RESULT_CODE, code) + if (message != null) { + putExtra(KEY_RESULT_MESSAGE, message) + } + } + applicationContext.sendBroadcast(intent) + } +} diff --git a/modules/background-task/android/src/oldarch/ReactNativeBackgroundTaskSpec.kt b/modules/background-task/android/src/oldarch/ReactNativeBackgroundTaskSpec.kt index 138a1cc1d4af..8053ccd1a717 100644 --- a/modules/background-task/android/src/oldarch/ReactNativeBackgroundTaskSpec.kt +++ b/modules/background-task/android/src/oldarch/ReactNativeBackgroundTaskSpec.kt @@ -3,9 +3,12 @@ package com.expensify.reactnativebackgroundtask import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.Callback abstract class ReactNativeBackgroundTaskSpec internal constructor(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { abstract fun defineTask(taskName: String, taskExecutor: Callback, promise: Promise) + abstract fun startReceiptUpload(options: ReadableMap, promise: Promise) } diff --git a/modules/background-task/src/NativeReactNativeBackgroundTask.ts b/modules/background-task/src/NativeReactNativeBackgroundTask.ts index 737d783d9634..a4169a94b0a4 100644 --- a/modules/background-task/src/NativeReactNativeBackgroundTask.ts +++ b/modules/background-task/src/NativeReactNativeBackgroundTask.ts @@ -5,6 +5,7 @@ import {TurboModuleRegistry} from 'react-native'; // eslint-disable-next-line rulesdir/no-inline-named-export, @typescript-eslint/consistent-type-definitions export interface Spec extends TurboModule { defineTask(taskName: string, taskExecutor: (data: unknown) => void | Promise): Promise; + startReceiptUpload(options: Object): Promise; addListener: (eventType: string) => void; removeListeners: (count: number) => void; } diff --git a/modules/background-task/src/index.ts b/modules/background-task/src/index.ts index d1254b991eed..223d1c81c137 100644 --- a/modules/background-task/src/index.ts +++ b/modules/background-task/src/index.ts @@ -41,6 +41,10 @@ const TaskManager = { return NativeReactNativeBackgroundTask.defineTask(taskName, taskExecutor); }, + /** + * Starts a background receipt upload on Android. + */ + startReceiptUpload: (options: Record): Promise => NativeReactNativeBackgroundTask.startReceiptUpload(options), }; addBackgroundTaskListener(); diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9780d4cb607a..15f4dd875aad 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1874,6 +1874,7 @@ const CONST = { MAX_PENDING_TIME_MS: 10 * 1000, RECHECK_INTERVAL_MS: 60 * 1000, MAX_REQUEST_RETRIES: 10, + MAX_RECEIPT_UPLOAD_RETRIES: 30, MAX_OPEN_APP_REQUEST_RETRIES: 2, NETWORK_STATUS: { ONLINE: 'online', diff --git a/src/libs/Network/BackgroundReceiptUpload.ts b/src/libs/Network/BackgroundReceiptUpload.ts new file mode 100644 index 000000000000..776bdb2c3274 --- /dev/null +++ b/src/libs/Network/BackgroundReceiptUpload.ts @@ -0,0 +1,67 @@ +import {NativeEventEmitter, Platform} from 'react-native'; +import TaskManager from '@expensify/react-native-background-task'; +import Log from '@libs/Log'; + +type UploadOptions = { + url: string; + filePath: string; + fileName?: string; + mimeType?: string; + transactionID?: string; + fields?: Record; + headers?: Record; +}; + +let unsubscribeUploadEvents: (() => void) | undefined; + +function ensureSubscribed() { + if (unsubscribeUploadEvents || Platform.OS !== 'android') { + return; + } + unsubscribeUploadEvents = subscribeToBackgroundReceiptUpload((result) => { + Log.info('[BackgroundReceiptUpload] Received native upload result', false, result); + }); +} + +/** + * Starts a background receipt upload on Android using the native background-task module. + * On other platforms, this resolves immediately. + */ +function startBackgroundReceiptUpload(options: UploadOptions): Promise { + if (Platform.OS !== 'android') { + return Promise.resolve(); + } + + const {url, filePath, fileName = '', mimeType = 'application/octet-stream', transactionID = '', fields = {}, headers = {}} = options; + ensureSubscribed(); + Log.info('[BackgroundReceiptUpload] Enqueue upload', false, {url, filePath, transactionID}); + return TaskManager.startReceiptUpload({ + url, + filePath, + fileName, + mimeType, + transactionID, + fields, + headers, + }).catch((error) => { + Log.hmmm('[BackgroundReceiptUpload] Failed to enqueue upload', {error}); + throw error; + }); +} + +/** + * Listen for background upload results emitted from the native module. + */ +function subscribeToBackgroundReceiptUpload(callback: (result: {transactionID?: string; success: boolean; code: number; message?: string}) => void) { + if (Platform.OS !== 'android') { + return () => {}; + } + const eventEmitter = new NativeEventEmitter(TaskManager as unknown as {addListener: (...args: unknown[]) => unknown}); + const subscription = eventEmitter.addListener('onReceiptUploadResult', (result) => { + Log.info('[BackgroundReceiptUpload] Upload result', false, result); + callback(result); + }); + return () => subscription.remove(); +} + +export {startBackgroundReceiptUpload, subscribeToBackgroundReceiptUpload}; diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts index ed85e6662ac0..e087d7f6e552 100644 --- a/src/libs/Network/SequentialQueue.ts +++ b/src/libs/Network/SequentialQueue.ts @@ -144,6 +144,14 @@ function process(): Promise { return Promise.resolve(); } + Log.info('[SequentialQueue] Handling request', false, { + command: requestToProcess.command, + requestID: requestToProcess.requestID, + transactionID: requestToProcess.data?.transactionID, + receiptState: requestToProcess.data?.receipt?.state, + initiatedOffline: requestToProcess.initiatedOffline, + }); + // Set the current request to a promise awaiting its processing so that getCurrentRequest can be used to take some action after the current request has processed. currentRequestPromise = processWithMiddleware(requestToProcess, true) .then((response) => { @@ -154,6 +162,15 @@ function process(): Promise { pause(); } + Log.info('[SequentialQueue] Received response for request', false, { + command: requestToProcess.command, + requestID: requestToProcess.requestID, + transactionID: requestToProcess.data?.transactionID, + receiptState: requestToProcess.data?.receipt?.state, + jsonCode: response?.jsonCode, + shouldPauseQueue: response?.shouldPauseQueue, + }); + Log.info('[SequentialQueue] Removing persisted request because it was processed successfully.', false, {request: requestToProcess}); endPersistedRequestAndRemoveFromQueue(requestToProcess); @@ -186,8 +203,10 @@ function process(): Promise { return process(); } rollbackOngoingPersistedRequest(); + const hasReceipt = !!requestToProcess.data?.receipt || !!requestToProcess.data?.receiptState; + const maxRetries = hasReceipt ? CONST.NETWORK.MAX_RECEIPT_UPLOAD_RETRIES : undefined; return sequentialQueueRequestThrottle - .sleep(error, requestToProcess.command) + .sleep(error, requestToProcess.command, maxRetries) .then(process) .catch(() => { Onyx.update(requestToProcess.failureData ?? []); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index baedd1485888..ccb9109bdfd1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -238,6 +238,9 @@ import { import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import type {IOUAction, IOUActionParams, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; +import {startBackgroundReceiptUpload} from '@libs/Network/BackgroundReceiptUpload'; +import {getApiRoot, getCommandURL} from '@libs/ApiUtils'; +import {Platform} from 'react-native'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -6523,6 +6526,48 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep guidedSetupData: guidedSetupData ? JSON.stringify(guidedSetupData) : undefined, testDriveCommentReportActionID, }; + if (Platform.OS === CONST.PLATFORM.ANDROID && isFileUploadable(receipt)) { + try { + const session = getSession(); + const authToken = session?.authToken ?? ''; + const apiRoot = getApiRoot({ + shouldUseSecure: true, + shouldSkipWebProxy: false, + } as unknown as any); + const url = `${apiRoot}api?command=${WRITE_COMMANDS.REQUEST_MONEY}`; + const fields: Record = {}; + Object.entries(parameters).forEach(([key, value]) => { + if (key === 'receipt' || value === undefined || value === null) { + return; + } + fields[key] = typeof value === 'string' ? value : JSON.stringify(value); + }); + const filePath = (receipt as Receipt)?.source ?? (receipt as Receipt)?.uri ?? ''; + startBackgroundReceiptUpload({ + url, + filePath, + fileName: (receipt as Receipt)?.name ?? '', + mimeType: (receipt as Receipt)?.type ?? 'application/octet-stream', + transactionID: transaction.transactionID, + fields: { + ...fields, + authToken, + }, + headers: { + 'X-Auth-Token': authToken, + }, + }).catch((error) => { + Log.hmmm('[RequestMoney] Failed to enqueue background upload', {error}); + }); + Log.info('[RequestMoney] Enqueued background upload', false, { + transactionID: transaction.transactionID, + filePath, + }); + } + catch (error) { + Log.hmmm('[RequestMoney] Error preparing background upload', {error}); + } + } // eslint-disable-next-line rulesdir/no-multiple-api-calls API.write(WRITE_COMMANDS.REQUEST_MONEY, parameters, onyxData); } diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index 3feb758a8f07..eb6cd8033c08 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -37,6 +37,17 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response, lastUpdateI // For most requests we can immediately update Onyx. For write requests we queue the updates and apply them after the sequential queue has flushed to prevent a replay effect in // the UI. See https://github.com/Expensify/App/issues/12775 for more info. const updateHandler: (updates: OnyxUpdate[]) => Promise = request?.data?.apiRequestType === CONST.API_REQUEST_TYPE.WRITE ? queueOnyxUpdates : Onyx.update; + const transactionKeysInResponse = (response.onyxData ?? []) + .map((update) => update.key) + .filter((key) => typeof key === 'string' && (key.includes('transactions_') || key.includes('transactionsDraft_') || key.includes('transaction_'))); + if (transactionKeysInResponse.length > 0) { + Log.info('[OnyxUpdateManager] Transaction/receipt updates in https response', false, { + lastUpdateID, + command: request.command, + keys: transactionKeysInResponse.slice(0, 10), + total: transactionKeysInResponse.length, + }); + } // First apply any onyx data updates that are being sent back from the API. We wait for this to complete and then // apply successData or failureData. This ensures that we do not update any pending, loading, or other UI states contained diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts index e537fedde7a2..c6507d850940 100644 --- a/src/libs/actions/PersistedRequests.ts +++ b/src/libs/actions/PersistedRequests.ts @@ -105,6 +105,15 @@ function endRequestAndRemoveFromQueue(requestToRemove: Request) { const requests = [...persistedRequests]; const index = requests.findIndex((persistedRequest) => deepEqual(persistedRequest, requestToRemove)); + Log.info('[PersistedRequests] Ending request and removing from queue', false, { + command: requestToRemove.command, + requestID: requestToRemove.requestID, + transactionID: requestToRemove.data?.transactionID, + receiptState: requestToRemove.data?.receipt?.state, + foundIndex: index, + queuedBefore: persistedRequests.length, + }); + if (index !== -1) { requests.splice(index, 1); } @@ -115,7 +124,10 @@ function endRequestAndRemoveFromQueue(requestToRemove: Request) { [ONYXKEYS.PERSISTED_REQUESTS]: persistedRequests, [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: null, }).then(() => { - Log.info(`[SequentialQueue] '${requestToRemove.command}' removed from the queue. Queue length is ${getLength()}`); + Log.info(`[SequentialQueue] '${requestToRemove.command}' removed from the queue. Queue length is ${getLength()}`, false, { + requestID: requestToRemove.requestID, + remainingQueued: persistedRequests.length, + }); }); } @@ -145,7 +157,7 @@ function updateOngoingRequest(newRequest: Request) { Log.info('[PersistedRequests] Updating the ongoing request', false, {ongoingRequest, newRequest}); ongoingRequest = newRequest; - if (newRequest.persistWhenOngoing) { + if (newRequest.persistWhenOngoing) { Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, newRequest); } } @@ -167,6 +179,14 @@ function processNextRequest(): Request | null { const newPersistedRequests = persistedRequests.slice(1); persistedRequests = newPersistedRequests; + Log.info('[PersistedRequests] Processing next request', false, { + command: ongoingRequest?.command, + requestID: ongoingRequest?.requestID, + transactionID: ongoingRequest?.data?.transactionID, + receiptState: ongoingRequest?.data?.receipt?.state, + remainingQueued: persistedRequests.length, + }); + if (ongoingRequest && ongoingRequest.persistWhenOngoing) { Onyx.set(ONYXKEYS.PERSISTED_ONGOING_REQUESTS, ongoingRequest); }