diff --git a/.gitignore b/.gitignore index b6d14825..19e61b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,18 @@ npm-debug.log # android # -android/build/ -android/.gradle/ -android/.idea/ -android/*.iml -android/gradle/ +.vscode/ +.settings/ +android/bin +android/gradle/wrapper android/gradlew android/gradlew.bat android/local.properties +*.iml +.gradle +/local.properties +.idea/ +captures/ +.externalNativeBuild +.project + diff --git a/README.md b/README.md index 6611d7af..2819284e 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,81 @@ Model tested: iPhone 6 (iOS), Nexus 5 (Android). 3. Component itself lacks platform support. 4. But you can just use the react-native-maps snapshot function: https://github.com/airbnb/react-native-maps#take-snapshot-of-map +## Performance Optimization + +During profiling captured several things that influence on performance: +1) (de-)allocation of memory for bitmap +2) (de-)allocation of memory for Base64 output buffer +3) compression of bitmap to different image formats: PNG, JPG + +To solve that in code introduced several new approaches: +- reusable images, that reduce load on GC; +- reusable arrays/buffers that also reduce load on GC; +- RAW image format for avoiding expensive compression; +- ZIP deflate compression for RAW data, that works faster in compare to `Bitmap.compress` + +more details and code snippet are below. + +### RAW Images + +Introduced a new image format RAW. it correspond a ARGB array of pixels. + +Advantages: +- no compression, so its supper quick. Screenshot taking is less than 16ms; + +RAW format supported for `zip-base64`, `base64` and `tmpfile` result types. + +RAW file on disk saved in format: `${width}:${height}|${base64}` string. + +### zip-base64 + +In compare to BASE64 result string this format fast try to apply zip/deflate compression on screenshot results +and only after that convert results to base64 string. In combination zip-base64 + raw we got a super fast +approach for capturing screen views and deliver them to the react side. + +### How to work with zip-base64 and RAW format? + +```js +const fs = require('fs') +const zlib = require('zlib') +const PNG = require('pngjs').PNG +const Buffer = require('buffer').Buffer + +const format = Platform.OS === 'android' ? 'raw' : 'png' +const result = Platform.OS === 'android' ? 'zip-base64' : 'base64' + +captureRef(this.ref, { result, format }).then(data => { + // expected pattern 'width:height|', example: '1080:1731|' + const resolution = /^(\d+):(\d+)\|/g.exec(data) + const width = (resolution || ['', 0, 0])[1] + const height = (resolution || ['', 0, 0])[2] + const base64 = data.substr((resolution || [''])[0].length || 0) + + // convert from base64 to Buffer + const buffer = Buffer.from(base64, 'base64') + // un-compress data + const inflated = zlib.inflateSync(buffer) + // compose PNG + const png = new PNG({ width, height }) + png.data = inflated + const pngData = PNG.sync.write(png) + // save composed PNG + fs.writeFileSync(output, pngData) +}) +``` + +Keep in mind that packaging PNG data is a CPU consuming operation as a `zlib.inflate`. + +Hint: use `process.fork()` approach for converting raw data into PNGs. + +> Note: code is tested in large commercial project. + +> Note #2: Don't forget to add packages into your project: +> ```js +> yarn add pngjs +> yarn add zlib +> ``` + ## Troubleshooting / FAQ ### Saving to a file? diff --git a/android/build.gradle b/android/build.gradle index 27df6c1b..204676fb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,41 +1,48 @@ buildscript { - repositories { - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:2.3.0' + /* In case of submodule usage, do not try to apply own repositories and plugins, + root project is responsible for that. */ + if (rootProject.buildDir == project.buildDir) { + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:2.3.0' + } } } apply plugin: 'com.android.library' android { - compileSdkVersion 26 - buildToolsVersion "26.0.1" + compileSdkVersion 27 + buildToolsVersion "28.0.3" defaultConfig { minSdkVersion 16 - targetSdkVersion 26 + targetSdkVersion 27 + versionCode 1 versionName "1.0" } + lintOptions { abortOnError false } } -allprojects { - repositories { - mavenLocal() - jcenter() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url "$rootDir/../node_modules/react-native/android" - } +repositories { + google() + jcenter() + mavenLocal() + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url "$rootDir/../node_modules/react-native/android" } } dependencies { - compile 'com.facebook.react:react-native:+' + implementation 'com.android.support:support-v4:27.+' + + api 'com.facebook.react:react-native:+' } \ No newline at end of file diff --git a/android/src/main/java/fr/greweb/reactnativeviewshot/RNViewShotModule.java b/android/src/main/java/fr/greweb/reactnativeviewshot/RNViewShotModule.java index b5afdaad..a2e1c8a2 100644 --- a/android/src/main/java/fr/greweb/reactnativeviewshot/RNViewShotModule.java +++ b/android/src/main/java/fr/greweb/reactnativeviewshot/RNViewShotModule.java @@ -1,35 +1,36 @@ package fr.greweb.reactnativeviewshot; +import android.app.Activity; import android.content.Context; -import android.graphics.Bitmap; import android.net.Uri; import android.os.AsyncTask; -import android.os.Environment; +import android.support.annotation.NonNull; import android.util.DisplayMetrics; -import android.view.View; - -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; +import android.util.Log; import com.facebook.react.bridge.GuardedAsyncTask; -import com.facebook.react.bridge.JSApplicationIllegalArgumentException; 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.ReadableMap; -import com.facebook.react.uimanager.UIBlock; import com.facebook.react.uimanager.UIManagerModule; import java.io.File; import java.io.FilenameFilter; import java.io.IOException; import java.util.Collections; -import java.util.HashMap; import java.util.Map; +import fr.greweb.reactnativeviewshot.ViewShot.Formats; +import fr.greweb.reactnativeviewshot.ViewShot.Results; + public class RNViewShotModule extends ReactContextBaseJavaModule { + public static final String RNVIEW_SHOT = "RNViewShot"; + private final ReactApplicationContext reactContext; public RNViewShotModule(ReactApplicationContext reactContext) { @@ -39,7 +40,7 @@ public RNViewShotModule(ReactApplicationContext reactContext) { @Override public String getName() { - return "RNViewShot"; + return RNVIEW_SHOT; } @Override @@ -67,30 +68,40 @@ public void releaseCapture(String uri) { @ReactMethod public void captureRef(int tag, ReadableMap options, Promise promise) { - ReactApplicationContext context = getReactApplicationContext(); - String format = options.getString("format"); - Bitmap.CompressFormat compressFormat = - format.equals("jpg") - ? Bitmap.CompressFormat.JPEG - : format.equals("webm") - ? Bitmap.CompressFormat.WEBP - : Bitmap.CompressFormat.PNG; - double quality = options.getDouble("quality"); - 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; - String result = options.getString("result"); - Boolean snapshotContentContainer = options.getBoolean("snapshotContentContainer"); + final ReactApplicationContext context = getReactApplicationContext(); + final DisplayMetrics dm = context.getResources().getDisplayMetrics(); + + final String extension = options.getString("format"); + final int imageFormat = "jpg".equals(extension) + ? Formats.JPEG + : "webm".equals(extension) + ? Formats.WEBP + : "raw".equals(extension) + ? Formats.RAW + : Formats.PNG; + + final double quality = options.getDouble("quality"); + final Integer scaleWidth = options.hasKey("width") ? (int) (dm.density * options.getDouble("width")) : null; + final Integer scaleHeight = options.hasKey("height") ? (int) (dm.density * options.getDouble("height")) : null; + final String resultStreamFormat = options.getString("result"); + final Boolean snapshotContentContainer = options.getBoolean("snapshotContentContainer"); + try { - File file = null; - if ("tmpfile".equals(result)) { - file = createTempFile(getReactApplicationContext(), format); + File outputFile = null; + if (Results.TEMP_FILE.equals(resultStreamFormat)) { + outputFile = createTempFile(getReactApplicationContext(), extension); } - UIManagerModule uiManager = this.reactContext.getNativeModule(UIManagerModule.class); - uiManager.addUIBlock(new ViewShot(tag, format, compressFormat, quality, width, height, file, result, snapshotContentContainer,reactContext, getCurrentActivity(), promise)); - } - catch (Exception e) { - promise.reject(ViewShot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag "+tag); + + final Activity activity = getCurrentActivity(); + final UIManagerModule uiManager = this.reactContext.getNativeModule(UIManagerModule.class); + + uiManager.addUIBlock(new ViewShot( + tag, extension, imageFormat, quality, + scaleWidth, scaleHeight, outputFile, resultStreamFormat, + snapshotContentContainer, reactContext, activity, promise) + ); + } catch (final Throwable ignored) { + promise.reject(ViewShot.ERROR_UNABLE_TO_SNAPSHOT, "Failed to snapshot view tag " + tag); } } @@ -106,34 +117,41 @@ public void captureScreen(ReadableMap options, Promise promise) { * 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 static class CleanTask extends GuardedAsyncTask implements FilenameFilter { + private final File cacheDir; + private final File externalCacheDir; private CleanTask(ReactContext context) { super(context); - mContext = context; + + cacheDir = context.getCacheDir(); + externalCacheDir = context.getExternalCacheDir(); } @Override protected void doInBackgroundGuarded(Void... params) { - cleanDirectory(mContext.getCacheDir()); - File externalCacheDir = mContext.getExternalCacheDir(); + if (null != cacheDir) { + cleanDirectory(cacheDir); + } + 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); - } - }); + @Override + public final boolean accept(File dir, String filename) { + return filename.startsWith(TEMP_FILE_PREFIX); + } + + private void cleanDirectory(@NonNull final File directory) { + final File[] toDelete = directory.listFiles(this); + if (toDelete != null) { - for (File file: toDelete) { - file.delete(); + for (File file : toDelete) { + if (file.delete()) { + Log.d(RNVIEW_SHOT, "deleted file: " + file.getAbsolutePath()); + } } } } @@ -143,26 +161,26 @@ public boolean accept(File dir, String filename) { * 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; + @NonNull + private File createTempFile(@NonNull final Context context, @NonNull final String ext) throws IOException { + final File externalCacheDir = context.getExternalCacheDir(); + final File internalCacheDir = context.getCacheDir(); + final File cacheDir; + if (externalCacheDir == null && internalCacheDir == null) { throw new IOException("No cache directory available"); } + if (externalCacheDir == null) { cacheDir = internalCacheDir; - } - else if (internalCacheDir == null) { + } else if (internalCacheDir == null) { cacheDir = externalCacheDir; } else { cacheDir = externalCacheDir.getFreeSpace() > internalCacheDir.getFreeSpace() ? externalCacheDir : internalCacheDir; } - String suffix = "." + ext; - File tmpFile = File.createTempFile(TEMP_FILE_PREFIX, suffix, cacheDir); - return tmpFile; - } + final String suffix = "." + ext; + return File.createTempFile(TEMP_FILE_PREFIX, suffix, cacheDir); + } } diff --git a/android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java b/android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java index 33b4bdab..46ed94d4 100644 --- a/android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java +++ b/android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java @@ -1,16 +1,22 @@ package fr.greweb.reactnativeviewshot; -import javax.annotation.Nullable; - import android.app.Activity; -import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; import android.net.Uri; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.support.annotation.StringDef; import android.util.Base64; import android.view.TextureView; import android.view.View; import android.view.ViewGroup; +import android.view.ViewParent; import android.widget.ScrollView; import com.facebook.react.bridge.Promise; @@ -23,42 +29,113 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.zip.Deflater; + +import javax.annotation.Nullable; /** * Snapshot utility class allow to screenshot a view. */ public class ViewShot implements UIBlock { - + //region Constants static final String ERROR_UNABLE_TO_SNAPSHOT = "E_UNABLE_TO_SNAPSHOT"; + /** + * pre-allocated output stream size for screenshot. In real life example it will eb around 7Mb. + */ + private static final int PREALLOCATE_SIZE = 64 * 1024; + /** + * ARGB size in bytes. + */ + private static final int ARGB_SIZE = 4; + @SuppressWarnings("WeakerAccess") + @IntDef({Formats.JPEG, Formats.PNG, Formats.WEBP, Formats.RAW}) + public @interface Formats { + int JPEG = 0; // Bitmap.CompressFormat.JPEG.ordinal(); + int PNG = 1; // Bitmap.CompressFormat.PNG.ordinal(); + int WEBP = 2; // Bitmap.CompressFormat.WEBP.ordinal(); + int RAW = -1; + + Bitmap.CompressFormat[] mapping = { + Bitmap.CompressFormat.JPEG, + Bitmap.CompressFormat.PNG, + Bitmap.CompressFormat.WEBP + }; + } + + /** + * Supported Output results. + */ + @StringDef({Results.BASE_64, Results.DATA_URI, Results.TEMP_FILE, Results.ZIP_BASE_64}) + public @interface Results { + /** + * Save screenshot as temp file on device. + */ + String TEMP_FILE = "tmpfile"; + /** + * Base 64 encoded image. + */ + String BASE_64 = "base64"; + /** + * Zipped RAW image in base 64 encoding. + */ + String ZIP_BASE_64 = "zip-base64"; + /** + * Base64 data uri. + */ + String DATA_URI = "data-uri"; + } + //endregion + + //region Static members + /** + * Image output buffer used as a source for base64 encoding + */ + private static byte[] outputBuffer = new byte[PREALLOCATE_SIZE]; + //endregion + + //region Class members private final int tag; private final String extension; - private final Bitmap.CompressFormat format; + @Formats + private final int format; private final double quality; private final Integer width; private final Integer height; private final File output; + @Results private final String result; private final Promise promise; private final Boolean snapshotContentContainer; + @SuppressWarnings({"unused", "FieldCanBeLocal"}) private final ReactApplicationContext reactContext; private final Activity currentActivity; + //endregion + //region Constructors + @SuppressWarnings("WeakerAccess") public ViewShot( - int tag, - String extension, - Bitmap.CompressFormat format, - double quality, + final int tag, + final String extension, + @Formats final int format, + final double quality, @Nullable Integer width, @Nullable Integer height, - File output, - String result, - Boolean snapshotContentContainer, - ReactApplicationContext reactContext, - Activity currentActivity, - Promise promise) { + final File output, + @Results final String result, + final Boolean snapshotContentContainer, + final ReactApplicationContext reactContext, + final Activity currentActivity, + final Promise promise) { this.tag = tag; this.extension = extension; this.format = format; @@ -72,7 +149,9 @@ public ViewShot( this.currentActivity = currentActivity; this.promise = promise; } + //endregion + //region Overrides @Override public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { final View view; @@ -84,74 +163,146 @@ public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) { } if (view == null) { - promise.reject(ERROR_UNABLE_TO_SNAPSHOT, "No view found with reactTag: "+tag); + promise.reject(ERROR_UNABLE_TO_SNAPSHOT, "No view found with reactTag: " + tag); return; } + try { - if ("tmpfile".equals(result)) { - captureView(view, new FileOutputStream(output)); - final String uri = Uri.fromFile(output).toString(); - promise.resolve(uri); - } else if ("base64".equals(result)) { - final ByteArrayOutputStream os = new ByteArrayOutputStream(); - captureView(view, os); - final byte[] bytes = os.toByteArray(); - final String data = Base64.encodeToString(bytes, Base64.NO_WRAP); - promise.resolve(data); - } else if ("data-uri".equals(result)) { - final ByteArrayOutputStream os = new ByteArrayOutputStream(); - captureView(view, os); - final byte[] bytes = os.toByteArray(); - String data = Base64.encodeToString(bytes, Base64.NO_WRAP); - // correct the extension if JPG - String imageFormat = extension; - if ("jpg".equals(extension)) { - imageFormat = "jpeg"; - } - data = "data:image/"+imageFormat+";base64," + data; - promise.resolve(data); + final ReusableByteArrayOutputStream stream = new ReusableByteArrayOutputStream(outputBuffer); + stream.setSize(proposeSize(view)); + outputBuffer = stream.innerBuffer(); + + if (Results.TEMP_FILE.equals(result) && Formats.RAW == this.format) { + saveToRawFileOnDevice(view); + } else if (Results.TEMP_FILE.equals(result) && Formats.RAW != this.format) { + saveToTempFileOnDevice(view); + } else if (Results.BASE_64.equals(result) || Results.ZIP_BASE_64.equals(result)) { + saveToBase64String(view); + } else if (Results.DATA_URI.equals(result)) { + saveToDataUriString(view); } - } catch (Exception e) { - e.printStackTrace(); + } catch (final Throwable ignored) { promise.reject(ERROR_UNABLE_TO_SNAPSHOT, "Failed to capture view snapshot"); } } + //endregion + + //region Implementation + private void saveToTempFileOnDevice(@NonNull final View view) throws IOException { + final FileOutputStream fos = new FileOutputStream(output); + captureView(view, fos); + + promise.resolve(Uri.fromFile(output).toString()); + } + + private void saveToRawFileOnDevice(@NonNull final View view) throws IOException { + final String uri = Uri.fromFile(output).toString(); + + final FileOutputStream fos = new FileOutputStream(output); + final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer); + final Point size = captureView(view, os); + + // in case of buffer grow that will be a new array with bigger size + outputBuffer = os.innerBuffer(); + final int length = os.size(); + final String resolution = String.format(Locale.US, "%d:%d|", size.x, size.y); + + fos.write(resolution.getBytes(Charset.forName("US-ASCII"))); + fos.write(outputBuffer, 0, length); + fos.close(); + + promise.resolve(uri); + } + + private void saveToDataUriString(@NonNull final View view) throws IOException { + final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer); + captureView(view, os); + + outputBuffer = os.innerBuffer(); + final int length = os.size(); - private List getAllChildren(View v) { + final String data = Base64.encodeToString(outputBuffer, 0, length, Base64.NO_WRAP); + // correct the extension if JPG + final String imageFormat = "jpg".equals(extension) ? "jpeg" : extension; + + promise.resolve("data:image/" + imageFormat + ";base64," + data); + } + + private void saveToBase64String(@NonNull final View view) throws IOException { + final boolean isRaw = Formats.RAW == this.format; + final boolean isZippedBase64 = Results.ZIP_BASE_64.equals(this.result); + + final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer); + final Point size = captureView(view, os); + + // in case of buffer grow that will be a new array with bigger size + outputBuffer = os.innerBuffer(); + final int length = os.size(); + final String resolution = String.format(Locale.US, "%d:%d|", size.x, size.y); + final String header = (isRaw ? resolution : ""); + final String data; + + if (isZippedBase64) { + final Deflater deflater = new Deflater(); + deflater.setInput(outputBuffer, 0, length); + deflater.finish(); + + final ReusableByteArrayOutputStream zipped = new ReusableByteArrayOutputStream(new byte[32]); + byte[] buffer = new byte[1024]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); // returns the generated code... index + zipped.write(buffer, 0, count); + } + + data = header + Base64.encodeToString(zipped.innerBuffer(), 0, zipped.size(), Base64.NO_WRAP); + } else { + data = header + Base64.encodeToString(outputBuffer, 0, length, Base64.NO_WRAP); + } + + promise.resolve(data); + } + + @NonNull + private List getAllChildren(@NonNull final View v) { if (!(v instanceof ViewGroup)) { - ArrayList viewArrayList = new ArrayList(); + final ArrayList viewArrayList = new ArrayList<>(); viewArrayList.add(v); + return viewArrayList; } - ArrayList result = new ArrayList(); + final ArrayList result = new ArrayList<>(); ViewGroup viewGroup = (ViewGroup) v; for (int i = 0; i < viewGroup.getChildCount(); i++) { - View child = viewGroup.getChildAt(i); //Do not add any parents, just add child elements result.addAll(getAllChildren(child)); } + return result; } /** - * Screenshot a view and return the captured bitmap. - * @param view the view to capture - * @return the screenshot or null if it failed. + * Wrap {@link #captureViewImpl(View, OutputStream)} call and on end close output stream. */ - private void captureView(View view, OutputStream os) throws IOException { + private Point captureView(@NonNull final View view, @NonNull final OutputStream os) throws IOException { try { - captureViewImpl(view, os); + return captureViewImpl(view, os); } finally { os.close(); } } - private void captureViewImpl(View view, OutputStream os) { + /** + * Screenshot a view and return the captured bitmap. + * + * @param view the view to capture + * @return screenshot resolution, Width * Height + */ + private Point captureViewImpl(@NonNull final View view, @NonNull final OutputStream os) { int w = view.getWidth(); int h = view.getHeight(); @@ -161,47 +312,224 @@ private void captureViewImpl(View view, OutputStream os) { //evaluate real height if (snapshotContentContainer) { - h=0; - ScrollView scrollView = (ScrollView)view; + h = 0; + ScrollView scrollView = (ScrollView) view; for (int i = 0; i < scrollView.getChildCount(); i++) { h += scrollView.getChildAt(i).getHeight(); } } - Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - Bitmap childBitmapBuffer; - Canvas c = new Canvas(bitmap); + + final Point resolution = new Point(w, h); + Bitmap bitmap = getBitmapForScreenshot(w, h); + + final Canvas c = new Canvas(bitmap); view.draw(c); //after view is drawn, go through children - List childrenList = getAllChildren(view); - - for (View child : childrenList) { - if(child instanceof TextureView) { - ((TextureView) child).setOpaque(false); - childBitmapBuffer = ((TextureView) child).getBitmap(child.getWidth(), child.getHeight()); - if (childBitmapBuffer != null) { - int left = child.getLeft(); - int top = child.getTop(); - View parentElem = (View)child.getParent(); - while (parentElem != null) { - if (parentElem == view) { - break; - } - left += parentElem.getLeft(); - top += parentElem.getTop(); - parentElem = (View)parentElem.getParent(); - } - c.drawBitmap(childBitmapBuffer, left + child.getPaddingLeft(), top + child.getPaddingTop(), null); + final List childrenList = getAllChildren(view); + + for (final View child : childrenList) { + // skip any child that we don't know how to process + if (!(child instanceof TextureView)) continue; + // skip all invisible to user child views + if (child.getVisibility() != View.VISIBLE) continue; + + final TextureView tvChild = (TextureView) child; + tvChild.setOpaque(false); + + final Point offsets = getParentOffsets(view, child); + final int left = child.getLeft() + child.getPaddingLeft() + offsets.x; + final int top = child.getTop() + child.getPaddingTop() + offsets.y; + final int childWidth = child.getWidth(); + final int childHeight = child.getHeight(); + final Rect source = new Rect(0, 0, childWidth, childHeight); + final RectF destination = new RectF(left, top, left + childWidth, top + childHeight); + + // get re-usable bitmap + final Bitmap childBitmapBuffer = tvChild.getBitmap(getBitmapForScreenshot(child.getWidth(), child.getHeight())); + + c.save(); + c.setMatrix(concatMatrix(view, child)); + // due to re-use of bitmaps for screenshot, we can get bitmap that is bigger in size than requested + c.drawBitmap(childBitmapBuffer, source, destination, null); + c.restore(); + recycleBitmap(childBitmapBuffer); + } + + if (width != null && height != null && (width != w || height != h)) { + final Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, width, height, true); + recycleBitmap(bitmap); + + bitmap = scaledBitmap; + } + + // special case, just save RAW ARGB array without any compression + if (Formats.RAW == this.format && os instanceof ReusableByteArrayOutputStream) { + final int total = w * h * ARGB_SIZE; + final ReusableByteArrayOutputStream rbaos = cast(os); + bitmap.copyPixelsToBuffer(rbaos.asBuffer(total)); + rbaos.setSize(total); + } else { + final Bitmap.CompressFormat cf = Formats.mapping[this.format]; + + bitmap.compress(cf, (int) (100.0 * quality), os); + } + + recycleBitmap(bitmap); + + return resolution; // return image width and height + } + + /** Concat all the transformation matrix's from child to parent. */ + @NonNull + private Matrix concatMatrix(@NonNull final View view, @NonNull final View child){ + final Matrix transform = new Matrix(); + + View iterator = child; + do { + + final Matrix m = iterator.getMatrix(); + transform.preConcat(m); + + iterator = (View)iterator.getParent(); + } while( iterator != view ); + + return transform; + } + + @NonNull + private Point getParentOffsets(@NonNull final View view, @NonNull final View child) { + int left = 0; + int top = 0; + + View parentElem = (View) child.getParent(); + while (parentElem != null) { + if (parentElem == view) break; + + left += parentElem.getLeft(); + top += parentElem.getTop(); + parentElem = (View) parentElem.getParent(); + } + + return new Point(left, top); + } + + @SuppressWarnings("unchecked") + private static T cast(final A instance) { + return (T) instance; + } + //endregion + + //region Cache re-usable bitmaps + /** + * Synchronization guard. + */ + private static final Object guardBitmaps = new Object(); + /** + * Reusable bitmaps for screenshots. + */ + private static final Set weakBitmaps = Collections.newSetFromMap(new WeakHashMap()); + + /** + * Propose allocation size of the array output stream. + */ + private static int proposeSize(@NonNull final View view) { + final int w = view.getWidth(); + final int h = view.getHeight(); + + return Math.min(w * h * ARGB_SIZE, 32); + } + + /** + * Return bitmap to set of available. + */ + private static void recycleBitmap(@NonNull final Bitmap bitmap) { + synchronized (guardBitmaps) { + weakBitmaps.add(bitmap); + } + } + + /** + * Try to find a bitmap for screenshot in reusabel set and if not found create a new one. + */ + @NonNull + private static Bitmap getBitmapForScreenshot(final int width, final int height) { + synchronized (guardBitmaps) { + for (final Bitmap bmp : weakBitmaps) { + if (bmp.getWidth() * bmp.getHeight() <= width * height) { + weakBitmaps.remove(bmp); + bmp.eraseColor(Color.TRANSPARENT); + return bmp; } } } - if (width != null && height != null && (width != w || height != h)) { - bitmap = Bitmap.createScaledBitmap(bitmap, width, height, true); + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + } + //endregion + + //region Nested declarations + + /** + * Stream that can re-use pre-allocated buffer. + */ + @SuppressWarnings("WeakerAccess") + public static class ReusableByteArrayOutputStream extends ByteArrayOutputStream { + private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; + + public ReusableByteArrayOutputStream(@NonNull final byte[] buffer) { + super(0); + + this.buf = buffer; + } + + /** + * Get access to inner buffer without any memory copy operations. + */ + public byte[] innerBuffer() { + return this.buf; + } + + @NonNull + public ByteBuffer asBuffer(final int size) { + if (this.buf.length < size) { + grow(size); + } + + return ByteBuffer.wrap(this.buf); } - if (bitmap == null) { - throw new RuntimeException("Impossible to snapshot the view"); + + public void setSize(final int size) { + this.count = size; + } + + /** + * Increases the capacity to ensure that it can hold at least the + * number of elements specified by the minimum capacity argument. + * + * @param minCapacity the desired minimum capacity + */ + protected void grow(int minCapacity) { + // overflow-conscious code + int oldCapacity = buf.length; + int newCapacity = oldCapacity << 1; + if (newCapacity - minCapacity < 0) + newCapacity = minCapacity; + if (newCapacity - MAX_ARRAY_SIZE > 0) + newCapacity = hugeCapacity(minCapacity); + buf = Arrays.copyOf(buf, newCapacity); + } + + protected static int hugeCapacity(int minCapacity) { + if (minCapacity < 0) // overflow + throw new OutOfMemoryError(); + + return (minCapacity > MAX_ARRAY_SIZE) ? + Integer.MAX_VALUE : + MAX_ARRAY_SIZE; } - bitmap.compress(format, (int)(100.0 * quality), os); + } + //endregion + } diff --git a/example/App.js b/example/App.js index b60fcbb1..63f98f3d 100644 --- a/example/App.js +++ b/example/App.js @@ -9,7 +9,8 @@ import { TextInput, Picker, Slider, - WebView + WebView, + ART } from "react-native"; import SvgUri from "react-native-svg-uri"; import omit from "lodash/omit"; @@ -148,6 +149,8 @@ export default class App extends Component { /> + + @@ -169,6 +172,7 @@ export default class App extends Component { + @@ -239,6 +243,7 @@ export default class App extends Component { > + @@ -258,6 +263,24 @@ export default class App extends Component { Experimental Stuff + + + Transform + + Sample Text + + + + + // For each separate APK per architecture, set a unique version code as described here: // http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits - def versionCodes = ["armeabi-v7a":1, "x86":2] + def versionCodes = ["armeabi-v7a": 1, "x86": 2] def abi = output.getFilter(OutputFile.ABI) if (abi != null) { // null for the universal-debug, universal-release variants output.versionCodeOverride = @@ -125,11 +131,23 @@ android { } } +repositories { + google() + jcenter() + mavenLocal() + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url "${project.projectDir}/../node_modules/react-native/android" + } +} + dependencies { compile project(':react-native-svg') compile fileTree(dir: "libs", include: ["*.jar"]) - compile "com.android.support:appcompat-v7:23.0.1" + + compile "com.android.support:appcompat-v7:27.+" compile "com.facebook.react:react-native:+" // From node_modules + compile project(':react-native-view-shot') compile project(':gl-react-native') compile project(':react-native-maps') diff --git a/example/android/build.gradle b/example/android/build.gradle index d9b60406..217071cf 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -2,19 +2,34 @@ buildscript { repositories { + google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.3' + classpath 'com.android.tools.build:gradle:3.1.4' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } +// This will apply compileSdkVersion and buildToolsVersion to any android modules +subprojects { subproject -> + afterEvaluate { project -> + if (!project.name.equalsIgnoreCase("app") && project.hasProperty("android")) { + android { + compileSdkVersion 27 + buildToolsVersion '28.0.3' + } + } + } +} + + allprojects { repositories { mavenLocal() + google() jcenter() maven { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm @@ -22,3 +37,17 @@ allprojects { } } } + + +subprojects { + configurations.all { + resolutionStrategy { + eachDependency { details -> + /* Override by group name */ + switch (details.requested.group) { + case 'com.android.support': details.useVersion '27.+'; break + } + } + } + } +} \ No newline at end of file diff --git a/example/android/gradle/wrapper/gradle-wrapper.jar b/example/android/gradle/wrapper/gradle-wrapper.jar index b5166dad..f6b961fd 100644 Binary files a/example/android/gradle/wrapper/gradle-wrapper.jar and b/example/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index cac449fa..bdc7842b 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Sep 12 09:47:32 CEST 2017 +#Tue Oct 09 08:59:03 CEST 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip diff --git a/example/android/gradlew b/example/android/gradlew index 91a7e269..cccdd3d5 100755 --- a/example/android/gradlew +++ b/example/android/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -6,20 +6,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +48,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,31 +59,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# For Cygwin, ensure paths are in UNIX format before anything is touched. -if $cygwin ; then - [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` -fi - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >&- -APP_HOME="`pwd -P`" -cd "$SAVED" >&- - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -90,7 +89,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -114,6 +113,7 @@ fi if $cygwin ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` @@ -154,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/example/android/gradlew.bat b/example/android/gradlew.bat index aec99730..e95643d6 100644 --- a/example/android/gradlew.bat +++ b/example/android/gradlew.bat @@ -8,14 +8,14 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -46,10 +46,9 @@ echo location of your Java installation. goto fail :init -@rem Get command-line arguments, handling Windowz variants +@rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args :win9xME_args @rem Slurp the command line arguments. @@ -60,11 +59,6 @@ set _SKIP=2 if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ :execute @rem Setup the command line diff --git a/example/android/settings.gradle b/example/android/settings.gradle index ed7e4b35..34b836d7 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -1,8 +1,9 @@ rootProject.name = 'ViewShotExample' +include ':app' + include ':react-native-svg' project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') -include ':app' include ':react-native-view-shot' project(':react-native-view-shot').projectDir = new File(rootProject.projectDir, '../../android') diff --git a/src/index.d.ts b/src/index.d.ts index ac73bd39..a79eee03 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -23,9 +23,9 @@ declare module 'react-native-view-shot' { */ height?: number; /** - * either png or jpg or webm (Android). Defaults to png. + * either png or jpg or webm (Android). Defaults to png. raw is a ARGB array of image pixels. */ - format?: 'jpg' | 'png' | 'webm'; + format?: 'jpg' | 'png' | 'webm' | 'raw'; /** * the quality. 0.0 - 1.0 (default). (only available on lossy formats like jpg) */ @@ -36,8 +36,9 @@ declare module 'react-native-view-shot' { " - base64": encode as base64 and returns the raw string. Use only with small images as this may result of * lags (the string is sent over the bridge). N.B. This is not a data uri, use data-uri instead. " - data-uri": same as base64 but also includes the Data URI scheme header. + " - zip-base64: compress data with zip deflate algorithm and than convert to base64 and return as a raw string." */ - result?: 'tmpfile' | 'base64' | 'data-uri'; + result?: 'tmpfile' | 'base64' | 'data-uri' | 'zip-base64'; /** * if true and when view is a ScrollView, the "content container" height will be evaluated instead of the * container height. diff --git a/src/index.js b/src/index.js index 756e96e1..113d8e58 100644 --- a/src/index.js +++ b/src/index.js @@ -8,9 +8,9 @@ const neverEndingPromise = new Promise(() => {}); type Options = { width?: number, height?: number, - format: "png" | "jpg" | "webm", + format: "png" | "jpg" | "webm" | "raw", quality: number, - result: "tmpfile" | "base64" | "data-uri", + result: "tmpfile" | "base64" | "data-uri" | "zip-base64", snapshotContentContainer: boolean }; @@ -21,10 +21,12 @@ if (!RNViewShot) { } const acceptedFormats = ["png", "jpg"].concat( - Platform.OS === "android" ? ["webm"] : [] + Platform.OS === "android" ? ["webm", "raw"] : [] ); -const acceptedResults = ["tmpfile", "base64", "data-uri"]; +const acceptedResults = ["tmpfile", "base64", "data-uri"].concat( + Platform.OS === "android" ? ["zip-base64"] : [] +); const defaultOptions = { format: "png", @@ -70,13 +72,13 @@ function validateOptions( if (acceptedFormats.indexOf(options.format) === -1) { options.format = defaultOptions.format; errors.push( - "option format is not in valid formats: " + acceptedFormats.join(" | ") + "option format '" + options.format + "' is not in valid formats: " + acceptedFormats.join(" | ") ); } if (acceptedResults.indexOf(options.result) === -1) { options.result = defaultOptions.result; errors.push( - "option result is not in valid formats: " + acceptedResults.join(" | ") + "option result '" + options.result + "' is not in valid formats: " + acceptedResults.join(" | ") ); } return { options, errors };