Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions modules/background-task/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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)
}
}

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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<UploadWorker>()
.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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>,
headers: Map<String, String>
): 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// 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<void>): Promise<void>;
startReceiptUpload(options: Object): Promise<void>;

Check failure on line 8 in modules/background-task/src/NativeReactNativeBackgroundTask.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Prefer using the primitive `object` as a type name, rather than the upper-cased `Object`

Check failure on line 8 in modules/background-task/src/NativeReactNativeBackgroundTask.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Prefer using the primitive `object` as a type name, rather than the upper-cased `Object`
addListener: (eventType: string) => void;
removeListeners: (count: number) => void;
}
Expand Down
4 changes: 4 additions & 0 deletions modules/background-task/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const TaskManager = {

return NativeReactNativeBackgroundTask.defineTask(taskName, taskExecutor);
},
/**
* Starts a background receipt upload on Android.
*/
startReceiptUpload: (options: Record<string, unknown>): Promise<void> => NativeReactNativeBackgroundTask.startReceiptUpload(options),
};

addBackgroundTaskListener();
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading