diff --git a/Libraries/ReactNative/AppRegistry.js b/Libraries/ReactNative/AppRegistry.js index 2d517ce6dd0..d7e9fc788a8 100644 --- a/Libraries/ReactNative/AppRegistry.js +++ b/Libraries/ReactNative/AppRegistry.js @@ -21,6 +21,7 @@ const createPerformanceLogger = require('../Utilities/createPerformanceLogger'); import type {IPerformanceLogger} from '../Utilities/createPerformanceLogger'; import NativeHeadlessJsTaskSupport from './NativeHeadlessJsTaskSupport'; +import HeadlessJsTaskError from './HeadlessJsTaskError'; type Task = (taskData: any) => Promise; type TaskProvider = () => Task; @@ -275,8 +276,18 @@ const AppRegistry = { }) .catch(reason => { console.error(reason); - if (NativeHeadlessJsTaskSupport) { - NativeHeadlessJsTaskSupport.notifyTaskFinished(taskId); + + if ( + NativeHeadlessJsTaskSupport && + reason instanceof HeadlessJsTaskError + ) { + NativeHeadlessJsTaskSupport.notifyTaskRetry(taskId).then( + retryPosted => { + if (!retryPosted) { + NativeHeadlessJsTaskSupport.notifyTaskFinished(taskId); + } + }, + ); } }); }, diff --git a/Libraries/ReactNative/HeadlessJsTaskError.js b/Libraries/ReactNative/HeadlessJsTaskError.js new file mode 100644 index 00000000000..0f85e138bb5 --- /dev/null +++ b/Libraries/ReactNative/HeadlessJsTaskError.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ +'use strict'; + +export default class HeadlessJsTaskError extends Error {} diff --git a/Libraries/ReactNative/NativeHeadlessJsTaskSupport.js b/Libraries/ReactNative/NativeHeadlessJsTaskSupport.js index 686ababaa9a..d3256f49e10 100644 --- a/Libraries/ReactNative/NativeHeadlessJsTaskSupport.js +++ b/Libraries/ReactNative/NativeHeadlessJsTaskSupport.js @@ -15,6 +15,7 @@ import * as TurboModuleRegistry from '../TurboModule/TurboModuleRegistry'; export interface Spec extends TurboModule { +notifyTaskFinished: (taskId: number) => void; + +notifyTaskRetry: (taskId: number) => Promise; } export default TurboModuleRegistry.get('HeadlessJsTaskSupport'); diff --git a/ReactAndroid/src/androidTest/java/com/facebook/react/tests/core/WritableNativeMapTest.java b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/core/WritableNativeMapTest.java index 5199156393d..e709799f66e 100644 --- a/ReactAndroid/src/androidTest/java/com/facebook/react/tests/core/WritableNativeMapTest.java +++ b/ReactAndroid/src/androidTest/java/com/facebook/react/tests/core/WritableNativeMapTest.java @@ -5,6 +5,8 @@ import static org.fest.assertions.api.Assertions.assertThat; import androidx.test.runner.AndroidJUnit4; import com.facebook.react.bridge.NoSuchKeyException; import com.facebook.react.bridge.UnexpectedNativeTypeException; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; import org.junit.Assert; @@ -16,6 +18,8 @@ import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) public class WritableNativeMapTest { + private static final String ARRAY = "array"; + private static final String MAP = "map"; private WritableNativeMap mMap; @Before @@ -25,8 +29,8 @@ public class WritableNativeMapTest { mMap.putDouble("double", 1.2); mMap.putInt("int", 1); mMap.putString("string", "abc"); - mMap.putMap("map", new WritableNativeMap()); - mMap.putArray("array", new WritableNativeArray()); + mMap.putMap(MAP, new WritableNativeMap()); + mMap.putArray(ARRAY, new WritableNativeArray()); mMap.putBoolean("dvacca", true); } @@ -100,4 +104,22 @@ public class WritableNativeMapTest { assertThat(e.getMessage()).contains(key); } } + + @Test + public void testCopy() { + final WritableMap copy = mMap.copy(); + + assertThat(copy).isNotSameAs(mMap); + assertThat(copy.getMap(MAP)).isNotSameAs(mMap.getMap(MAP)); + assertThat(copy.getArray(ARRAY)).isNotSameAs(mMap.getArray(ARRAY)); + } + + @Test + public void testCopyModification() { + final WritableMap copy = mMap.copy(); + copy.putString("string", "foo"); + + assertThat(copy.getString("string")).isEqualTo("foo"); + assertThat(mMap.getString("string")).isEqualTo("abc"); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java index 139f8246113..ddf450f814c 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java @@ -206,6 +206,13 @@ public class JavaOnlyMap implements ReadableMap, WritableMap { mBackingMap.putAll(((JavaOnlyMap) source).mBackingMap); } + @Override + public WritableMap copy() { + final JavaOnlyMap target = new JavaOnlyMap(); + target.merge(this); + return target; + } + @Override public void putArray(@Nonnull String key, @Nullable WritableArray value) { mBackingMap.put(key, value); diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/UiThreadUtil.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/UiThreadUtil.java index 3adf9b8359e..9b320097d69 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/UiThreadUtil.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/UiThreadUtil.java @@ -44,11 +44,18 @@ public class UiThreadUtil { * Runs the given {@code Runnable} on the UI thread. */ public static void runOnUiThread(Runnable runnable) { + runOnUiThread(runnable, 0); + } + + /** + * Runs the given {@code Runnable} on the UI thread with the specified delay. + */ + public static void runOnUiThread(Runnable runnable, long delayInMs) { synchronized (UiThreadUtil.class) { if (sMainHandler == null) { sMainHandler = new Handler(Looper.getMainLooper()); } } - sMainHandler.post(runnable); + sMainHandler.postDelayed(runnable, delayInMs); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableMap.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableMap.java index 1aa2d7a108f..9338c19ae96 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableMap.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableMap.java @@ -24,4 +24,5 @@ public interface WritableMap extends ReadableMap { void putMap(@Nonnull String key, @Nullable WritableMap value); void merge(@Nonnull ReadableMap source); + WritableMap copy(); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableNativeMap.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableNativeMap.java index ec632940b47..997f4058783 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableNativeMap.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/WritableNativeMap.java @@ -61,6 +61,13 @@ public class WritableNativeMap extends ReadableNativeMap implements WritableMap mergeNativeMap((ReadableNativeMap) source); } + @Override + public WritableMap copy() { + final WritableNativeMap target = new WritableNativeMap(); + target.merge(this); + return target; + } + public WritableNativeMap() { super(initHybrid()); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java index f0e15451f7c..1c441300ba1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskConfig.java @@ -15,6 +15,7 @@ public class HeadlessJsTaskConfig { private final WritableMap mData; private final long mTimeout; private final boolean mAllowedInForeground; + private final HeadlessJsTaskRetryPolicy mRetryPolicy; /** * Create a HeadlessJsTaskConfig. Equivalent to calling @@ -55,10 +56,51 @@ public class HeadlessJsTaskConfig { WritableMap data, long timeout, boolean allowedInForeground) { + this(taskKey, data, timeout, allowedInForeground, NoRetryPolicy.INSTANCE); + } + + /** + * 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. + * @param retryPolicy the number of times & delays the task should be retried on error. + */ + public HeadlessJsTaskConfig( + String taskKey, + WritableMap data, + long timeout, + boolean allowedInForeground, + HeadlessJsTaskRetryPolicy retryPolicy) { mTaskKey = taskKey; mData = data; mTimeout = timeout; mAllowedInForeground = allowedInForeground; + mRetryPolicy = retryPolicy; + } + + public HeadlessJsTaskConfig(HeadlessJsTaskConfig source) { + mTaskKey = source.mTaskKey; + mData = source.mData.copy(); + mTimeout = source.mTimeout; + mAllowedInForeground = source.mAllowedInForeground; + + final HeadlessJsTaskRetryPolicy retryPolicy = source.mRetryPolicy; + if (retryPolicy != null) { + mRetryPolicy = retryPolicy.copy(); + } else { + mRetryPolicy = null; + } } /* package */ String getTaskKey() { @@ -76,4 +118,8 @@ public class HeadlessJsTaskConfig { /* package */ boolean isAllowedInForeground() { return mAllowedInForeground; } + + /* package */ HeadlessJsTaskRetryPolicy getRetryPolicy() { + return mRetryPolicy; + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java index fbf38f1a41e..e69505f396a 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskContext.java @@ -5,12 +5,6 @@ package com.facebook.react.jstasks; -import java.lang.ref.WeakReference; -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; @@ -20,6 +14,14 @@ import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.common.LifecycleState; import com.facebook.react.modules.appregistry.AppRegistry; +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; + /** * Helper class for dealing with JS tasks. Handles per-ReactContext active task tracking, starting / * stopping tasks and notifying listeners. @@ -51,6 +53,7 @@ public class HeadlessJsTaskContext { private final AtomicInteger mLastTaskId = new AtomicInteger(0); private final Handler mHandler = new Handler(); private final Set mActiveTasks = new CopyOnWriteArraySet<>(); + private final Map mActiveTaskConfigs = new ConcurrentHashMap<>(); private final SparseArray mTaskTimeouts = new SparseArray<>(); private HeadlessJsTaskContext(ReactContext reactContext) { @@ -85,6 +88,16 @@ public class HeadlessJsTaskContext { * @return a unique id representing this task instance. */ public synchronized int startTask(final HeadlessJsTaskConfig taskConfig) { + final int taskId = mLastTaskId.incrementAndGet(); + startTask(taskConfig, taskId); + return taskId; + } + + /** + * Start a JS task the provided task id. Handles invoking {@link AppRegistry#startHeadlessTask} + * and notifying listeners. + */ + private synchronized void startTask(final HeadlessJsTaskConfig taskConfig, int taskId) { UiThreadUtil.assertOnUiThread(); ReactContext reactContext = Assertions.assertNotNull( mReactContext.get(), @@ -95,8 +108,8 @@ public class HeadlessJsTaskContext { "Tried to start task " + taskConfig.getTaskKey() + " while in foreground, but this is not allowed."); } - final int taskId = mLastTaskId.incrementAndGet(); mActiveTasks.add(taskId); + mActiveTaskConfigs.put(taskId, new HeadlessJsTaskConfig(taskConfig)); reactContext.getJSModule(AppRegistry.class) .startHeadlessTask(taskId, taskConfig.getTaskKey(), taskConfig.getData()); if (taskConfig.getTimeout() > 0) { @@ -105,7 +118,44 @@ public class HeadlessJsTaskContext { for (HeadlessJsTaskEventListener listener : mHeadlessJsTaskEventListeners) { listener.onHeadlessJsTaskStart(taskId); } - return taskId; + } + + /** + * Retry a running JS task with a delay. Invokes + * {@link HeadlessJsTaskContext#startTask(HeadlessJsTaskConfig, int)} as long as the process does + * not get killed. + * + * @return true if a retry attempt has been posted. + */ + public synchronized boolean retryTask(final int taskId) { + final HeadlessJsTaskConfig sourceTaskConfig = mActiveTaskConfigs.get(taskId); + Assertions.assertCondition( + sourceTaskConfig != null, + "Tried to retrieve non-existent task config with id " + taskId + "."); + + final HeadlessJsTaskRetryPolicy retryPolicy = sourceTaskConfig.getRetryPolicy(); + if (!retryPolicy.canRetry()) { + return false; + } + + removeTimeout(taskId); + final HeadlessJsTaskConfig taskConfig = new HeadlessJsTaskConfig( + sourceTaskConfig.getTaskKey(), + sourceTaskConfig.getData(), + sourceTaskConfig.getTimeout(), + sourceTaskConfig.isAllowedInForeground(), + retryPolicy.update() + ); + + final Runnable retryAttempt = new Runnable() { + @Override + public void run() { + startTask(taskConfig, taskId); + } + }; + + UiThreadUtil.runOnUiThread(retryAttempt, retryPolicy.getDelay()); + return true; } /** @@ -118,11 +168,10 @@ public class HeadlessJsTaskContext { 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); - } + Assertions.assertCondition( + mActiveTaskConfigs.remove(taskId) != null, + "Tried to remove non-existent task config with id " + taskId + "."); + removeTimeout(taskId); UiThreadUtil.runOnUiThread(new Runnable() { @Override public void run() { @@ -133,6 +182,14 @@ public class HeadlessJsTaskContext { }); } + private void removeTimeout(int taskId) { + Runnable timeout = mTaskTimeouts.get(taskId); + if (timeout != null) { + mHandler.removeCallbacks(timeout); + mTaskTimeouts.remove(taskId); + } + } + /** * Check if a given task is currently running. A task is stopped if either {@link #finishTask} is * called or it times out. diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskRetryPolicy.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskRetryPolicy.java new file mode 100644 index 00000000000..215f5b8b44c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/HeadlessJsTaskRetryPolicy.java @@ -0,0 +1,16 @@ +package com.facebook.react.jstasks; + +import javax.annotation.CheckReturnValue; + +public interface HeadlessJsTaskRetryPolicy { + + boolean canRetry(); + + int getDelay(); + + @CheckReturnValue + HeadlessJsTaskRetryPolicy update(); + + HeadlessJsTaskRetryPolicy copy(); + +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/LinearCountingRetryPolicy.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/LinearCountingRetryPolicy.java new file mode 100644 index 00000000000..b319d35435e --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/LinearCountingRetryPolicy.java @@ -0,0 +1,38 @@ +package com.facebook.react.jstasks; + +public class LinearCountingRetryPolicy implements HeadlessJsTaskRetryPolicy { + + private final int mRetryAttempts; + private final int mDelayBetweenAttemptsInMs; + + public LinearCountingRetryPolicy(int retryAttempts, int delayBetweenAttemptsInMs) { + mRetryAttempts = retryAttempts; + mDelayBetweenAttemptsInMs = delayBetweenAttemptsInMs; + } + + @Override + public boolean canRetry() { + return mRetryAttempts > 0; + } + + @Override + public int getDelay() { + return mDelayBetweenAttemptsInMs; + } + + @Override + public HeadlessJsTaskRetryPolicy update() { + final int remainingRetryAttempts = mRetryAttempts - 1; + + if (remainingRetryAttempts > 0) { + return new LinearCountingRetryPolicy(remainingRetryAttempts, mDelayBetweenAttemptsInMs); + } else { + return NoRetryPolicy.INSTANCE; + } + } + + @Override + public HeadlessJsTaskRetryPolicy copy() { + return new LinearCountingRetryPolicy(mRetryAttempts, mDelayBetweenAttemptsInMs); + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/jstasks/NoRetryPolicy.java b/ReactAndroid/src/main/java/com/facebook/react/jstasks/NoRetryPolicy.java new file mode 100644 index 00000000000..ac707ab70d7 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/jstasks/NoRetryPolicy.java @@ -0,0 +1,30 @@ +package com.facebook.react.jstasks; + +public class NoRetryPolicy implements HeadlessJsTaskRetryPolicy { + + public static final NoRetryPolicy INSTANCE = new NoRetryPolicy(); + + private NoRetryPolicy() { + } + + @Override + public boolean canRetry() { + return false; + } + + @Override + public int getDelay() { + throw new IllegalStateException("Should not retrieve delay as canRetry is: " + canRetry()); + } + + @Override + public HeadlessJsTaskRetryPolicy update() { + throw new IllegalStateException("Should not update as canRetry is: " + canRetry()); + } + + @Override + public HeadlessJsTaskRetryPolicy copy() { + // Class is immutable so no need to copy + return this; + } +} 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 index 2d783585a77..e9c2cae2a69 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/core/HeadlessJsTaskSupportModule.java @@ -8,6 +8,7 @@ package com.facebook.react.modules.core; import com.facebook.common.logging.FLog; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; @@ -32,6 +33,22 @@ public class HeadlessJsTaskSupportModule extends ReactContextBaseJavaModule { return NAME; } + @ReactMethod + public void notifyTaskRetry(int taskId, Promise promise) { + HeadlessJsTaskContext headlessJsTaskContext = + HeadlessJsTaskContext.getInstance(getReactApplicationContext()); + if (headlessJsTaskContext.isTaskRunning(taskId)) { + final boolean retryPosted = headlessJsTaskContext.retryTask(taskId); + promise.resolve(retryPosted); + } else { + FLog.w( + HeadlessJsTaskSupportModule.class, + "Tried to retry non-active task with id %d. Did it time out?", + taskId); + promise.resolve(false); + } + } + @ReactMethod public void notifyTaskFinished(int taskId) { HeadlessJsTaskContext headlessJsTaskContext =