Created
December 17, 2025 16:51
-
-
Save tyrauber/9d798fd281ff6509f2fef80857d246cb to your computer and use it in GitHub Desktop.
expo-task-manager patch: Fix Android job scheduling ANR and duplicate data #41688
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/android/build.gradle b/android/build.gradle | |
| index bdecf20e6f9f5e7449abae9b8e0c4f75e6c140bb..2e24862b7bb85a07a69e787c8a33cecc0dbb8f6d 100644 | |
| --- a/android/build.gradle | |
| +++ b/android/build.gradle | |
| @@ -15,6 +15,8 @@ android { | |
| } | |
| dependencies { | |
| - implementation project(':unimodules-app-loader') | |
| + // PATCH: Changed from 'implementation project(':unimodules-app-loader')' to use expo-modules-core | |
| + // The apploader classes (AppLoaderProvider, HeadlessAppLoader) are now part of expo-modules-core | |
| + compileOnly project(':expo-modules-core') | |
| api "androidx.core:core:1.0.0" | |
| } | |
| diff --git a/android/src/main/java/expo/modules/taskManager/TaskJobService.java b/android/src/main/java/expo/modules/taskManager/TaskJobService.java | |
| index abc6d9017e021c9c202a0957d128c7467e6f5728..597e88c9301e9529a911bc64952a625b8a6488db 100644 | |
| --- a/android/src/main/java/expo/modules/taskManager/TaskJobService.java | |
| +++ b/android/src/main/java/expo/modules/taskManager/TaskJobService.java | |
| @@ -3,21 +3,55 @@ package expo.modules.taskManager; | |
| import android.app.job.JobParameters; | |
| import android.app.job.JobService; | |
| import android.content.Context; | |
| +import android.os.PersistableBundle; | |
| +import android.util.Log; | |
| public class TaskJobService extends JobService { | |
| + private static final String TAG = "TaskJobService"; | |
| + | |
| + // PATCH: Helper function to log to native logcat with consistent prefix | |
| + private static void patchLog(String message) { | |
| + Log.i(TAG, "[EXPO-TASK-MANAGER] " + message); | |
| + } | |
| + | |
| @Override | |
| public boolean onStartJob(JobParameters params) { | |
| + PersistableBundle extras = params.getExtras(); | |
| + String taskName = extras.getString("taskName", "unknown"); | |
| + int dataSize = extras.getInt("dataSize", 0); | |
| + int jobId = params.getJobId(); | |
| + | |
| + patchLog("onStartJob CALLED - jobId=" + jobId + ", task=" + taskName + ", dataSize=" + dataSize); | |
| + long startTime = System.currentTimeMillis(); | |
| + | |
| Context context = getApplicationContext(); | |
| TaskService taskService = new TaskService(context); | |
| - return taskService.handleJob(this, params); | |
| + boolean isAsync = taskService.handleJob(this, params); | |
| + | |
| + long duration = System.currentTimeMillis() - startTime; | |
| + patchLog("onStartJob COMPLETE - jobId=" + jobId + ", isAsync=" + isAsync + ", duration=" + duration + "ms"); | |
| + | |
| + return isAsync; | |
| } | |
| @Override | |
| public boolean onStopJob(JobParameters params) { | |
| + PersistableBundle extras = params.getExtras(); | |
| + String taskName = extras.getString("taskName", "unknown"); | |
| + int dataSize = extras.getInt("dataSize", 0); | |
| + int jobId = params.getJobId(); | |
| + | |
| + patchLog("onStopJob CALLED - jobId=" + jobId + ", task=" + taskName + ", dataSize=" + dataSize | |
| + + " (job being stopped by system!)"); | |
| + | |
| Context context = getApplicationContext(); | |
| TaskService taskService = new TaskService(context); | |
| - return taskService.cancelJob(this, params); | |
| + boolean shouldReschedule = taskService.cancelJob(this, params); | |
| + | |
| + patchLog("onStopJob COMPLETE - jobId=" + jobId + ", shouldReschedule=" + shouldReschedule); | |
| + | |
| + return shouldReschedule; | |
| } | |
| } | |
| diff --git a/android/src/main/java/expo/modules/taskManager/TaskManagerUtils.java b/android/src/main/java/expo/modules/taskManager/TaskManagerUtils.java | |
| index f31dd3d4e1a96827920d6c1e1df8d8e8099bc2bf..ae99a25ae5ffe6d0aa31e754ce48e5495d8548b2 100644 | |
| --- a/android/src/main/java/expo/modules/taskManager/TaskManagerUtils.java | |
| +++ b/android/src/main/java/expo/modules/taskManager/TaskManagerUtils.java | |
| @@ -15,15 +15,11 @@ import android.os.PersistableBundle; | |
| import android.util.Log; | |
| import java.util.ArrayList; | |
| -import java.util.Collections; | |
| -import java.util.Comparator; | |
| import java.util.List; | |
| import java.util.Map; | |
| -import java.util.Set; | |
| import androidx.annotation.NonNull; | |
| import androidx.annotation.Nullable; | |
| -import androidx.collection.ArraySet; | |
| import expo.modules.interfaces.taskManager.TaskExecutionCallback; | |
| import expo.modules.interfaces.taskManager.TaskInterface; | |
| @@ -31,18 +27,30 @@ import expo.modules.interfaces.taskManager.TaskManagerUtilsInterface; | |
| public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| - // Key that every job created by the task manager must contain in its extras bundle. | |
| + // Key that every job created by the task manager must contain in its extras | |
| + // bundle. | |
| private static final String EXTRAS_REQUIRED_KEY = "expo.modules.taskManager"; | |
| private static final String TAG = "TaskManagerUtils"; | |
| + // PATCH: Helper function to log to native logcat with consistent prefix | |
| + private static void patchLog(String message) { | |
| + Log.i(TAG, "[EXPO-TASK-MANAGER] " + message); | |
| + } | |
| + | |
| // Request code number used for pending intents created by this module. | |
| private static final int PENDING_INTENT_REQUEST_CODE = 5055; | |
| private static final int DEFAULT_OVERRIDE_DEADLINE = 60 * 1000; // 1 minute | |
| - private static final Set<TaskInterface> sTasksReschedulingJob = new ArraySet<>(); | |
| + // Leave buffer before Android's job limit to avoid edge cases. | |
| + private static final int JOB_LIMIT_BUFFER = 10; | |
| + | |
| + private static int getJobLimit() { | |
| + // Android 12 (API 31+) increased limit from 100 to 150 | |
| + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? 150 : 100; | |
| + } | |
| - //region TaskManagerUtilsInterface | |
| + // region TaskManagerUtilsInterface | |
| @Override | |
| public PendingIntent createTaskIntent(Context context, TaskInterface task) { | |
| @@ -61,8 +69,11 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| @Override | |
| public void scheduleJob(Context context, @NonNull TaskInterface task, List<PersistableBundle> data) { | |
| if (task == null) { | |
| + patchLog("ERROR: scheduleJob called with null task!"); | |
| Log.e(TAG, "Trying to schedule job for null task!"); | |
| } else { | |
| + int dataSize = data != null ? data.size() : 0; | |
| + patchLog("scheduleJob called - task: " + task.getName() + ", dataSize: " + dataSize); | |
| updateOrScheduleJob(context, task, data); | |
| } | |
| } | |
| @@ -78,11 +89,14 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| @Override | |
| public void cancelScheduledJob(Context context, int jobId) { | |
| + patchLog("cancelScheduledJob - jobId: " + jobId); | |
| JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); | |
| if (jobScheduler != null) { | |
| jobScheduler.cancel(jobId); | |
| + patchLog("cancelScheduledJob - job cancelled successfully"); | |
| } else { | |
| + patchLog("ERROR: cancelScheduledJob - Job scheduler not found!"); | |
| Log.e(this.getClass().getName(), "Job scheduler not found!"); | |
| } | |
| } | |
| @@ -92,6 +106,9 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| PersistableBundle extras = params.getExtras(); | |
| List<PersistableBundle> data = new ArrayList<>(); | |
| int dataSize = extras.getInt("dataSize", 0); | |
| + String taskName = extras.getString("taskName", "unknown"); | |
| + | |
| + patchLog("extractDataFromJobParams - task: " + taskName + ", dataSize: " + dataSize); | |
| for (int i = 0; i < dataSize; i++) { | |
| data.add(extras.getPersistableBundle(String.valueOf(i))); | |
| @@ -99,72 +116,77 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| return data; | |
| } | |
| - //endregion TaskManagerUtilsInterface | |
| - //region static helpers | |
| - | |
| - static boolean notifyTaskJobCancelled(TaskInterface task) { | |
| - boolean isRescheduled = sTasksReschedulingJob.contains(task); | |
| - | |
| - if (isRescheduled) { | |
| - sTasksReschedulingJob.remove(task); | |
| - } | |
| - return isRescheduled; | |
| - } | |
| - | |
| - //endregion static helpers | |
| - //region private helpers | |
| + // endregion TaskManagerUtilsInterface | |
| + // region private helpers | |
| private void updateOrScheduleJob(Context context, TaskInterface task, List<PersistableBundle> data) { | |
| + String taskName = task.getName(); | |
| + int dataSize = data != null ? data.size() : 0; | |
| + patchLog("updateOrScheduleJob START - task: " + taskName + ", dataSize: " + dataSize); | |
| + | |
| JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); | |
| if (jobScheduler == null) { | |
| + patchLog("ERROR: updateOrScheduleJob - Job scheduler not found!"); | |
| Log.e(this.getClass().getName(), "Job scheduler not found!"); | |
| return; | |
| } | |
| List<JobInfo> pendingJobs = jobScheduler.getAllPendingJobs(); | |
| + if (pendingJobs == null) { | |
| + pendingJobs = new ArrayList<>(); | |
| + } | |
| - Collections.sort(pendingJobs, new Comparator<JobInfo>() { | |
| - @Override | |
| - public int compare(JobInfo a, JobInfo b) { | |
| - return Integer.compare(a.getId(), b.getId()); | |
| - } | |
| - }); | |
| + patchLog("updateOrScheduleJob - found " + pendingJobs.size() + " pending jobs in system"); | |
| - // We will be looking for the lowest number that is not being used yet. | |
| + // Find newest job for this task (for merging if needed) and next available ID | |
| int newJobId = 0; | |
| + JobInfo newestJob = null; | |
| for (JobInfo jobInfo : pendingJobs) { | |
| int jobId = jobInfo.getId(); | |
| - | |
| + if (jobId >= newJobId) { | |
| + newJobId = jobId + 1; | |
| + } | |
| if (isJobInfoRelatedToTask(jobInfo, task)) { | |
| - JobInfo mergedJobInfo = createJobInfoByAddingData(jobInfo, data); | |
| - | |
| - // Add the task to the list of rescheduled tasks. | |
| - sTasksReschedulingJob.add(task); | |
| - | |
| - try { | |
| - // Cancel jobs with the same ID to let them be rescheduled. | |
| - jobScheduler.cancel(jobId); | |
| - | |
| - // Reschedule job for given task. | |
| - jobScheduler.schedule(mergedJobInfo); | |
| - } catch (IllegalStateException e) { | |
| - Log.e(this.getClass().getName(), "Unable to reschedule a job: " + e.getMessage()); | |
| + if (newestJob == null || jobId > newestJob.getId()) { | |
| + newestJob = jobInfo; | |
| } | |
| - return; | |
| } | |
| - if (newJobId == jobId) { | |
| - newJobId++; | |
| + } | |
| + | |
| + // At Android's job limit? Merge into newest job for this task. | |
| + if (pendingJobs.size() >= getJobLimit() - JOB_LIMIT_BUFFER && newestJob != null) { | |
| + patchLog("updateOrScheduleJob - Approaching job limit (" + pendingJobs.size() + "). Merging data for task '" + taskName + "'."); | |
| + Log.i(TAG, "Approaching job limit (" + pendingJobs.size() + "). Merging data for task '" + taskName + "'."); | |
| + try { | |
| + JobInfo mergedJobInfo = createJobInfoByAddingData(newestJob, data); | |
| + jobScheduler.cancel(newestJob.getId()); | |
| + jobScheduler.schedule(mergedJobInfo); | |
| + patchLog("updateOrScheduleJob - MERGED into job " + newestJob.getId()); | |
| + } catch (IllegalStateException e) { | |
| + patchLog("ERROR: updateOrScheduleJob - Unable to merge job: " + e.getMessage()); | |
| + Log.e(this.getClass().getName(), "Unable to merge job: " + e.getMessage()); | |
| } | |
| + return; | |
| } | |
| + // Under limit: schedule as new job. Don't touch existing jobs. | |
| try { | |
| - // Given task doesn't have any pending jobs yet, create a new JobInfo and schedule it then. | |
| + patchLog("updateOrScheduleJob - SCHEDULING NEW JOB with id=" + newJobId + ", dataSize=" + dataSize); | |
| + long startTime = System.currentTimeMillis(); | |
| JobInfo jobInfo = createJobInfo(context, task, newJobId, data); | |
| - jobScheduler.schedule(jobInfo); | |
| + int result = jobScheduler.schedule(jobInfo); | |
| + long duration = System.currentTimeMillis() - startTime; | |
| + if (result == JobScheduler.RESULT_SUCCESS) { | |
| + patchLog("updateOrScheduleJob - JOB SCHEDULED SUCCESSFULLY in " + duration + "ms (id=" + newJobId + ")"); | |
| + } else { | |
| + patchLog("ERROR: updateOrScheduleJob - JOB SCHEDULE FAILED in " + duration + "ms (id=" + newJobId + ", result=" | |
| + + result + ")"); | |
| + } | |
| } catch (IllegalStateException e) { | |
| - Log.e(this.getClass().getName(), "Unable to schedule a new job: " + e.getMessage()); | |
| + patchLog("ERROR: updateOrScheduleJob - IllegalStateException: " + e.getMessage()); | |
| + Log.e(this.getClass().getName(), "Unable to schedule job: " + e.getMessage()); | |
| } | |
| } | |
| @@ -191,13 +213,14 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| // query param is called appId for legacy reasons | |
| Uri dataUri = new Uri.Builder() | |
| - .appendQueryParameter("appId", appScopeKey) | |
| - .appendQueryParameter("taskName", taskName) | |
| - .build(); | |
| + .appendQueryParameter("appId", appScopeKey) | |
| + .appendQueryParameter("taskName", taskName) | |
| + .build(); | |
| intent.setData(dataUri); | |
| - // We're defaulting to the behaviour prior API 31 (mutable) even though Android recommends immutability | |
| + // We're defaulting to the behaviour prior API 31 (mutable) even though Android | |
| + // recommends immutability | |
| int mutableFlag = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0; | |
| return PendingIntent.getBroadcast(context, PENDING_INTENT_REQUEST_CODE, intent, flags | mutableFlag); | |
| } | |
| @@ -210,11 +233,24 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| } | |
| private JobInfo createJobInfo(int jobId, ComponentName jobService, PersistableBundle extras) { | |
| - return new JobInfo.Builder(jobId, jobService) | |
| - .setExtras(extras) | |
| - .setMinimumLatency(0) | |
| - .setOverrideDeadline(DEFAULT_OVERRIDE_DEADLINE) | |
| - .build(); | |
| + JobInfo.Builder jobBuilder = new JobInfo.Builder(jobId, jobService) | |
| + .setExtras(extras) | |
| + .setPersisted(true) | |
| + .setRequiresDeviceIdle(false); | |
| + | |
| + if (Build.VERSION.SDK_INT < 28) { | |
| + // For Android versions below 28 (Android 9 and below) | |
| + jobBuilder.setMinimumLatency(0) | |
| + .setOverrideDeadline(DEFAULT_OVERRIDE_DEADLINE); | |
| + } else if (Build.VERSION.SDK_INT < 31) { | |
| + // For Android 9 (API 28) to Android 11 (API 30) | |
| + jobBuilder.setImportantWhileForeground(true); | |
| + } else { | |
| + // For Android 12 (API 31) and above | |
| + jobBuilder.setExpedited(true); | |
| + } | |
| + | |
| + return jobBuilder.build(); | |
| } | |
| private JobInfo createJobInfo(Context context, TaskInterface task, int jobId, List<PersistableBundle> data) { | |
| @@ -255,8 +291,8 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| return false; | |
| } | |
| - //endregion private helpers | |
| - //region converting map to bundle | |
| + // endregion private helpers | |
| + // region converting map to bundle | |
| @SuppressWarnings("unchecked") | |
| static Bundle mapToBundle(Map<String, Object> map) { | |
| @@ -332,5 +368,5 @@ public class TaskManagerUtils implements TaskManagerUtilsInterface { | |
| return arrayList; | |
| } | |
| - //endregion converting map to bundle | |
| + // endregion converting map to bundle | |
| } | |
| diff --git a/android/src/main/java/expo/modules/taskManager/TaskService.java b/android/src/main/java/expo/modules/taskManager/TaskService.java | |
| index 2b6c2fa60a8c52dda8a41641d416563db6e15725..1db9323d3882c0ba4897ee9ca0107a65e445eac3 100644 | |
| --- a/android/src/main/java/expo/modules/taskManager/TaskService.java | |
| +++ b/android/src/main/java/expo/modules/taskManager/TaskService.java | |
| @@ -51,10 +51,16 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| private static final String SHARED_PREFERENCES_NAME = "TaskManagerModule"; | |
| private static final int MAX_TASK_EXECUTION_TIME_MS = 15000; // 15 seconds | |
| + // PATCH: Helper function to log to native logcat with consistent prefix | |
| + private static void patchLog(String message) { | |
| + Log.i(TAG, "[EXPO-TASK-MANAGER] " + message); | |
| + } | |
| + | |
| private WeakReference<Context> mContextRef; | |
| private TaskManagerUtilsInterface mTaskManagerUtils; | |
| - // Map with task managers of running (foregrounded) apps. { "<appScopeKey>": WeakReference(TaskManagerInterface) } | |
| + // Map with task managers of running (foregrounded) apps. { "<appScopeKey>": | |
| + // WeakReference(TaskManagerInterface) } | |
| private static final Map<String, WeakReference<TaskManagerInterface>> sTaskManagers = new HashMap<>(); | |
| // Same as above but for headless (backgrounded) apps. | |
| @@ -65,7 +71,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| private TasksAndEventsRepository mTasksAndEventsRepository; | |
| - // Map of callbacks for task execution events. Schema: { "<eventId>": TaskExecutionCallback } | |
| + // Map of callbacks for task execution events. Schema: { "<eventId>": | |
| + // TaskExecutionCallback } | |
| private static final Map<String, TaskExecutionCallback> sTaskCallbacks = new HashMap<>(); | |
| public TaskService(Context context) { | |
| @@ -83,7 +90,7 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| return "TaskService"; | |
| } | |
| - //region TaskServiceInterface | |
| + // region TaskServiceInterface | |
| @Override | |
| public boolean hasRegisteredTask(String taskName, String appScopeKey) { | |
| @@ -92,7 +99,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| @Override | |
| - public void registerTask(String taskName, String appScopeKey, String appUrl, Class consumerClass, Map<String, Object> options) throws TaskRegisteringFailedException { | |
| + public void registerTask(String taskName, String appScopeKey, String appUrl, Class consumerClass, | |
| + Map<String, Object> options) throws TaskRegisteringFailedException { | |
| TaskInterface task = getTask(taskName, appScopeKey); | |
| Class unversionedConsumerClass = unversionedClassForClass(consumerClass); | |
| @@ -107,7 +115,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| @Override | |
| - public void unregisterTask(String taskName, String appScopeKey, Class consumerClass) throws TaskNotFoundException, InvalidConsumerClassException { | |
| + public void unregisterTask(String taskName, String appScopeKey, Class consumerClass) | |
| + throws TaskNotFoundException, InvalidConsumerClassException { | |
| TaskInterface task = getTask(taskName, appScopeKey); | |
| Class unversionedConsumerClass = unversionedClassForClass(consumerClass); | |
| @@ -206,7 +215,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| if (appEvents.size() == 0) { | |
| sEvents.remove(appScopeKey); | |
| - // Invalidate app record but after 2 seconds delay so we can still take batched events. | |
| + // Invalidate app record but after 2 seconds delay so we can still take batched | |
| + // events. | |
| Handler handler = new Handler(); | |
| handler.postDelayed(new Runnable() { | |
| @Override | |
| @@ -257,7 +267,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| mTasksAndEventsRepository.removeEvents(appScopeKey); | |
| if (!isHeadless) { | |
| - // Maybe update app url in user defaults. It might change only in non-headless mode. | |
| + // Maybe update app url in user defaults. It might change only in non-headless | |
| + // mode. | |
| maybeUpdateAppUrlForAppScopeKey(appUrl, appScopeKey); | |
| } | |
| } | |
| @@ -316,71 +327,96 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| public boolean handleJob(final JobService jobService, final JobParameters params) { | |
| + long startTime = System.currentTimeMillis(); | |
| + int jobId = params.getJobId(); | |
| PersistableBundle extras = params.getExtras(); | |
| - // persistable bundle extras param name is appId for legacy reasons | |
| String appScopeKey = extras.getString("appId"); | |
| String taskName = extras.getString("taskName"); | |
| + // PATCH: Log job handling start with details | |
| + int keyCount = extras.keySet() != null ? extras.keySet().size() : 0; | |
| + patchLog("handleJob START - jobId=" + jobId + ", taskName=" + taskName + ", appScopeKey=" + appScopeKey | |
| + + ", extrasKeyCount=" + keyCount); | |
| + | |
| TaskConsumerInterface consumer = getTaskConsumer(taskName, appScopeKey); | |
| if (consumer == null) { | |
| + patchLog("handleJob FAILED - consumer not found for task '" + taskName + "', returning false"); | |
| Log.w(TAG, "Task or consumer not found."); | |
| return false; | |
| } | |
| Log.i(TAG, "Handling job with task name '" + taskName + "' for app with scoping identifier '" + appScopeKey + "'."); | |
| + patchLog("handleJob - consumer found: " + consumer.getClass().getSimpleName() + ", executing..."); | |
| - // executes task | |
| boolean isAsyncJob = consumer.didExecuteJob(jobService, params); | |
| + long executionTime = System.currentTimeMillis() - startTime; | |
| + patchLog("handleJob - didExecuteJob returned isAsyncJob=" + isAsyncJob + ", executionTime=" + executionTime + "ms"); | |
| if (isAsyncJob) { | |
| - // Make sure the task doesn't take more than 15 seconds | |
| + patchLog("handleJob - scheduling timeout callback in " + MAX_TASK_EXECUTION_TIME_MS + "ms"); | |
| finishJobAfterTimeout(jobService, params, MAX_TASK_EXECUTION_TIME_MS); | |
| } | |
| + patchLog("handleJob END - jobId=" + jobId + ", taskName=" + taskName + ", isAsyncJob=" + isAsyncJob + ", totalTime=" | |
| + + executionTime + "ms"); | |
| return isAsyncJob; | |
| } | |
| public boolean cancelJob(JobService jobService, JobParameters params) { | |
| + int jobId = params.getJobId(); | |
| PersistableBundle extras = params.getExtras(); | |
| - // persistable bundle extras param name is appId for legacy reasons | |
| String appScopeKey = extras.getString("appId"); | |
| String taskName = extras.getString("taskName"); | |
| + // PATCH: Log job cancellation | |
| + patchLog("cancelJob START - jobId=" + jobId + ", taskName=" + taskName + ", appScopeKey=" + appScopeKey); | |
| + | |
| TaskInterface task = getTask(taskName, appScopeKey); | |
| - // `notifyTaskJobCancelled` notifies TaskManagerUtils about a job for task being cancelled. | |
| - // It returns `true` if the job has been intentionally cancelled to be rescheduled, | |
| - // in that case we don't want to inform the consumer about cancellation. | |
| - if (task != null && !TaskManagerUtils.notifyTaskJobCancelled(task)) { | |
| + if (task != null) { | |
| TaskConsumerInterface consumer = task.getConsumer(); | |
| if (consumer == null) { | |
| + patchLog("cancelJob - task found but consumer is null, returning false"); | |
| return false; | |
| } | |
| Log.i(TAG, "Job for task '" + taskName + "' has been cancelled by the system."); | |
| + patchLog("cancelJob - job cancelled by system, calling didCancelJob on consumer: " | |
| + + consumer.getClass().getSimpleName()); | |
| - // cancels task | |
| - return consumer.didCancelJob(jobService, params); | |
| + boolean result = consumer.didCancelJob(jobService, params); | |
| + patchLog("cancelJob END - jobId=" + jobId + ", consumer.didCancelJob returned=" + result); | |
| + return result; | |
| } | |
| + // PATCH: Log when task not found | |
| + patchLog("cancelJob END - task not found, returning false (no reschedule)"); | |
| + | |
| // `false` = don't reschedule the job. | |
| return false; | |
| } | |
| public void executeTask(TaskInterface task, Bundle data, Error error, TaskExecutionCallback callback) { | |
| + String taskName = task.getName(); | |
| + String appScopeKey = task.getAppScopeKey(); | |
| + | |
| + // PATCH: Log task execution start | |
| + int dataSize = data != null ? data.keySet().size() : 0; | |
| + patchLog("executeTask START - taskName=" + taskName + ", appScopeKey=" + appScopeKey + ", dataKeyCount=" + dataSize | |
| + + ", hasError=" + (error != null) + ", hasCallback=" + (callback != null)); | |
| + | |
| TaskManagerInterface taskManager = getTaskManager(task.getAppScopeKey()); | |
| Bundle body = createExecutionEventBody(task, data, error); | |
| Bundle executionInfo = body.getBundle("executionInfo"); | |
| if (executionInfo == null) { | |
| - // it should never happen, just to suppress warnings :) | |
| + patchLog("executeTask FAILED - executionInfo is null, aborting"); | |
| return; | |
| } | |
| String eventId = executionInfo.getString("eventId"); | |
| - String appScopeKey = task.getAppScopeKey(); | |
| if (callback != null) { | |
| sTaskCallbacks.put(eventId, callback); | |
| @@ -397,12 +433,18 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| if (taskManager != null) { | |
| + patchLog("executeTask - taskManager available, executing immediately with eventId=" + eventId); | |
| taskManager.executeTaskWithBody(body); | |
| return; | |
| } | |
| + // PATCH: Log when task needs to be queued | |
| + patchLog( | |
| + "executeTask - taskManager not available, queueing event with eventId=" + eventId + " for later execution"); | |
| + | |
| // The app is not fully loaded as its task manager is not there yet. | |
| - // We need to add event's body to the queue from which events will be executed once the task manager is ready. | |
| + // We need to add event's body to the queue from which events will be executed | |
| + // once the task manager is ready. | |
| if (!mTasksAndEventsRepository.hasEvents(appScopeKey)) { | |
| mTasksAndEventsRepository.putEvents(appScopeKey, new ArrayList<>()); | |
| } | |
| @@ -431,8 +473,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| } | |
| - //endregion | |
| - //region helpers | |
| + // endregion | |
| + // region helpers | |
| private HeadlessAppLoader getAppLoader() { | |
| if (mContextRef.get() != null) { | |
| @@ -442,7 +484,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| } | |
| - private void internalRegisterTask(String taskName, String appScopeKey, String appUrl, Class<TaskConsumerInterface> consumerClass, Map<String, Object> options) throws TaskRegisteringFailedException { | |
| + private void internalRegisterTask(String taskName, String appScopeKey, String appUrl, | |
| + Class<TaskConsumerInterface> consumerClass, Map<String, Object> options) throws TaskRegisteringFailedException { | |
| Constructor<?> consumerConstructor; | |
| TaskConsumerInterface consumer; | |
| Context context = mContextRef.get(); | |
| @@ -460,7 +503,9 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| Task task = new Task(taskName, appScopeKey, appUrl, consumer, options, this); | |
| - Map<String, TaskInterface> appTasks = mTasksAndEventsRepository.hasTasks(appScopeKey) ? mTasksAndEventsRepository.getTasks(appScopeKey) : new HashMap<String, TaskInterface>(); | |
| + Map<String, TaskInterface> appTasks = mTasksAndEventsRepository.hasTasks(appScopeKey) | |
| + ? mTasksAndEventsRepository.getTasks(appScopeKey) | |
| + : new HashMap<String, TaskInterface>(); | |
| appTasks.put(taskName, task); | |
| mTasksAndEventsRepository.putTasks(appScopeKey, appTasks); | |
| @@ -530,16 +575,17 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| appConfig.put("appUrl", appUrl); | |
| preferences | |
| - .edit() | |
| - .putString(appScopeKey, new JSONObject(appConfig).toString()) | |
| - .apply(); | |
| + .edit() | |
| + .putString(appScopeKey, new JSONObject(appConfig).toString()) | |
| + .apply(); | |
| } | |
| } | |
| } | |
| @SuppressWarnings("unchecked") | |
| private void restoreTasks() { | |
| - Map<String, TasksAndEventsRepository.AppConfig> apps = mTasksAndEventsRepository.readPersistedTasks(getSharedPreferences()); | |
| + Map<String, TasksAndEventsRepository.AppConfig> apps = mTasksAndEventsRepository | |
| + .readPersistedTasks(getSharedPreferences()); | |
| for (Map.Entry<String, TasksAndEventsRepository.AppConfig> entry : apps.entrySet()) { | |
| String appScopeKey = entry.getKey(); | |
| @@ -561,13 +607,15 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| Map<String, Object> options = (HashMap<String, Object>) taskConfig.get("options"); | |
| try { | |
| - // register the task using internal method which doesn't change shared preferences. | |
| + // register the task using internal method which doesn't change shared | |
| + // preferences. | |
| internalRegisterTask(taskName, appScopeKey, appUrl, consumerClass, options); | |
| } catch (TaskRegisteringFailedException e) { | |
| Log.e(TAG, e.getMessage()); | |
| } | |
| } else { | |
| - Log.w(TAG, "Task consumer '" + consumerClassString + "' has version '" + currentConsumerVersion + "' that is not compatible with the saved version '" + previousConsumerVersion + "'."); | |
| + Log.w(TAG, "Task consumer '" + consumerClassString + "' has version '" + currentConsumerVersion | |
| + + "' that is not compatible with the saved version '" + previousConsumerVersion + "'."); | |
| } | |
| } catch (ClassNotFoundException | NullPointerException e) { | |
| Log.e(TAG, e.getMessage()); | |
| @@ -587,7 +635,8 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| /** | |
| - * Returns task manager for given appScopeKey. Task managers initialized in non-headless contexts have precedence over headless one. | |
| + * Returns task manager for given appScopeKey. Task managers initialized in | |
| + * non-headless contexts have precedence over headless one. | |
| */ | |
| @Nullable | |
| private TaskManagerInterface getTaskManager(String appScopeKey) { | |
| @@ -610,11 +659,22 @@ public class TaskService implements SingletonModule, TaskServiceInterface { | |
| } | |
| private void finishJobAfterTimeout(final JobService jobService, final JobParameters params, long timeout) { | |
| + final int jobId = params.getJobId(); | |
| + final String taskName = params.getExtras().getString("taskName"); | |
| + | |
| + // PATCH: Log timeout scheduling | |
| + patchLog("finishJobAfterTimeout - scheduling jobFinished callback for jobId=" + jobId + ", taskName=" + taskName | |
| + + " in " + timeout + "ms"); | |
| + | |
| Handler handler = new Handler(); | |
| handler.postDelayed(new Runnable() { | |
| @Override | |
| public void run() { | |
| + // PATCH: Log when timeout fires | |
| + patchLog( | |
| + "finishJobAfterTimeout FIRED - calling jobFinished(false) for jobId=" + jobId + ", taskName=" + taskName); | |
| jobService.jobFinished(params, false); | |
| + patchLog("finishJobAfterTimeout - jobFinished completed for jobId=" + jobId); | |
| } | |
| }, timeout); | |
| } | |
| diff --git a/android/src/main/java/expo/modules/taskManager/Utils.java b/android/src/main/java/expo/modules/taskManager/Utils.java | |
| index 36b4e573409a80aac0087fe713992e69ba698c62..548d821432771e19e626df5c9724e290fd3c9b67 100644 | |
| --- a/android/src/main/java/expo/modules/taskManager/Utils.java | |
| +++ b/android/src/main/java/expo/modules/taskManager/Utils.java | |
| @@ -51,7 +51,8 @@ public class Utils { | |
| } | |
| /** | |
| - * Method that unversions class names, so we can always use unversioned task consumer classes. | |
| + * Method that unversions class names, so we can always use unversioned task | |
| + * consumer classes. | |
| */ | |
| public static String unversionedClassNameForClass(Class versionedClass) { | |
| String className = versionedClass.getName(); | |
| @@ -59,7 +60,8 @@ public class Utils { | |
| } | |
| /** | |
| - * Returns task consumer's version. Defaults to 0 if `VERSION` static field is not implemented. | |
| + * Returns task consumer's version. Defaults to 0 if `VERSION` static field is | |
| + * not implemented. | |
| */ | |
| public static int getConsumerVersion(Class consumerClass) { | |
| try { | |
| @@ -103,7 +105,10 @@ public class Utils { | |
| for (int i = 0; i < json.length(); i++) { | |
| Object value = json.get(i); | |
| - if (value instanceof JSONArray) { | |
| + // Handle JSONObject.NULL - convert to actual Java null | |
| + if (value == JSONObject.NULL) { | |
| + value = null; | |
| + } else if (value instanceof JSONArray) { | |
| value = jsonToList((JSONArray) value); | |
| } else if (value instanceof JSONObject) { | |
| value = jsonToMap((JSONObject) value); | |
| @@ -117,6 +122,12 @@ public class Utils { | |
| } | |
| public static Object jsonObjectToObject(Object json) { | |
| + // Handle JSONObject.NULL - convert to actual Java null | |
| + // JSONObject.NULL is returned when a JSON key has a null value | |
| + // and it's an instance of a special singleton class (JSONObject$1) | |
| + if (json == null || json == JSONObject.NULL) { | |
| + return null; | |
| + } | |
| if (json instanceof JSONObject) { | |
| return jsonToMap((JSONObject) json); | |
| } | |
| diff --git a/expo-module.config.json b/expo-module.config.json | |
| index b2698a3815fd5e5e8db3ee597e3ba8ec160028ef..e3ed27b1a8c4563cc300353d0a978cbfbd401e54 100644 | |
| --- a/expo-module.config.json | |
| +++ b/expo-module.config.json | |
| @@ -1,12 +1,6 @@ | |
| { | |
| "platforms": ["apple", "android"], | |
| "android": { | |
| - "modules": ["expo.modules.taskManager.TaskManagerModule"], | |
| - "publication": { | |
| - "groupId": "host.exp.exponent", | |
| - "artifactId": "expo.modules.taskmanager", | |
| - "version": "14.0.7", | |
| - "repository": "local-maven-repo" | |
| - } | |
| + "modules": ["expo.modules.taskManager.TaskManagerModule"] | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment