From 3080b8d26cfbfdb4f940ad987c02563dee624d92 Mon Sep 17 00:00:00 2001 From: Felix Oghina Date: Thu, 29 Sep 2016 03:43:59 -0700 Subject: [PATCH] Android: add support for headless js tasks Summary: Provide a base `HeadlessJsTaskService` class that can be extended to run JS in headless mode in response to some event. Added `HeadlessJsTaskEventListener` for modules that are interested in background lifecycle events, and `HeadlessJsTaskContext` that basically extends `ReactContext` without touching it. The react instance is shared with the rest of the app (e.g. activities) through the `ReactNativeHost`. Reviewed By: astreet Differential Revision: D3225753 fbshipit-source-id: 2c5e7679636f31e0e7842d8a67aeb95baf47c563 --- Libraries/AppRegistry/AppRegistry.js | 42 ++++- .../src/main/java/com/facebook/react/BUCK | 1 + .../facebook/react/CoreModulesPackage.java | 8 + .../facebook/react/HeadlessJsTaskService.java | 165 ++++++++++++++++++ .../facebook/react/bridge/ReactContext.java | 11 +- .../main/java/com/facebook/react/jstasks/BUCK | 23 +++ .../react/jstasks/HeadlessJsTaskConfig.java | 76 ++++++++ .../react/jstasks/HeadlessJsTaskContext.java | 139 +++++++++++++++ .../jstasks/HeadlessJsTaskEventListener.java | 30 ++++ .../java/com/facebook/react/modules/core/BUCK | 1 + .../core/HeadlessJsTaskSupportModule.java | 38 ++++ .../facebook/react/modules/core/Timing.java | 84 ++++++--- .../facebook/react/uimanager/AppRegistry.java | 2 +- .../modules/timing/TimingModuleTest.java | 61 ++++++- 14 files changed, 650 insertions(+), 31 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/HeadlessJsTaskService.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/jstasks/BUCK create mode 100644 ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskEventListener.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java diff --git a/Libraries/AppRegistry/AppRegistry.js b/Libraries/AppRegistry/AppRegistry.js index 122a2248e..5dbdaa4a7 100644 --- a/Libraries/AppRegistry/AppRegistry.js +++ b/Libraries/AppRegistry/AppRegistry.js @@ -15,9 +15,11 @@ var BatchedBridge = require('BatchedBridge'); var BugReporting = require('BugReporting'); var ReactNative = require('react/lib/ReactNative'); +const infoLog = require('infoLog'); var invariant = require('fbjs/lib/invariant'); var renderApplication = require('renderApplication'); -const infoLog = require('infoLog'); + +const { HeadlessJsTaskSupport } = require('NativeModules'); if (__DEV__) { // In order to use Cmd+P to record/dump perf data, we need to make sure @@ -25,8 +27,12 @@ if (__DEV__) { require('RCTRenderingPerf'); } +type Task = (taskData: any) => Promise; +type TaskProvider = () => Task; + var runnables = {}; var runCount = 1; +const tasks: Map = new Map(); type ComponentProvider = () => ReactClass; @@ -103,6 +109,40 @@ var AppRegistry = { ReactNative.unmountComponentAtNodeAndRemoveContainer(rootTag); }, + /** + * Register a headless task. A headless task is a bit of code that runs without a UI. + * @param taskKey the key associated with this task + * @param task a promise returning function that takes some data passed from the native side as + * the only argument; when the promise is resolved or rejected the native side is + * notified of this event and it may decide to destroy the JS context. + */ + registerHeadlessTask: function(taskKey: string, task: TaskProvider): void { + if (tasks.has(taskKey)) { + console.warn(`registerHeadlessTask called multiple times for same key '${taskKey}'`); + } + tasks.set(taskKey, task); + }, + + /** + * Only called from native code. Starts a headless task. + * + * @param taskId the native id for this task instance to keep track of its execution + * @param taskKey the key for the task to start + * @param data the data to pass to the task + */ + startHeadlessTask: function(taskId: number, taskKey: string, data: any): void { + const taskProvider = tasks.get(taskKey); + if (!taskProvider) { + throw new Error(`No task registered for key ${taskKey}`); + } + taskProvider()(data) + .then(() => HeadlessJsTaskSupport.notifyTaskFinished(taskId)) + .catch(reason => { + console.error(reason); + HeadlessJsTaskSupport.notifyTaskFinished(taskId); + }); + } + }; BatchedBridge.registerCallableModule( diff --git a/ReactAndroid/src/main/java/com/facebook/react/BUCK b/ReactAndroid/src/main/java/com/facebook/react/BUCK index 1d8a7bc4c..b96d08966 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/BUCK @@ -5,6 +5,7 @@ DEPS = [ react_native_target('java/com/facebook/react/bridge:bridge'), react_native_target('java/com/facebook/react/common:common'), react_native_target('java/com/facebook/react/devsupport:devsupport'), + react_native_target('java/com/facebook/react/jstasks:jstasks'), react_native_target('java/com/facebook/react/module/annotations:annotations'), react_native_target('java/com/facebook/react/module/model:model'), react_native_target('java/com/facebook/react/modules/core:core'), diff --git a/ReactAndroid/src/main/java/com/facebook/react/CoreModulesPackage.java b/ReactAndroid/src/main/java/com/facebook/react/CoreModulesPackage.java index cb0c210b0..1b6ac73f7 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/CoreModulesPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/CoreModulesPackage.java @@ -26,6 +26,7 @@ import com.facebook.react.devsupport.HMRClient; import com.facebook.react.devsupport.JSCHeapCapture; import com.facebook.react.devsupport.JSCSamplingProfiler; import com.facebook.react.module.annotations.ReactModuleList; +import com.facebook.react.modules.core.HeadlessJsTaskSupportModule; import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler; import com.facebook.react.modules.core.DeviceEventManagerModule; import com.facebook.react.modules.core.ExceptionsManagerModule; @@ -97,6 +98,13 @@ import static com.facebook.react.bridge.ReactMarkerConstants.CREATE_UI_MANAGER_M return new AndroidInfoModule(); } })); + moduleSpecList + .add(new ModuleSpec(HeadlessJsTaskSupportModule.class, new Provider() { + @Override + public NativeModule get() { + return new HeadlessJsTaskSupportModule(reactContext); + } + })); moduleSpecList.add( new ModuleSpec(DeviceEventManagerModule.class, new Provider() { @Override diff --git a/ReactAndroid/src/main/java/com/facebook/react/HeadlessJsTaskService.java b/ReactAndroid/src/main/java/com/facebook/react/HeadlessJsTaskService.java new file mode 100644 index 000000000..4d88f009c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/HeadlessJsTaskService.java @@ -0,0 +1,165 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react; + +import javax.annotation.Nullable; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.os.PowerManager; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.jstasks.HeadlessJsTaskEventListener; +import com.facebook.react.jstasks.HeadlessJsTaskConfig; +import com.facebook.react.jstasks.HeadlessJsTaskContext; + +/** + * Base class for running JS without a UI. Generally, you only need to override + * {@link #getTaskConfig}, which is called for every {@link #onStartCommand}. The + * result, if not {@code null}, is used to run a JS task. + * + * If you need more fine-grained control over how tasks are run, you can override + * {@link #onStartCommand} and call {@link #startTask} depending on your custom logic. + * + * If you're starting a {@code HeadlessJsTaskService} 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. + */ +public abstract class HeadlessJsTaskService extends Service implements HeadlessJsTaskEventListener { + + private final Set mActiveTasks = new CopyOnWriteArraySet<>(); + private static @Nullable PowerManager.WakeLock sWakeLock; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + HeadlessJsTaskConfig taskConfig = getTaskConfig(intent); + if (taskConfig != null) { + startTask(taskConfig); + return START_REDELIVER_INTENT; + } + return START_NOT_STICKY; + } + + /** + * Called from {@link #onStartCommand} to create a {@link HeadlessJsTaskConfig} for this intent. + * @param intent the {@link Intent} received in {@link #onStartCommand}. + * @return a {@link HeadlessJsTaskConfig} to be used with {@link #startTask}, or + * {@code null} to ignore this command. + */ + protected @Nullable HeadlessJsTaskConfig getTaskConfig(Intent intent) { + return null; + } + + /** + * Acquire a wake lock to ensure the device doesn't go to sleep while processing background tasks. + */ + public static void acquireWakeLockNow(Context context) { + if (sWakeLock == null || !sWakeLock.isHeld()) { + PowerManager powerManager = + Assertions.assertNotNull((PowerManager) context.getSystemService(POWER_SERVICE)); + sWakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + HeadlessJsTaskService.class.getSimpleName()); + sWakeLock.setReferenceCounted(false); + sWakeLock.acquire(); + } + } + + @Override + public @Nullable IBinder onBind(Intent intent) { + return null; + } + + /** + * Start a task. This method handles starting a new React instance if required. + * + * Has to be called on the UI thread. + * + * @param taskConfig describes what task to start and the parameters to pass to it + */ + protected void startTask(final HeadlessJsTaskConfig taskConfig) { + UiThreadUtil.assertOnUiThread(); + acquireWakeLockNow(this); + final ReactInstanceManager reactInstanceManager = + getReactNativeHost().getReactInstanceManager(); + ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); + if (reactContext == null) { + reactInstanceManager + .addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() { + @Override + public void onReactContextInitialized(ReactContext reactContext) { + invokeStartTask(reactContext, taskConfig); + reactInstanceManager.removeReactInstanceEventListener(this); + } + }); + if (!reactInstanceManager.hasStartedCreatingInitialContext()) { + reactInstanceManager.createReactContextInBackground(); + } + } else { + invokeStartTask(reactContext, taskConfig); + } + } + + private void invokeStartTask(ReactContext reactContext, HeadlessJsTaskConfig taskConfig) { + HeadlessJsTaskContext headlessJsTaskContext = HeadlessJsTaskContext.getInstance(reactContext); + headlessJsTaskContext.addTaskEventListener(this); + int taskId = headlessJsTaskContext.startTask(taskConfig); + mActiveTasks.add(taskId); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (getReactNativeHost().hasInstance()) { + ReactInstanceManager reactInstanceManager = getReactNativeHost().getReactInstanceManager(); + ReactContext reactContext = reactInstanceManager.getCurrentReactContext(); + if (reactContext != null) { + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(reactContext); + headlessJsTaskContext.removeTaskEventListener(this); + } + } + if (sWakeLock != null) { + sWakeLock.release(); + } + } + + @Override + public void onHeadlessJsTaskStart(int taskId) { } + + @Override + public void onHeadlessJsTaskFinish(int taskId) { + mActiveTasks.remove(taskId); + if (mActiveTasks.size() == 0) { + stopSelf(); + } + } + + /** + * 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 ReactNativeHost getReactNativeHost() { + return ((ReactApplication) getApplication()).getReactNativeHost(); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index 7d0416903..482c37247 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -12,9 +12,9 @@ package com.facebook.react.bridge; import javax.annotation.Nullable; import java.lang.ref.WeakReference; -import java.util.concurrent.CopyOnWriteArraySet; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.CopyOnWriteArraySet; import android.app.Activity; import android.content.Context; @@ -47,6 +47,8 @@ public class ReactContext extends ContextWrapper { private final CopyOnWriteArraySet mActivityEventListeners = new CopyOnWriteArraySet<>(); + private LifecycleState mLifecycleState = LifecycleState.BEFORE_CREATE; + private @Nullable CatalystInstance mCatalystInstance; private @Nullable LayoutInflater mInflater; private @Nullable MessageQueueThread mUiMessageQueueThread; @@ -54,7 +56,6 @@ public class ReactContext extends ContextWrapper { private @Nullable MessageQueueThread mJSMessageQueueThread; private @Nullable NativeModuleCallExceptionHandler mNativeModuleCallExceptionHandler; private @Nullable WeakReference mCurrentActivity; - private LifecycleState mLifecycleState = LifecycleState.BEFORE_RESUME; public ReactContext(Context base) { super(base); @@ -145,6 +146,10 @@ public class ReactContext extends ContextWrapper { return mCatalystInstance != null && !mCatalystInstance.isDestroyed(); } + public LifecycleState getLifecycleState() { + return mLifecycleState; + } + public void addLifecycleEventListener(final LifecycleEventListener listener) { mLifecycleEventListeners.add(listener); if (hasActiveCatalystInstance()) { @@ -197,6 +202,7 @@ public class ReactContext extends ContextWrapper { */ public void onHostResume(@Nullable Activity activity) { UiThreadUtil.assertOnUiThread(); + mLifecycleState = LifecycleState.RESUMED; mCurrentActivity = new WeakReference(activity); mLifecycleState = LifecycleState.RESUMED; for (LifecycleEventListener listener : mLifecycleEventListeners) { @@ -228,6 +234,7 @@ public class ReactContext extends ContextWrapper { */ public void onHostDestroy() { UiThreadUtil.assertOnUiThread(); + mLifecycleState = LifecycleState.BEFORE_CREATE; for (LifecycleEventListener listener : mLifecycleEventListeners) { listener.onHostDestroy(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/BUCK b/ReactAndroid/src/main/java/com/facebook/react/jstasks/BUCK new file mode 100644 index 000000000..09359c749 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/BUCK @@ -0,0 +1,23 @@ +include_defs('//ReactAndroid/DEFS') + +DEPS = [ + react_native_target('java/com/facebook/react/bridge:bridge'), + react_native_target('java/com/facebook/react/common:common'), + react_native_target('java/com/facebook/react/uimanager:uimanager'), + react_native_dep('libraries/fbcore/src/main/java/com/facebook/common/logging:logging'), + react_native_dep('third-party/java/infer-annotations:infer-annotations'), + react_native_dep('third-party/java/jsr-305:jsr-305'), +] + +android_library( + name = 'jstasks', + srcs = glob(['*.java']), + deps = DEPS, + visibility = [ + 'PUBLIC', + ], +) + +project_config( + src_target = ':jstasks', +) diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java new file mode 100644 index 000000000..747d0132e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java @@ -0,0 +1,76 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.jstasks; + +import com.facebook.react.bridge.WritableMap; + +/** + * Class that holds the various parameters needed to start a JS task. + */ +public class HeadlessJsTaskConfig { + private final String mTaskKey; + private final WritableMap mData; + private final long mTimeout; + private final boolean mAllowedInForeground; + + /** + * Create a HeadlessJsTaskConfig. Equivalent to calling + * {@link #HeadlessJsTaskConfig(String, WritableMap, long, boolean)} with no timeout (0) and + * {@code false} for {@code allowedInBackground}. + */ + public HeadlessJsTaskConfig(String taskKey, WritableMap data) { + this(taskKey, data, 0, false); + } + + /** + * Create a HeadlessJsTaskConfig. Equivalent to calling + * {@link #HeadlessJsTaskConfig(String, WritableMap, long, boolean)} with {@code false} for + * {@code allowedInBackground}. + */ + public HeadlessJsTaskConfig(String taskKey, WritableMap data, long timeout) { + this(taskKey, data, timeout, false); + } + + /** + * Create a HeadlessJsTaskConfig. + * + * @param taskKey the key for the JS task to execute. This is the same key that you call {@code + * AppRegistry.registerTask} with in JS. + * @param data a map of parameters passed to the JS task executor. + * @param timeout the amount of time (in ms) after which the React instance should be terminated + * regardless of whether the task has completed or not. This is meant as a safeguard against + * accidentally keeping the device awake for long periods of time because JS crashed or some + * request timed out. A value of 0 means no timeout (should only be used for long-running tasks + * such as music playback). + * @param allowedInForeground whether to allow this task to run while the app is in the foreground + * (i.e. there is a host in resumed mode for the current ReactContext). Only set this to true if + * you really need it. Note that tasks run in the same JS thread as UI code, so doing expensive + * operations would degrade user experience. + */ + public HeadlessJsTaskConfig( + String taskKey, + WritableMap data, + long timeout, + boolean allowedInForeground) { + mTaskKey = taskKey; + mData = data; + mTimeout = timeout; + mAllowedInForeground = allowedInForeground; + } + + /* package */ String getTaskKey() { + return mTaskKey; + } + + /* package */ WritableMap getData() { + return mData; + } + + /* package */ long getTimeout() { + return mTimeout; + } + + /* package */ boolean isAllowedInForeground() { + return mAllowedInForeground; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java new file mode 100644 index 000000000..5dfb919aa --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java @@ -0,0 +1,139 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +package com.facebook.react.jstasks; + +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; + +import android.os.Handler; +import android.util.SparseArray; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.common.LifecycleState; +import com.facebook.react.uimanager.AppRegistry; + +/** + * Helper class for dealing with JS tasks. Handles per-ReactContext active task tracking, starting / + * stopping tasks and notifying listeners. + */ +public class HeadlessJsTaskContext { + + private static final WeakHashMap INSTANCES = + new WeakHashMap<>(); + + /** + * Get the task helper instance for a particular {@link ReactContext}. There is only one instance + * per context. + *

+ * Note: do not hold long-lived references to the object returned here, as that + * will cause memory leaks. Instead, just call this method on-demand. + */ + public static HeadlessJsTaskContext getInstance(ReactContext context) { + HeadlessJsTaskContext helper = INSTANCES.get(context); + if (helper == null) { + helper = new HeadlessJsTaskContext(context); + INSTANCES.put(context, helper); + } + return helper; + } + + private final ReactContext mReactContext; + private final Set mHeadlessJsTaskEventListeners = + new CopyOnWriteArraySet<>(); + private final AtomicInteger mLastTaskId = new AtomicInteger(0); + private final Handler mHandler = new Handler(); + private final Set mActiveTasks = new CopyOnWriteArraySet<>(); + private final SparseArray mTaskTimeouts = new SparseArray<>(); + + private HeadlessJsTaskContext(ReactContext reactContext) { + mReactContext = reactContext; + } + + /** + * Register a task lifecycle event listener. + */ + public void addTaskEventListener(HeadlessJsTaskEventListener listener) { + mHeadlessJsTaskEventListeners.add(listener); + } + + /** + * Unregister a task lifecycle event listener. + */ + public void removeTaskEventListener(HeadlessJsTaskEventListener listener) { + mHeadlessJsTaskEventListeners.remove(listener); + } + + /** + * Get whether there are any running JS tasks at the moment. + */ + public boolean hasActiveTasks() { + return mActiveTasks.size() > 0; + } + + /** + * Start a JS task. Handles invoking {@link AppRegistry#startHeadlessTask} and notifying + * listeners. + * + * @return a unique id representing this task instance. + */ + public synchronized int startTask(final HeadlessJsTaskConfig taskConfig) { + UiThreadUtil.assertOnUiThread(); + if (mReactContext.getLifecycleState() == LifecycleState.RESUMED && + !taskConfig.isAllowedInForeground()) { + throw new IllegalStateException( + "Tried to start task " + taskConfig.getTaskKey() + + " while in foreground, but this is not allowed."); + } + final int taskId = mLastTaskId.incrementAndGet(); + mReactContext.getJSModule(AppRegistry.class) + .startHeadlessTask(taskId, taskConfig.getTaskKey(), taskConfig.getData()); + if (taskConfig.getTimeout() > 0) { + scheduleTaskTimeout(taskId, taskConfig.getTimeout()); + } + mActiveTasks.add(taskId); + for (HeadlessJsTaskEventListener listener : mHeadlessJsTaskEventListeners) { + listener.onHeadlessJsTaskStart(taskId); + } + return taskId; + } + + /** + * Finish a JS task. Doesn't actually stop the task on the JS side, only removes it from the list + * of active tasks and notifies listeners. + * + * @param taskId the unique id returned by {@link #startTask}. + */ + public synchronized void finishTask(final int taskId) { + Assertions.assertCondition( + mActiveTasks.remove(taskId), + "Tried to finish non-existent task with id " + taskId + "."); + Runnable timeout = mTaskTimeouts.get(taskId); + if (timeout != null) { + mHandler.removeCallbacks(timeout); + mTaskTimeouts.remove(taskId); + } + UiThreadUtil.runOnUiThread(new Runnable() { + @Override + public void run() { + for (HeadlessJsTaskEventListener listener : mHeadlessJsTaskEventListeners) { + listener.onHeadlessJsTaskFinish(taskId); + } + } + }); + } + + private void scheduleTaskTimeout(final int taskId, long timeout) { + Runnable runnable = new Runnable() { + @Override + public void run() { + finishTask(taskId); + } + }; + mTaskTimeouts.append(taskId, runnable); + mHandler.postDelayed(runnable, timeout); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskEventListener.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskEventListener.java new file mode 100644 index 000000000..fd81539b4 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskEventListener.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.jstasks; + +/** + * Listener interface for task lifecycle events. + */ +public interface HeadlessJsTaskEventListener { + + /** + * Called when a JS task is started, on the UI thread. + * + * @param taskId the unique identifier of this task instance + */ + void onHeadlessJsTaskStart(int taskId); + + /** + * Called when a JS task finishes (i.e. when + * {@link HeadlessJsTaskSupportModule#notifyTaskFinished} is called, or when it times out), on the + * UI thread. + */ + void onHeadlessJsTaskFinish(int taskId); +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/BUCK b/ReactAndroid/src/main/java/com/facebook/react/modules/core/BUCK index 415cb678a..6c5cf2424 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/BUCK @@ -12,6 +12,7 @@ android_library( react_native_target('java/com/facebook/react/devsupport:devsupport'), react_native_target('java/com/facebook/react/module/annotations:annotations'), react_native_target('java/com/facebook/react/uimanager:uimanager'), + react_native_target('java/com/facebook/react/jstasks:jstasks'), ], visibility = [ 'PUBLIC', diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java new file mode 100644 index 000000000..352b40c7c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +package com.facebook.react.modules.core; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.jstasks.HeadlessJsTaskContext; + +/** + * Simple native module that allows JS to notify native of having completed some task work, so that + * it can e.g. release any resources, stop timers etc. + */ +public class HeadlessJsTaskSupportModule extends ReactContextBaseJavaModule { + + public HeadlessJsTaskSupportModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Override + public String getName() { + return "HeadlessJsTaskSupport"; + } + + @ReactMethod + public void notifyTaskFinished(int taskId) { + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + headlessJsTaskContext.finishTask(taskId); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/core/Timing.java b/ReactAndroid/src/main/java/com/facebook/react/modules/core/Timing.java index 30bc729d2..092f34e61 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/Timing.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/Timing.java @@ -9,6 +9,18 @@ package com.facebook.react.modules.core; +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + import android.util.SparseArray; import android.view.Choreographer; @@ -24,27 +36,17 @@ import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableArray; import com.facebook.react.common.SystemClock; import com.facebook.react.devsupport.DevSupportManager; +import com.facebook.react.jstasks.HeadlessJsTaskEventListener; +import com.facebook.react.jstasks.HeadlessJsTaskContext; import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.uimanager.ReactChoreographer; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.PriorityQueue; -import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; - -import javax.annotation.Nullable; - /** * Native module for JS timer execution. Timers fire on frame boundaries. */ @ReactModule(name = "RCTTiming", supportsWebWorkers = true) public final class Timing extends ReactContextBaseJavaModule implements LifecycleEventListener, - OnExecutorUnregisteredListener { + OnExecutorUnregisteredListener, HeadlessJsTaskEventListener { // These timing contants should be kept in sync with the ones in `JSTimersExecution.js`. // The minimum time in milliseconds left in the frame to call idle callbacks. @@ -88,7 +90,7 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl */ @Override public void doFrame(long frameTimeNanos) { - if (isPaused.get()) { + if (isPaused.get() && !isRunningTasks.get()) { return; } @@ -132,7 +134,7 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl @Override public void doFrame(long frameTimeNanos) { - if (isPaused.get()) { + if (isPaused.get() && !isRunningTasks.get()) { return; } @@ -197,6 +199,7 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl private final PriorityQueue mTimers; private final Map> mTimerIdsToTimers; private final AtomicBoolean isPaused = new AtomicBoolean(true); + private final AtomicBoolean isRunningTasks = new AtomicBoolean(false); private final TimerFrameCallback mTimerFrameCallback = new TimerFrameCallback(); private final IdleFrameCallback mIdleFrameCallback = new IdleFrameCallback(); private @Nullable IdleCallbackRunnable mCurrentIdleCallbackRunnable; @@ -236,19 +239,22 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl // Safe to acquire choreographer here, as initialize() is invoked from UI thread. mReactChoreographer = ReactChoreographer.getInstance(); getReactApplicationContext().addLifecycleEventListener(this); + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + headlessJsTaskContext.addTaskEventListener(this); } @Override public void onHostPause() { isPaused.set(true); clearChoreographerCallback(); - clearChoreographerIdleCallback(); + maybeClearChoreographerIdleCallback(); } @Override public void onHostDestroy() { clearChoreographerCallback(); - clearChoreographerIdleCallback(); + maybeClearChoreographerIdleCallback(); } @Override @@ -257,11 +263,25 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl // TODO(5195192) Investigate possible problems related to restarting all tasks at the same // moment setChoreographerCallback(); + maybeSetChoreographerIdleCallback(); + } - synchronized (mIdleCallbackGuard) { - if (mSendIdleEventsExecutorTokens.size() > 0) { - setChoreographerIdleCallback(); - } + @Override + public void onHeadlessJsTaskStart(int taskId) { + if (!isRunningTasks.getAndSet(true)) { + setChoreographerCallback(); + maybeSetChoreographerIdleCallback(); + } + } + + @Override + public void onHeadlessJsTaskFinish(int taskId) { + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + if (!headlessJsTaskContext.hasActiveTasks()) { + isRunningTasks.set(false); + clearChoreographerCallback(); + maybeClearChoreographerIdleCallback(); } } @@ -269,6 +289,23 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl public void onCatalystInstanceDestroy() { clearChoreographerCallback(); clearChoreographerIdleCallback(); + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + headlessJsTaskContext.removeTaskEventListener(this); + } + + private void maybeSetChoreographerIdleCallback() { + synchronized (mIdleCallbackGuard) { + if (mSendIdleEventsExecutorTokens.size() > 0) { + setChoreographerIdleCallback(); + } + } + } + + private void maybeClearChoreographerIdleCallback() { + if (isPaused.get() && !isRunningTasks.get()) { + clearChoreographerCallback(); + } } private void setChoreographerCallback() { @@ -281,7 +318,10 @@ public final class Timing extends ReactContextBaseJavaModule implements Lifecycl } private void clearChoreographerCallback() { - if (mFrameCallbackPosted) { + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + if (mFrameCallbackPosted && isPaused.get() && + !headlessJsTaskContext.hasActiveTasks()) { Assertions.assertNotNull(mReactChoreographer).removeFrameCallback( ReactChoreographer.CallbackType.TIMERS_EVENTS, mTimerFrameCallback); diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/AppRegistry.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/AppRegistry.java index d9a387b7f..d0f4cbeef 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/AppRegistry.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/AppRegistry.java @@ -19,5 +19,5 @@ public interface AppRegistry extends JavaScriptModule { void runApplication(String appKey, WritableMap appParameters); void unmountApplicationComponentAtRootTag(int rootNodeTag); - + void startHeadlessTask(int taskId, String taskKey, WritableMap data); } diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/timing/TimingModuleTest.java b/ReactAndroid/src/test/java/com/facebook/react/modules/timing/TimingModuleTest.java index 5ca8e67e0..288c0bf30 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/modules/timing/TimingModuleTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/timing/TimingModuleTest.java @@ -22,22 +22,17 @@ import com.facebook.react.common.SystemClock; import com.facebook.react.modules.core.JSTimersExecution; import com.facebook.react.modules.core.Timing; -import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.powermock.api.mockito.PowerMockito; import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.core.classloader.annotations.PowerMockIgnore; import org.powermock.modules.junit4.rule.PowerMockRule; -import org.robolectric.RuntimeEnvironment; import org.robolectric.RobolectricTestRunner; -import org.robolectric.annotation.Config; import static org.mockito.Mockito.*; @@ -189,6 +184,62 @@ public class TimingModuleTest { verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); } + @Test + public void testHeadlessJsTaskInBackground() { + mTiming.onHostPause(); + mTiming.onHeadlessJsTaskStart(42); + mTiming.createTimer(mExecutorTokenMock, 41, 1, 0, true); + + stepChoreographerFrame(); + verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); + + reset(mJSTimersMock); + mTiming.onHeadlessJsTaskFinish(42); + stepChoreographerFrame(); + verifyNoMoreInteractions(mJSTimersMock); + } + + @Test + public void testHeadlessJsTaskInForeground() { + mTiming.onHostResume(); + mTiming.onHeadlessJsTaskStart(42); + mTiming.createTimer(mExecutorTokenMock, 41, 1, 0, true); + + stepChoreographerFrame(); + verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); + + reset(mJSTimersMock); + mTiming.onHeadlessJsTaskFinish(42); + stepChoreographerFrame(); + verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); + + reset(mJSTimersMock); + mTiming.onHostPause(); + verifyNoMoreInteractions(mJSTimersMock); + } + + @Test + public void testHeadlessJsTaskIntertwine() { + mTiming.onHostResume(); + mTiming.onHeadlessJsTaskStart(42); + mTiming.createTimer(mExecutorTokenMock, 41, 1, 0, true); + mTiming.onHostPause(); + + stepChoreographerFrame(); + verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); + + reset(mJSTimersMock); + mTiming.onHostResume(); + mTiming.onHeadlessJsTaskFinish(42); + stepChoreographerFrame(); + verify(mJSTimersMock).callTimers(JavaOnlyArray.of(41)); + + reset(mJSTimersMock); + mTiming.onHostPause(); + stepChoreographerFrame(); + verifyNoMoreInteractions(mJSTimersMock); + } + @Test public void testSetTimeoutZero() { mTiming.createTimer(mExecutorTokenMock, 100, 0, 0, false);