diff --git a/Examples/UIExplorer/SnapshotExample.js b/Examples/UIExplorer/SnapshotExample.js index d843acee80d3..88dd663188e5 100644 --- a/Examples/UIExplorer/SnapshotExample.js +++ b/Examples/UIExplorer/SnapshotExample.js @@ -25,6 +25,8 @@ var { } = React; var ScreenshotExample = React.createClass({ + _view: null, + getInitialState() { return { uri: undefined, @@ -33,29 +35,66 @@ var ScreenshotExample = React.createClass({ render() { return ( - + { this._view = ref; }}> + - Click to take a screenshot + Screenshot the App (JPG 50%) + + + Screenshot this View (PNG) - + + Screenshot the App & resize to 32x48 + + ); }, takeScreenshot() { UIManager - .takeSnapshot('window', {format: 'jpeg', quality: 0.8}) // See UIManager.js for options + .takeSnapshot('window', {format: 'jpeg', quality: 0.5 }) // See UIManager.js for options + .then((uri) => this.setState({uri})) + .catch((error) => alert(error)); + }, + + takeScreenshot2() { + UIManager + .takeSnapshot(this._view) + .then((uri) => this.setState({uri})) + .catch((error) => alert(error)); + }, + + takeScreenshot3() { + UIManager + .takeSnapshot('window', { width: 32, height: 48 }) .then((uri) => this.setState({uri})) .catch((error) => alert(error)); } }); var style = StyleSheet.create({ + root: { + backgroundColor: '#fff', + position: 'relative', + }, button: { - marginBottom: 10, + margin: 5, fontWeight: '500', + backgroundColor: 'transparent', + textDecorationLine: 'underline' }, image: { + width: 40, + height: 40, + position: 'absolute', + top: 0, + right: 0, + }, + result: { flex: 1, height: 300, resizeMode: 'contain', diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 341dddc928c5..0249919d34ee 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -47,6 +47,10 @@ var ComponentExamples: Array = [ key: 'ScrollViewSimpleExample', module: require('./ScrollViewSimpleExample'), }, + { + key: 'SnapshotExample', + module: require('./SnapshotExample'), + }, { key: 'StatusBarExample', module: require('./StatusBarExample'), diff --git a/Libraries/Utilities/UIManager.js b/Libraries/Utilities/UIManager.js index efd8ce5b0728..b051a7019c57 100644 --- a/Libraries/Utilities/UIManager.js +++ b/Libraries/Utilities/UIManager.js @@ -49,15 +49,15 @@ const _takeSnapshot = UIManager.takeSnapshot; * Capture an image of the screen, window or an individual view. The image * will be stored in a temporary file that will only exist for as long as the * app is running. - * + * * The `view` argument can be the literal string `window` if you want to * capture the entire window, or it can be a reference to a specific * React Native component. * * The `options` argument may include: * - width/height (number) - the width and height of the image to capture. - * - format (string) - either 'png' or 'jpeg'. Defaults to 'png'. - * - quality (number) - the quality when using jpeg. 0.0 - 1.0 (default). + * - format (string) - either 'png' or 'jpg'/'jpeg' or 'webm' (Android). Defaults to 'png'. + * - quality (number) - the quality. 0.0 - 1.0 (default). (only available on lossy formats like jpeg) * * Returns a Promise. * @platform ios @@ -78,7 +78,8 @@ UIManager.takeSnapshot = async function( if (typeof view !== 'number' && view !== 'window') { view = findNodeHandle(view) || 'window'; } - return _takeSnapshot(view, options); + const isTag = typeof view === 'number'; + return _takeSnapshot(isTag ? null : view, isTag ? view : 0, options || {}); }; module.exports = UIManager; diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index d0db1c40aa5b..887ab6670281 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -1226,7 +1226,8 @@ static void RCTMeasureLayout(RCTShadowView *view, callback(@[results]); } -RCT_EXPORT_METHOD(takeSnapshot:(id /* NSString or NSNumber */)target +RCT_EXPORT_METHOD(takeSnapshot:(NSString *)target + withTag:(nonnull NSNumber *)tag withOptions:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) @@ -1235,10 +1236,10 @@ static void RCTMeasureLayout(RCTShadowView *view, // Get view UIView *view; - if (target == nil || [target isEqual:@"window"]) { + if (target && [target isEqual:@"window"]) { view = RCTKeyWindow(); - } else if ([target isKindOfClass:[NSNumber class]]) { - view = viewRegistry[target]; + } else { + view = viewRegistry[tag]; if (!view) { RCTLogError(@"No view found with reactTag: %@", target); return; @@ -1269,7 +1270,7 @@ static void RCTMeasureLayout(RCTShadowView *view, NSData *data; if ([format isEqualToString:@"png"]) { data = UIImagePNGRepresentation(image); - } else if ([format isEqualToString:@"jpeg"]) { + } else if ([format isEqualToString:@"jpeg"]||[format isEqualToString:@"jpg"]) { CGFloat quality = [RCTConvert CGFloat:options[@"quality"] ?: @1]; data = UIImageJPEGRepresentation(image, quality); } else { diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java index b5939823acf2..4b10e8bce866 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/NativeViewHierarchyManager.java @@ -37,6 +37,9 @@ import com.facebook.systrace.Systrace; import com.facebook.systrace.SystraceMessage; +import java.io.FileOutputStream; +import java.io.IOException; + /** * Delegate of {@link UIManagerModule} that owns the native view hierarchy and mapping between * native view names used in JS and corresponding instances of {@link ViewManager}. The diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/Snapshot.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/Snapshot.java new file mode 100644 index 000000000000..c8fbe01ab73c --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/Snapshot.java @@ -0,0 +1,58 @@ +package com.facebook.react.uimanager; + +import javax.annotation.Nullable; +import android.graphics.Bitmap; +import android.view.View; +import java.io.FileOutputStream; + +/** + * Snapshot utility class allow to screenshot a view. + */ +public class Snapshot { + + static final String ERROR_UNABLE_TO_SNAPSHOT = "E_UNABLE_TO_SNAPSHOT"; + + Bitmap.CompressFormat format; + double quality; + Integer width; + Integer height; + + public Snapshot(Bitmap.CompressFormat format, double quality, @Nullable Integer width, @Nullable Integer height) { + this.format = format; + this.quality = quality; + this.width = width; + this.height = height; + } + + /** + * Write the captured image to a file output stream. + * @param view the view to screenshot + * @param out the file output stream to write + */ + public void captureViewToFileOutputStream (View view, FileOutputStream out) { + Bitmap bitmap = captureView(view); + if (bitmap == null) { + throw new RuntimeException("Impossible to snapshot the view"); + } + bitmap.compress(format, (int)(100.0 * quality), out); + } + + /** + * Screenshot a view and return the captured bitmap. + * @param view the view to capture + * @return the screenshot or null if it failed. + */ + public Bitmap captureView (View view) { + int w = view.getWidth(); + int h = view.getHeight(); + if (w <= 0 || h <= 0) return null; + Bitmap bitmap = view.getDrawingCache(); + if (bitmap == null) + view.setDrawingCacheEnabled(true); + bitmap = view.getDrawingCache(); + if (width != null && height != null && (width != w || height != h)) { + bitmap = Bitmap.createScaledBitmap(bitmap, width, height, true); + } + return bitmap; + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java index b407c416a5a9..ff0d80672743 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIImplementation.java @@ -10,6 +10,7 @@ import javax.annotation.Nullable; +import java.io.File; import java.util.Arrays; import java.util.List; @@ -18,6 +19,8 @@ import com.facebook.react.animation.Animation; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; @@ -729,4 +732,25 @@ protected void applyUpdatesRecursive( } cssNode.markUpdateSeen(); } + + public void takeSnapshot(String target, int tag, Snapshot snapshot, File destFile, Promise promise) { + int t; + if (target == null) { + t = tag; + } + else { + if (target.equals("window")) { + t = mShadowNodeRegistry.getRootTag(0); + } + else { + throw new JSApplicationIllegalArgumentException("Invalid snapshot target: "+target); + } + } + mOperationsQueue.enqueueTakeSnapshot( + t, + snapshot, + destFile, + promise + ); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java index ddeb94e6db2e..0e5821641d5e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIManagerModule.java @@ -9,16 +9,28 @@ package com.facebook.react.uimanager; +import android.content.Context; +import android.graphics.Bitmap; +import android.os.AsyncTask; +import android.util.DisplayMetrics; + import javax.annotation.Nullable; +import java.io.File; +import java.io.FilenameFilter; +import java.io.IOException; import java.util.List; import java.util.Map; import com.facebook.react.animation.Animation; import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.GuardedAsyncTask; +import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.LifecycleEventListener; import com.facebook.react.bridge.OnBatchCompleteListener; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableArray; @@ -76,6 +88,7 @@ public UIManagerModule( List viewManagerList, UIImplementation uiImplementation) { super(reactContext); + new CleanTask(getReactApplicationContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); mEventDispatcher = new EventDispatcher(reactContext); mModuleConstants = createConstants(viewManagerList); mUIImplementation = uiImplementation; @@ -112,6 +125,7 @@ public void onHostDestroy() { public void onCatalystInstanceDestroy() { super.onCatalystInstanceDestroy(); mEventDispatcher.onCatalystInstanceDestroyed(); + new CleanTask(getReactApplicationContext()).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); } private static Map createConstants(List viewManagerList) { @@ -449,4 +463,93 @@ public EventDispatcher getEventDispatcher() { public void sendAccessibilityEvent(int tag, int eventType) { mUIImplementation.sendAccessibilityEvent(tag, eventType); } + + @ReactMethod + public void takeSnapshot(String target, int tag, ReadableMap options, Promise promise) { + ReactApplicationContext context = getReactApplicationContext(); + String format = options.hasKey("format") ? options.getString("format") : "png"; + Bitmap.CompressFormat compressFormat = + format.equals("png") ? Bitmap.CompressFormat.PNG : + format.equals("jpg")||format.equals("jpeg") ? Bitmap.CompressFormat.JPEG : + format.equals("webm") ? Bitmap.CompressFormat.WEBP : + null; + if (compressFormat == null) { + throw new JSApplicationIllegalArgumentException("Unsupported image format: " + format); + } + double quality = options.hasKey("quality") ? options.getDouble("quality") : 1.0; + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + Integer width = options.hasKey("width") ? (int)(displayMetrics.density * options.getDouble("width")) : null; + Integer height = options.hasKey("height") ? (int)(displayMetrics.density * options.getDouble("height")) : null; + try { + File tmpFile = createTempFile(getReactApplicationContext(), format); + mUIImplementation.takeSnapshot(target, tag, new Snapshot(compressFormat, quality, width, height), tmpFile, promise); + } + catch (Exception e) { + promise.reject(Snapshot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag "+tag); + } + } + + + private static final String TEMP_FILE_PREFIX = "ReactNative_snapshot_image_"; + /** + * Asynchronous task that cleans up cache dirs (internal and, if available, external) of cropped + * image files. This is run when the catalyst instance is being destroyed (i.e. app is shutting + * down) and when the module is instantiated, to handle the case where the app crashed. + */ + private static class CleanTask extends GuardedAsyncTask { + private final Context mContext; + + private CleanTask(ReactContext context) { + super(context); + mContext = context; + } + + @Override + protected void doInBackgroundGuarded(Void... params) { + cleanDirectory(mContext.getCacheDir()); + File externalCacheDir = mContext.getExternalCacheDir(); + if (externalCacheDir != null) { + cleanDirectory(externalCacheDir); + } + } + + private void cleanDirectory(File directory) { + File[] toDelete = directory.listFiles( + new FilenameFilter() { + @Override + public boolean accept(File dir, String filename) { + return filename.startsWith(TEMP_FILE_PREFIX); + } + }); + if (toDelete != null) { + for (File file: toDelete) { + file.delete(); + } + } + } + } + + /** + * Create a temporary file in the cache directory on either internal or external storage, + * whichever is available and has more free space. + */ + private File createTempFile(Context context, String ext) + throws IOException { + File externalCacheDir = context.getExternalCacheDir(); + File internalCacheDir = context.getCacheDir(); + File cacheDir; + if (externalCacheDir == null && internalCacheDir == null) { + throw new IOException("No cache directory available"); + } + if (externalCacheDir == null) { + cacheDir = internalCacheDir; + } + else if (internalCacheDir == null) { + cacheDir = externalCacheDir; + } else { + cacheDir = externalCacheDir.getFreeSpace() > internalCacheDir.getFreeSpace() ? + externalCacheDir : internalCacheDir; + } + return File.createTempFile(TEMP_FILE_PREFIX, ext, cacheDir); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java index b3b931e0f8ef..f60db327f7b3 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/UIViewOperationQueue.java @@ -9,9 +9,15 @@ package com.facebook.react.uimanager; +import android.net.Uri; +import android.view.View; + import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.util.ArrayList; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; @@ -19,6 +25,7 @@ import com.facebook.react.animation.Animation; import com.facebook.react.animation.AnimationRegistry; import com.facebook.react.bridge.Callback; +import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.SoftAssertions; import com.facebook.react.bridge.ReactContext; @@ -491,6 +498,49 @@ public void execute() { } } + private final class TakeSnapshot extends ViewOperation { + private Snapshot snapshot; + private Promise promise; + private File destFile; + + /** + * Create a TakeSnapshot operation that will screenshot a view and save it to a file. + * @param tag the view to snapshot + * @param snapshot the snapshot instance (configure the snapshot and provide utility methods) + * @param destFile the output file + * @param promise will be resolved with the uri (or rejected) + */ + public TakeSnapshot(int tag, Snapshot snapshot, File destFile, Promise promise) { + super(tag); + this.snapshot = snapshot; + this.promise = promise; + this.destFile = destFile; + } + @Override + public void execute() { + FileOutputStream fileOutputStream = null; + try { + View view = mNativeViewHierarchyManager.resolveView(mTag); + fileOutputStream = new FileOutputStream(destFile); + snapshot.captureViewToFileOutputStream(view, fileOutputStream); + String uri = Uri.fromFile(destFile).toString(); + promise.resolve(uri); + } + catch (Exception e) { + promise.reject(Snapshot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag "+mTag); + } + finally { + if (fileOutputStream != null) { + try { + fileOutputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + } + private final NativeViewHierarchyManager mNativeViewHierarchyManager; private final AnimationRegistry mAnimationRegistry; @@ -687,6 +737,10 @@ public void enqueueSendAccessibilityEvent(int tag, int eventType) { mOperations.add(new SendAccessibilityEvent(tag, eventType)); } + public void enqueueTakeSnapshot(int tag, Snapshot snapshot, File destFile, Promise promise) { + mOperations.add(new TakeSnapshot(tag, snapshot, destFile, promise)); + } + /* package */ void dispatchViewUpdates(final int batchId) { // Store the current operation queues to dispatch and create new empty ones to continue // receiving new operations