package expo.modules.backgroundtask import android.content.Context import android.os.Build import android.util.Log import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.util.concurrent.ListenableFuture import expo.modules.interfaces.taskManager.TaskServiceProviderHelper import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.time.Duration import java.util.concurrent.TimeUnit object BackgroundTaskScheduler { // Default interval const val DEFAULT_INTERVAL_MINUTES = 60L * 24L // Once every day // Unique identifier (generated by us) to identify the worker private const val WORKER_IDENTIFIER = "EXPO_BACKGROUND_WORKER" // Log tag private val TAG = BackgroundTaskScheduler::class.java.simpleName // Number of active task consumers private var numberOfRegisteredTasksOfThisType = 0 // Interval private var intervalMinutes: Long = DEFAULT_INTERVAL_MINUTES // Tracks whether in foreground var inForeground: Boolean = false /** * Call when a task is registered */ fun registerTask(context: Context, intervalMinutes: Long) { numberOfRegisteredTasksOfThisType += 1 this.intervalMinutes = intervalMinutes if (numberOfRegisteredTasksOfThisType == 1) { runBlocking { scheduleWorker(context, context.packageName) } } } /** * Call when a task is unregistered */ fun unregisterTask(context: Context) { numberOfRegisteredTasksOfThisType -= 1 if (numberOfRegisteredTasksOfThisType == 0) { runBlocking { stopWorker(context) } } } /** * Schedules the worker task to run. The worker should run periodically. */ private suspend fun scheduleWorker(context: Context, appScopeKey: String, cancelExisting: Boolean = true, overriddenIntervalMinutes: Long = intervalMinutes): Boolean { if (numberOfRegisteredTasksOfThisType == 0) { Log.d(TAG, "Will not enqueue worker. No registered tasks to run.") return false } // Stop the current worker (if any) if (cancelExisting) { stopWorker(context) } Log.d(TAG, "Enqueuing worker with identifier $WORKER_IDENTIFIER and '$overriddenIntervalMinutes' minutes delay.") // Build the work request val data = Data.Builder() .putString("appScopeKey", appScopeKey) .build() val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() // Get Work manager val workManager = WorkManager.getInstance(context) // We have two different paths here, since on Android 14-15 we need to use a periodic request // builder since the OneTimeWorkRequest doesn't support setting initial delay (which is how we // control executing a task periodically - we spawn a One-time work request when backgrounding, // and when this is done we spawn a new one. This makes it a lot easier to debug since we can // use adb to spawn jobs whenever we want) // The following is the path we have to follow on Android.O and later: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Create the work request val builder = OneTimeWorkRequestBuilder() .setInputData(data) .setConstraints(constraints) // Add minimum interval here as well so that the work doesn't start immediately builder.setInitialDelay(Duration.ofMinutes(overriddenIntervalMinutes)) // Create work request val workRequest = builder.build() // Enqueue the work return try { workManager.enqueueUniqueWork( WORKER_IDENTIFIER, // This is where we decide if we should cancel or replace the task - cancelling is done // when spawning the first task, while appending is when we spawn from a running task // to set up the next periodic run of the task if (cancelExisting) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.APPEND, workRequest ).await() true } catch (e: Exception) { Log.e(TAG, "Worker failed to start with error " + e.message) false } } else { val builder = PeriodicWorkRequestBuilder( repeatIntervalTimeUnit = TimeUnit.MINUTES, repeatInterval = intervalMinutes ) // Create work request val workRequest = builder.build() // Enqueue the work return try { workManager.enqueueUniquePeriodicWork( WORKER_IDENTIFIER, ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, workRequest ).await() true } catch (e: Exception) { Log.e(TAG, "Worker failed to start with error " + e.message) false } } } /** * Cancels the worker task */ private suspend fun stopWorker(context: Context): Boolean { if (!isWorkerRunning(context)) { return false } Log.d(TAG, "Cancelling worker with identifier $WORKER_IDENTIFIER") // Stop our main worker val workManager = WorkManager.getInstance(context) return try { workManager.cancelUniqueWork(WORKER_IDENTIFIER).await() } catch (e: Exception) { Log.d(TAG, "Stopping worker failed with error " + e.message) false } } /** * Returns true if the worker task is pending */ private suspend fun isWorkerRunning(context: Context): Boolean { val workInfo = getWorkerInfo(context) return workInfo?.state == WorkInfo.State.RUNNING || workInfo?.state == WorkInfo.State.ENQUEUED } /** * Runs tasks with the given appScopeKey */ suspend fun runTasks(context: Context, appScopeKey: String) { // Get task service val taskService = TaskServiceProviderHelper.getTaskServiceImpl(context) ?: throw MissingTaskServiceException() Log.d(TAG, "runTasks: $appScopeKey") // Get all task consumers val consumers = taskService.getTaskConsumers(appScopeKey) Log.d(TAG, "runTasks: number of consumers ${consumers.size}") if (consumers.isEmpty()) { return } // Make sure we're in the background before running a task if (inForeground) { // Schedule task in an hour (or at least minimumInterval) - the app is foregrounded and // we don't want to run anything to avoid performance issues. Log.d(TAG, "runTasks: App is in the foreground") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { scheduleWorker(context, appScopeKey, false, 60L.coerceAtMost(intervalMinutes)) Log.d(TAG, "runTasks: Scheduled new worker in $intervalMinutes minutes") } return } val tasks = consumers.filterIsInstance() .map { bgTaskConsumer -> Log.d(TAG, "runTasks: executing tasks for consumer of type ${bgTaskConsumer.taskType()}") val taskCompletion = CompletableDeferred() try { bgTaskConsumer.executeTask { Log.d(TAG, "Task successfully finished") taskCompletion.complete(Unit) } } catch (e: Exception) { Log.e(TAG, "Task failed: ${e.message}") } taskCompletion } // Await all tasks to complete tasks.awaitAll() // Schedule next task if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { scheduleWorker(context, appScopeKey, false) } } /** * Returns the worker info object from the WorkManager if the worker has been * registered, otherwise returns null */ private suspend fun getWorkerInfo(context: Context): WorkInfo? { // Get work manager val workManager = WorkManager.getInstance(context) return try { val workInfos = workManager.getWorkInfosForUniqueWork(WORKER_IDENTIFIER).await() return workInfos.firstOrNull() } catch (e: Exception) { Log.d(TAG, "Calling getWorkInfosForUniqueWork failed with error " + e.message) null } } /** * Helper function for calling functions returning an Operation */ private suspend fun Operation.await(): Boolean = withContext(Dispatchers.IO) { result.get() true } /** * Helper function for calling functions returning a ListenableFuture */ private suspend fun ListenableFuture.await(): T = withContext(Dispatchers.IO) { try { get() } catch (e: CancellationException) { cancel(true) throw e } catch (e: Exception) { throw e } } }