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);
}