Files
react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/JavaOnlyMap.java
T
Joshua Ong ac7ec4602f Allow headless JS tasks to retry (#23231)
Summary:
`setTimeout` inside a headless JS task does not always works; the function does not get invoked until the user starts an `Activity`.

This was attempted to be used in the context of widgets. When the widget update or user interaction causes the process and React context to be created, the headless JS task may run before other app-specific JS initialisation logic has completed. If it's not possible to change the behaviour of the pre-requisites to be synchronous, then the headless JS task blocks such asynchronous JS work that it may depend on. A primitive solution is the use of `setTimeout` in order to wait for the pre-conditions to be met before continuing with the rest of the headless JS task. But as the function passed to `setTimeout` is not always called, the task will not run to completion.

This PR solves this scenario by allowing the task to be retried again with a delay. If the task returns a promise that resolves to a `{'timeout': number}` object, `AppRegistry.js` will not notify that the task has finished as per master, instead it will tell `HeadlessJsContext` to `startTask` again (cleaning up any posted `Runnable`s beforehand) via a `Handler` within the `HeadlessJsContext`.

Documentation also updated here: https://github.com/facebook/react-native-website/pull/771

### AppRegistry.js
If the task provider does not return any data, or if the data it returns does not contain `timeout` as a number, then it behaves as `master`; notifies that the task has finished. If the response does contain `{timeout: number}`, then it will attempt to queue a retry. If that fails, then it will behaves as if the task provider returned no response i.e. behaves as `master` again. If the retry was successfully queued, then there is nothing to do as we do not want the `Service` to stop itself.

### HeadlessJsTaskSupportModule.java
Similar to notify start/finished, we simply check if the context is running, and if so, pass the request onto `HeadlessJsTaskContext`. The only difference here is that we return a `Promise`, so that `AppRegistry`, as above, knows whether the enqueuing failed and thus needs to perform the usual task clean-up.

### HeadlessJsTaskContext.java
Before retrying, we need to clean-up any timeout `Runnable`'s posted for the first attempt. Then we need to copy the task config so that if this retry (second attempt) also fails, then on the third attempt (second retry) we do not run into a consumed exception. This is also why in `startTask` we copy the config before putting it in the `Map`, so that the initial attempt does leave the config's in the map as consumed. Then we post a `Runnable` to call `startTask` on the main thread's `Handler`. We use the same `taskId` because the `Service` is keeping track of active task IDs in order to calculate whether it needs to `stopSelf`. This negates the need to inform the `Service` of a new task id and us having to remove the old one.

## Changelog
[Android][added] - Allow headless JS tasks to return a promise that will cause the task to be retried again with the specified delay
Pull Request resolved: https://github.com/facebook/react-native/pull/23231

Differential Revision: D15646870

fbshipit-source-id: 4440f4b4392f1fa5c69aab7908b51b7007ba2c40
2019-06-06 11:57:49 -07:00

249 lines
6.7 KiB
Java

/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* <p>This source code is licensed under the MIT license found in the LICENSE file in the root
* directory of this source tree.
*/
package com.facebook.react.bridge;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Java {@link HashMap} backed implementation of {@link ReadableMap} and {@link WritableMap}
* Instances of this class SHOULD NOT be used for communication between java and JS, use instances
* of {@link WritableNativeMap} created via {@link Arguments#createMap} or just {@link ReadableMap}
* interface if you want your "native" module method to take a map from JS as an argument.
*
* <p>Main purpose for this class is to be used in java-only unit tests, but could also be used
* outside of tests in the code that operates only in java and needs to communicate with RN modules
* via their JS-exposed API.
*/
public class JavaOnlyMap implements ReadableMap, WritableMap {
private final Map mBackingMap;
public static JavaOnlyMap of(Object... keysAndValues) {
return new JavaOnlyMap(keysAndValues);
}
public static JavaOnlyMap deepClone(ReadableMap map) {
JavaOnlyMap res = new JavaOnlyMap();
ReadableMapKeySetIterator iter = map.keySetIterator();
while (iter.hasNextKey()) {
String propKey = iter.nextKey();
ReadableType type = map.getType(propKey);
switch (type) {
case Null:
res.putNull(propKey);
break;
case Boolean:
res.putBoolean(propKey, map.getBoolean(propKey));
break;
case Number:
res.putDouble(propKey, map.getDouble(propKey));
break;
case String:
res.putString(propKey, map.getString(propKey));
break;
case Map:
res.putMap(propKey, deepClone(map.getMap(propKey)));
break;
case Array:
res.putArray(propKey, JavaOnlyArray.deepClone(map.getArray(propKey)));
break;
}
}
return res;
}
/** @param keysAndValues keys and values, interleaved */
private JavaOnlyMap(Object... keysAndValues) {
if (keysAndValues.length % 2 != 0) {
throw new IllegalArgumentException("You must provide the same number of keys and values");
}
mBackingMap = new HashMap();
for (int i = 0; i < keysAndValues.length; i += 2) {
Object val = keysAndValues[i + 1];
if (val instanceof Number) {
// all values from JS are doubles, so emulate that here for tests.
val = ((Number)val).doubleValue();
}
mBackingMap.put(keysAndValues[i], val);
}
}
public JavaOnlyMap() {
mBackingMap = new HashMap();
}
@Override
public boolean hasKey(@Nonnull String name) {
return mBackingMap.containsKey(name);
}
@Override
public boolean isNull(@Nonnull String name) {
return mBackingMap.get(name) == null;
}
@Override
public boolean getBoolean(@Nonnull String name) {
return (Boolean) mBackingMap.get(name);
}
@Override
public double getDouble(@Nonnull String name) {
return ((Number) mBackingMap.get(name)).doubleValue();
}
@Override
public int getInt(@Nonnull String name) {
return ((Number) mBackingMap.get(name)).intValue();
}
@Override
public String getString(@Nonnull String name) {
return (String) mBackingMap.get(name);
}
@Override
public ReadableMap getMap(@Nonnull String name) {
return (ReadableMap) mBackingMap.get(name);
}
@Override
public JavaOnlyArray getArray(@Nonnull String name) {
return (JavaOnlyArray) mBackingMap.get(name);
}
@Override
public @Nonnull Dynamic getDynamic(@Nonnull String name) {
return DynamicFromMap.create(this, name);
}
@Override
public @Nonnull ReadableType getType(@Nonnull String name) {
Object value = mBackingMap.get(name);
if (value == null) {
return ReadableType.Null;
} else if (value instanceof Number) {
return ReadableType.Number;
} else if (value instanceof String) {
return ReadableType.String;
} else if (value instanceof Boolean) {
return ReadableType.Boolean;
} else if (value instanceof ReadableMap) {
return ReadableType.Map;
} else if (value instanceof ReadableArray) {
return ReadableType.Array;
} else if (value instanceof Dynamic) {
return ((Dynamic) value).getType();
} else {
throw new IllegalArgumentException(
"Invalid value " + value.toString() + " for key " + name + "contained in JavaOnlyMap");
}
}
@Override
public @Nonnull Iterator<Map.Entry<String, Object>> getEntryIterator() {
return mBackingMap.entrySet().iterator();
}
@Override
public @Nonnull ReadableMapKeySetIterator keySetIterator() {
return new ReadableMapKeySetIterator() {
Iterator<Map.Entry<String, Object>> mIterator = mBackingMap.entrySet().iterator();
@Override
public boolean hasNextKey() {
return mIterator.hasNext();
}
@Override
public String nextKey() {
return mIterator.next().getKey();
}
};
}
@Override
public void putBoolean(@Nonnull String key, boolean value) {
mBackingMap.put(key, value);
}
@Override
public void putDouble(@Nonnull String key, double value) {
mBackingMap.put(key, value);
}
@Override
public void putInt(@Nonnull String key, int value) {
mBackingMap.put(key, new Double(value));
}
@Override
public void putString(@Nonnull String key, @Nullable String value) {
mBackingMap.put(key, value);
}
@Override
public void putNull(@Nonnull String key) {
mBackingMap.put(key, null);
}
@Override
public void putMap(@Nonnull String key, @Nullable WritableMap value) {
mBackingMap.put(key, value);
}
@Override
public void merge(@Nonnull ReadableMap source) {
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);
}
@Override
public @Nonnull HashMap<String, Object> toHashMap() {
return new HashMap<String, Object>(mBackingMap);
}
@Override
public String toString() {
return mBackingMap.toString();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
JavaOnlyMap that = (JavaOnlyMap) o;
if (mBackingMap != null ? !mBackingMap.equals(that.mBackingMap) : that.mBackingMap != null)
return false;
return true;
}
@Override
public int hashCode() {
return mBackingMap != null ? mBackingMap.hashCode() : 0;
}
}