Permalink
Browse files

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
  • Loading branch information...
1 parent 542ab86 commit 3080b8d26cfbfdb4f940ad987c02563dee624d92 @foghina foghina committed with Facebook Github Bot Sep 29, 2016
@@ -15,18 +15,24 @@ 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
// this module is available in the bundle
require('RCTRenderingPerf');
}
+type Task = (taskData: any) => Promise<void>;
+type TaskProvider = () => Task;
+
var runnables = {};
var runCount = 1;
+const tasks: Map<string, TaskProvider> = new Map();
type ComponentProvider = () => ReactClass<any>;
@@ -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(
@@ -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'),
@@ -26,6 +26,7 @@
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 @@ public NativeModule get() {
return new AndroidInfoModule();
}
}));
+ moduleSpecList
+ .add(new ModuleSpec(HeadlessJsTaskSupportModule.class, new Provider<NativeModule>() {
+ @Override
+ public NativeModule get() {
+ return new HeadlessJsTaskSupportModule(reactContext);
+ }
+ }));
moduleSpecList.add(
new ModuleSpec(DeviceEventManagerModule.class, new Provider<NativeModule>() {
@Override
@@ -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<Integer> 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();
+ }
+}
@@ -12,9 +12,9 @@
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,14 +47,15 @@
private final CopyOnWriteArraySet<ActivityEventListener> mActivityEventListeners =
new CopyOnWriteArraySet<>();
+ private LifecycleState mLifecycleState = LifecycleState.BEFORE_CREATE;
+
private @Nullable CatalystInstance mCatalystInstance;
private @Nullable LayoutInflater mInflater;
private @Nullable MessageQueueThread mUiMessageQueueThread;
private @Nullable MessageQueueThread mNativeModulesMessageQueueThread;
private @Nullable MessageQueueThread mJSMessageQueueThread;
private @Nullable NativeModuleCallExceptionHandler mNativeModuleCallExceptionHandler;
private @Nullable WeakReference<Activity> mCurrentActivity;
- private LifecycleState mLifecycleState = LifecycleState.BEFORE_RESUME;
public ReactContext(Context base) {
super(base);
@@ -145,6 +146,10 @@ public boolean hasActiveCatalystInstance() {
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 void removeActivityEventListener(ActivityEventListener listener) {
*/
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 void onHostPause() {
*/
public void onHostDestroy() {
UiThreadUtil.assertOnUiThread();
+ mLifecycleState = LifecycleState.BEFORE_CREATE;
for (LifecycleEventListener listener : mLifecycleEventListeners) {
listener.onHostDestroy();
}
@@ -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',
+)
Oops, something went wrong.

3 comments on commit 3080b8d

@datwheat

Will this be a solution for service worker-like processing or is this just for background tasks running while the UI of the app is still alive? For example, I want to be able to launch a worker that polls for database changes and notifies the user that something has changed or they have received a message.

Thanks!

@foghina
Contributor

The former. I have a PR for adding docs about this in #10325.

@datwheat

Awesome! I'm stoked 👍

Please sign in to comment.