facebook / react-native

A framework for building native applications using React
https://reactnative.dev
MIT License
119.53k stars 24.37k forks source link

HeadlessJS crashes on apps targeting newer Android SDKs #36816

Open valeri-terziyski opened 1 year ago

valeri-terziyski commented 1 year ago

Description

The current HeadlessJS recomendation is to call startService on the passed context as described here

var service = Intent(applicationContext, MyTaskService::class)
applicationContext.startService(service)

This is problematic since Android 8 (SDK 26), there are limitations placed on the background services. So up until Android 12 (SDK 31) this could have been fixed with something like

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    applicationContext.startForegroundService(service)
} else {
    applicationContext.startService(service)
}

But this will cause issues on apps targeting higher SDKs, since there are additional resrtictions there.

So the latest recommended approach for starting a background service is by using either WorkManager or Coroutines - https://developer.android.com/guide/background#recommended-approaches

React Native Version

0.71.6

Output of npx react-native info

System: OS: Linux 5.15 Ubuntu 22.04.1 LTS 22.04.1 LTS (Jammy Jellyfish) CPU: (8) x64 AMD Ryzen 7 4700U with Radeon Graphics Memory: 21.18 GB / 38.41 GB Shell: 5.1.16 - /bin/bash Binaries: Node: 16.14.2 - /usr/local/bin/node Yarn: 1.22.17 - /usr/local/bin/yarn npm: 8.5.0 - /usr/local/bin/npm Watchman: 2022.08.15.00 - /home/linuxbrew/.linuxbrew/bin/watchman SDKs: Android SDK: Not Found IDEs: Android Studio: Not Found Languages: Java: 11.0.18 - /usr/bin/javac npmPackages: @react-native-community/cli: Not Found react: 18.2.0 => 18.2.0 react-native: 0.71.6 => 0.71.6 npmGlobalPackages: react-native: Not Found

Steps to reproduce

  1. Checkout the example app
  2. Build it with yarn start and yarn android
  3. Launch it
  4. The app crashes

Snack, code example, screenshot, or link to a repository

https://github.com/valeri-terziyski/react-native-headless-js-example

Steven-DriveQuant commented 1 year ago

@valeri-terziyski Hi, in your sample code you call the BackgroundTask when the app is in foreground, but in the overriden getTaskConfig() method, the last parameter of HeadlessJsTaskConfig instanciation, allowedInForeground, is set to false.

Did your code work if you pass this parameter to true ?

david-arteaga commented 1 year ago

For anyone having this same issue, I was able to integrate the headless js service with WorkManager using bindService instead of startService and a CoroutineWorker that suspends until the headless task finishes executing (onHeadlessJsTaskFinish call from HeadlessJsTaskService)

In the Binder instance returned from onBind, I just added a method to start executing the headless js task that just calls getTaskConfig and startTask.

valeri-terziyski commented 1 year ago

@valeri-terziyski Hi, in your sample code you call the BackgroundTask when the app is in foreground, but in the overriden getTaskConfig() method, the last parameter of HeadlessJsTaskConfig instanciation, allowedInForeground, is set to false.

Did your code work if you pass this parameter to true ?

Unfortunately even after setting it to true, I still encountered a few crashes, so I wouldn't say, that's a viable option.

mrbrentkelly commented 1 year ago

For anyone having this same issue, I was able to integrate the headless js service with WorkManager using bindService instead of startService and a CoroutineWorker that suspends until the headless task finishes executing (onHeadlessJsTaskFinish call from HeadlessJsTaskService)

In the Binder instance returned from onBind, I just added a method to start executing the headless js task that just calls getTaskConfig and startTask.

@david-arteaga do you have any sample code you could share for this 🙏?

david-arteaga commented 1 year ago

@mrbrentkelly sure 😁

This assumes any given instance of BackgroundUploadTaskService will only run 1 headless task at a time (given how it uses the onFinish “callback”). I wasn’t sure what the best way to handle that part was but the callback works.


class BackgroundUploadTaskWorker(
  appContext: Context, workerParameters: WorkerParameters
) : CoroutineWorker(appContext, workerParameters) {
  override suspend fun doWork(): Result {
    delay(500) // delaying because sometimes when the app is being backgrounded, isAppInForeground still returns true

    // Headless JS task crashes if in foreground
    if (isAppInForeground(applicationContext)) {
      Log.i(
        "BackgroundTasksModule",
        "App is currently in the foreground, will not start background upload task"
      )
      return Result.success()
    }

    Log.i(
      "BackgroundTasksModule", "App is not in the foreground, will attempt to run headless js task"
    )
    return runHeadlessJsTask()
  }

  private suspend fun runHeadlessJsTask(): Result {

    val intent = Intent(
      applicationContext, BackgroundUploadTaskService::class.java
    )
    val bundle = Bundle()
    bundle.putLong(
      BACKGROUND_TASK_INTERVAL_MS_PARAM, inputData.getLong(BACKGROUND_TASK_INTERVAL_MS_PARAM, 0)
    )
    intent.putExtras(bundle)

    Log.i(
      "BackgroundTasksModule",
      "Will start executing Headless JS BackgroundUploadTaskService service"
    )
    suspendCoroutine { continuation ->
      var hasUnbound = false // needed because sometimes onAfterServiceDone is called more than once
      val connection = object : ServiceConnection {
        private fun onAfterServiceDone(fromCallback: String) {
          if (hasUnbound) {
            Log.i(
              "BackgroundTasksModule",
              "$fromCallback - Will not unbind from headless js service because already unbound previously"
            )
            return
          }
          hasUnbound = true

          Log.i("BackgroundTasksModule", "$fromCallback - Will unbind from headless js service")
          applicationContext.unbindService(this)
          continuation.resume(null)
        }

        override fun onServiceConnected(className: ComponentName, service: IBinder) {
          Log.d("BackgroundTasksModule", "Service connected")
          val binder = service as BackgroundUploadTaskService.LocalBinder
          binder.startRunning(intent) {
            onAfterServiceDone("onServiceConnected:startRunning:onFinish")
          }
        }

        override fun onServiceDisconnected(arg0: ComponentName) {
          onAfterServiceDone("onServiceDisconnected")
        }
      }

      Log.d("BackgroundTasksModule", "Will bind to service")
      applicationContext.bindService(intent, connection, Context.BIND_AUTO_CREATE)
    }

    Log.d("BackgroundTasksModule", "Will return from BackgroundUploadTaskWorker")
    return Result.success()
  }
}

class BackgroundUploadTaskService : HeadlessJsTaskService() {

  companion object {
    const val HEADLESS_TASK_NAME = "AndroidBackgroundUploadTask"
  }

  override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig {
    Log.d("BackgroundTasksModule", "Will get task config for BackgroundUploadTaskService")

    val defaultTimeoutMs = 60_000 * 15L
    val timeoutMs =
      intent?.getLongExtra(BACKGROUND_TASK_INTERVAL_MS_PARAM, defaultTimeoutMs) ?: defaultTimeoutMs

    val allowedInForeground = false

    return HeadlessJsTaskConfig(
      HEADLESS_TASK_NAME, Arguments.createMap(), timeoutMs, allowedInForeground
    )
  }

  inner class LocalBinder : Binder() {
    fun startRunning(intent: Intent, onFinish: () -> Unit) {
      Log.d("BackgroundTasksModule", "LocalBinder startRunning")
      val service = this@BackgroundUploadTaskService

      val config = getTaskConfig(intent)
      service.startTask(config)
      service.onFinish = onFinish
    }
  }

  private val binder = LocalBinder()

  override fun onBind(intent: Intent): IBinder {
    return binder
  }

  var onFinish: () -> Unit = {}

  override fun onHeadlessJsTaskFinish(taskId: Int) {
    Log.d("BackgroundTasksModule", "Will finish headless js task with id $taskId")
    super.onHeadlessJsTaskFinish(taskId)
    onFinish()
  }

}

fun isAppInForeground(context: Context): Boolean {
  val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
  val appProcesses = activityManager.runningAppProcesses ?: return false
  val packageName = context.packageName
  for (appProcess in appProcesses) {
    if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
      return true
    }
  }
  return false
}
mrbrentkelly commented 1 year ago

@david-arteaga this is great, thanks so much for sharing!

vfedosieievdish commented 9 months ago

We've also encountered the same issue and implemented our own HeadlessJsTaskWorker (instantiated from a CoroutineWorker instead of a Service).

import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.os.PowerManager
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.facebook.infer.annotation.Assertions
import com.facebook.react.ReactApplication
import com.facebook.react.ReactInstanceEventListener
import com.facebook.react.ReactNativeHost
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.jstasks.HeadlessJsTaskConfig
import com.facebook.react.jstasks.HeadlessJsTaskContext
import com.facebook.react.jstasks.HeadlessJsTaskEventListener
import java.util.concurrent.CopyOnWriteArraySet
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

/**
 * Base class for running JS without a UI. Generally, you only need to override {@link
 * #getTaskConfig}, which is called for every {@link #doWork}. The result, if not {@code
 * null}, is used to run a JS task.
 *
 * <p>If you need more fine-grained control over how tasks are run, you can override {@link
 * #doWork} and call {@link #startTask} depending on your custom logic.
 *
 * <p>If you're starting a {@code HeadlessJsTaskWorker} from a {@code BroadcastReceiver} (e.g.
 * handling push notifications), make sure to call {@link #acquireWakeLockNow} before returning from
 * {@link BroadcastReceiver#onReceive}, to make sure the device doesn't go to sleep before the
 * service is started.
 */
abstract class HeadlessJsTaskWorker(private val notificationId: Int, appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams), HeadlessJsTaskEventListener {
    private val mActiveTasks = CopyOnWriteArraySet<Int>()
    private var workFinishedCont: Continuation<Result>? = null

    companion object {
        private var sWakeLock: PowerManager.WakeLock? = null

        /**
         * Acquire a wake lock to ensure the device doesn't go to sleep while processing background tasks.
         */
        @SuppressLint("WakelockTimeout")
        fun acquireWakeLockNow(context: Context) {
            if (sWakeLock?.isHeld != true) {
                val powerManager = Assertions.assertNotNull(context.getSystemService(Context.POWER_SERVICE) as PowerManager);
                sWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, HeadlessJsTaskWorker::class.java.canonicalName).apply {
                    setReferenceCounted(false)
                    acquire()
                }
            }
        }
    }

    final override suspend fun getForegroundInfo(): ForegroundInfo {
        return ForegroundInfo(notificationId, createNotification())
    }

    /*
     * Called from {@link #getForegroundInfo} to create a {@link Notification}.
     *
     * @return a {@link Notification} to be displayed when your background JS task
     * is being executed.
     */
    protected abstract fun createNotification(): Notification

    override suspend fun doWork(): Result = suspendCoroutine { cont ->
        workFinishedCont = cont

        when (val cfg = getTaskConfig(inputData.keyValueMap)) {
            null -> finishWork()

            else -> UiThreadUtil.runOnUiThread {
                startTask(cfg)
            }
        }
    }

    /**
     * Called from {@link #doWork} to create a {@link HeadlessJsTaskConfig} for this writableMap.
     *
     * @param writableMap the {@link WritableMap} received in {@link #doWork}.
     * @return a {@link HeadlessJsTaskConfig} to be used with {@link #startTask}, or {@code null} to
     *     ignore this command.
     */
    protected open fun getTaskConfig(extras: Map<String, Any>): HeadlessJsTaskConfig? {
        return null
    }

    /**
     * Start a task. This method handles starting a new React instance if required.
     *
     * <p>Has to be called on the UI thread.
     *
     * @param taskConfig describes what task to start and the parameters to pass to it
     */
    protected open fun startTask(taskConfig: HeadlessJsTaskConfig) {
        UiThreadUtil.assertOnUiThread()
        acquireWakeLockNow(applicationContext);
        val reactInstanceManager = getReactNativeHost().reactInstanceManager;
        val reactContext = reactInstanceManager.currentReactContext;

        when (reactContext) {
            null -> {
                reactInstanceManager.addReactInstanceEventListener(
                    object : ReactInstanceEventListener {
                        override fun onReactContextInitialized(context: ReactContext?) {
                            invokeStartTask(context, taskConfig)
                            reactInstanceManager.removeReactInstanceEventListener(this)
                        }
                    });
                reactInstanceManager.createReactContextInBackground()
            }

            else -> {
                invokeStartTask(reactContext, taskConfig)
            }
        }
    }

    private fun invokeStartTask(reactContext: ReactContext?, taskConfig: HeadlessJsTaskConfig) {
        val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactContext)
        headlessJsTaskContext.addTaskEventListener(this)

        UiThreadUtil.runOnUiThread {
            val taskId = headlessJsTaskContext.startTask(taskConfig)
            mActiveTasks.add(taskId)
        }
    }

    private fun finishWork() {
        if (getReactNativeHost().hasInstance()) {
            val reactInstanceManager = getReactNativeHost().reactInstanceManager;
            reactInstanceManager.currentReactContext?.let {
                val headlessJsTaskContext = HeadlessJsTaskContext.getInstance(it);
                headlessJsTaskContext.removeTaskEventListener(this);
            }
        }
        sWakeLock?.release();

        workFinishedCont?.resume(Result.success())
    }

    override fun onHeadlessJsTaskStart(taskId: Int) {}

    override fun onHeadlessJsTaskFinish(taskId: Int) {
        mActiveTasks.remove(taskId)
        if (mActiveTasks.size == 0) {
            finishWork()
        }
    }

    /**
     * Get the {@link ReactNativeHost} used by this app. By default, assumes {@link #getApplication()}
     * is an instance of {@link ReactApplication} and calls {@link
     * ReactApplication#getReactNativeHost()}. Override this method if your application class does not
     * implement {@code ReactApplication} or you simply have a different mechanism for storing a
     * {@code ReactNativeHost}, e.g. as a static field somewhere.
     */
    protected open fun getReactNativeHost(): ReactNativeHost {
        return (applicationContext as ReactApplication).reactNativeHost
    }
}

And from now - scheduling our HeadlessJS tasks using the WorkManager, not a startService() or startForegroundService():

class MyAPI(appContext: Context, workerParams: WorkerParameters) : HeadlessJsTaskWorker(NOTIFICATION_ID, appContext, workerParams) {

    override fun createNotification(): Notification {
        val builder = NotificationCompat.Builder(applicationContext, "My App")
            .setContentTitle("My App")
            .setContentText("My App")
            .setSmallIcon(R.mipmap.ic_launcher)

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            createNotificationChannel("My App", "My App").also {
                builder.setChannelId(it.id)
            }
        }
        return builder.build()
    }

    @TargetApi(Build.VERSION_CODES.O)
    private fun createNotificationChannel(
        channelId: String,
        name: String
    ): NotificationChannel {
        return NotificationChannel(
            channelId, name, NotificationManager.IMPORTANCE_LOW
        ).also { channel ->
            (applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(channel)
        }
    }

    override fun getTaskConfig(extras: Map<String, Any>): HeadlessJsTaskConfig? {
        try {
            return HeadlessJsTaskConfig(
                "MyAPITask",
                Arguments.makeNativeMap(extras),
                50000,
                true)
        } catch (e: Throwable) { }
        return null
    }

    companion object {
        private const val TAG = "MyAPI"
        private const val NOTIFICATION_ID = 9999
        private const val EXTRAS_API_ID = "apiId"
        private const val EXTRAS_AUTH20_TOKEN = "auth20_token"

        private enum class API private constructor(val value: Int) {
            AUTH_20(0)
        }

        fun auth20(context: Context, auth20_token: String) {
            invokeAPITask(
                context,
                API.AUTH_20,
                EXTRAS_AUTH20_TOKEN to auth20_token
            )
        }

        private fun invokeAPITask(context: Context, api: API, vararg params: Pair<String, Any?>) {
                val apiData = arrayOf(EXTRAS_API_ID to api.value)
                WorkManager.getInstance(context)
                    .beginUniqueWork(
                        TAG,
                        ExistingWorkPolicy.APPEND_OR_REPLACE,
                        getExpeditedWork<MyAPI>(TAG, *apiData, *params)
                    )
                    .enqueue()
        }

        private inline fun <reified T: ListenableWorker> getExpeditedWork(tag: String, vararg params: Pair<String, Any?>): OneTimeWorkRequest {
            val bundle = workDataOf(*params)
            return OneTimeWorkRequestBuilder<T>()
                .setInputData(bundle)
                .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
                .setBackoffCriteria(BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS)
                .addTag(tag)
                .build()
        }
    }
}
AliceYuan commented 4 months ago

Hi I work at Google, and I'm a SME on Android on background work.

Our recommendation if you need to do background work (where the application is no longer in the foreground) with a react native application is to continue create a custom HeadlessJsTaskService to ensure that you follow best practice guidance. Please refer to https://developer.android.com/develop/background-work/background-tasks to decide whether you should be using Coroutines, WorkManager, or Foreground Service.

I'm happy to discuss with the owners of react native library if they would like to improve the react native library to better support background work on Android. At this moment the HeadlessJsTaskService will not run in the background after certain Android OS versions unless the developers write custom Android code as depicted above utilizing either WorkManager or Foreground Service.

Please note: you do not need to acquire a wakelock except in very specific circumstances, we do not recommend adding the logic to acquire a wakelock as depicted in by @vfedosieievdish. Also, please consider using WorkManager to schedule tasks that do not need user interaction/ notification.

cortinico commented 4 months ago

I'm happy to discuss with the owners of react native library if they would like to improve the react native library to better support background work on Android. At this moment the HeadlessJsTaskService will not run in the background after certain Android OS versions unless the developers write custom Android code as depicted above utilizing either WorkManager or Foreground Service.

Hey @AliceYuan, Nicola from the React Native team at Meta. Happy to chat about it. Agree that we would love to expose other implementation of HeadlessJsTaskService that are backed by WM, Coroutines and so on. On the other hand we don't want to do add those external dependencies to the framework as every app consumer will also depend on them, so they're probably better suited for 3rd party library implementations

gituser8796 commented 1 month ago

@cortinico we had the same experience when enabling the New Architecture (setting newArchEnabled=true), turning it off fixed the issue.

On the matter of HeadlessTask on react-native 0.75.x - starting it from WorkManager no longer works (it did on earlier versions) and we are faced with this error:

E  Work [ id=c54094ac-da2e-48ca-9509-01f71c94667d, tags={ com.testapp75.MyWorker } ] failed because it threw an exception/error
    java.util.concurrent.ExecutionException: android.app.BackgroundServiceStartNotAllowedException: Not allowed to start service Intent { cmp=com.testapp75/.MyHeadlessJsService (has extras) }: app is in background uid UidRecord{cfc0c33 u0a565 TRNB bg:+396ms idle change:procadj procs:0 seq(6889459,6889455)} caps=-------
        at androidx.work.impl.utils.futures.AbstractFuture.getDoneValue(AbstractFuture.java:515)
        at androidx.work.impl.utils.futures.AbstractFuture.get(AbstractFuture.java:474)
        at androidx.work.impl.WorkerWrapper$2.run(WorkerWrapper.java:316)
        at androidx.work.impl.utils.SerialExecutorImpl$Task.run(SerialExecutorImpl.java:96)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644)
        at java.lang.Thread.run(Thread.java:1012)
    Caused by: android.app.BackgroundServiceStartNotAllowedException: Not allowed to start service Intent { cmp=com.testapp75/.MyHeadlessJsService (has extras) }: app is in background uid UidRecord{cfc0c33 u0a565 TRNB bg:+396ms idle change:procadj procs:0 seq(6889459,6889455)} caps=-------
        at android.app.ContextImpl.startServiceCommon(ContextImpl.java:1946)
        at android.app.ContextImpl.startService(ContextImpl.java:1901)
        at android.content.ContextWrapper.startService(ContextWrapper.java:827)
        at com.testapp75.MyWorker.doWork(MyWorker.kt:43)
        at androidx.work.Worker$1.run(Worker.java:82)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145) 
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:644) 
        at java.lang.Thread.run(Thread.java:1012)
@RequiresApi(Build.VERSION_CODES.O)
 override fun doWork(): Result {
     val reactNativeHost = (applicationContext as ReactApplication).reactNativeHost
     val reactInstanceManager: ReactInstanceManager = reactNativeHost.reactInstanceManager
     val reactContext = reactInstanceManager.currentReactContext
     val service = Intent(applicationContext, MyHeadlessJsService::class.java)
     applicationContext.startService(service)
     HeadlessJsTaskService.acquireWakeLockNow(applicationContext);
     return Result.success()
 }

We'd appreciate any feedback on this one, thanks!

cortinico commented 1 month ago

We'd appreciate any feedback on this one, thanks!

We're probably going to deprecate & remove HeadlessJSTasks in a future version of RN so you should not be using it.

You should instead use JobScheduler, WorkManager and other Android APIs to schedule background work.