ViewShot.java 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. package fr.greweb.reactnativeviewshot;
  2. import android.app.Activity;
  3. import android.graphics.Bitmap;
  4. import android.graphics.Canvas;
  5. import android.graphics.Color;
  6. import android.graphics.Matrix;
  7. import android.graphics.Paint;
  8. import android.graphics.Point;
  9. import android.net.Uri;
  10. import android.support.annotation.IntDef;
  11. import android.support.annotation.NonNull;
  12. import android.support.annotation.StringDef;
  13. import android.util.Base64;
  14. import android.util.Log;
  15. import android.view.TextureView;
  16. import android.view.View;
  17. import android.view.ViewGroup;
  18. import android.widget.ScrollView;
  19. import com.facebook.react.bridge.Promise;
  20. import com.facebook.react.bridge.ReactApplicationContext;
  21. import com.facebook.react.uimanager.NativeViewHierarchyManager;
  22. import com.facebook.react.uimanager.UIBlock;
  23. import java.io.ByteArrayOutputStream;
  24. import java.io.File;
  25. import java.io.FileOutputStream;
  26. import java.io.IOException;
  27. import java.io.OutputStream;
  28. import java.nio.ByteBuffer;
  29. import java.nio.charset.Charset;
  30. import java.util.ArrayList;
  31. import java.util.Arrays;
  32. import java.util.Collections;
  33. import java.util.LinkedList;
  34. import java.util.List;
  35. import java.util.Locale;
  36. import java.util.Set;
  37. import java.util.WeakHashMap;
  38. import java.util.zip.Deflater;
  39. import javax.annotation.Nullable;
  40. import static android.view.View.VISIBLE;
  41. /**
  42. * Snapshot utility class allow to screenshot a view.
  43. */
  44. public class ViewShot implements UIBlock {
  45. //region Constants
  46. /**
  47. * Tag fort Class logs.
  48. */
  49. private static final String TAG = ViewShot.class.getSimpleName();
  50. /**
  51. * Error code that we return to RN.
  52. */
  53. public static final String ERROR_UNABLE_TO_SNAPSHOT = "E_UNABLE_TO_SNAPSHOT";
  54. /**
  55. * pre-allocated output stream size for screenshot. In real life example it will eb around 7Mb.
  56. */
  57. private static final int PREALLOCATE_SIZE = 64 * 1024;
  58. /**
  59. * ARGB size in bytes.
  60. */
  61. private static final int ARGB_SIZE = 4;
  62. @SuppressWarnings("WeakerAccess")
  63. @IntDef({Formats.JPEG, Formats.PNG, Formats.WEBP, Formats.RAW})
  64. public @interface Formats {
  65. int JPEG = 0; // Bitmap.CompressFormat.JPEG.ordinal();
  66. int PNG = 1; // Bitmap.CompressFormat.PNG.ordinal();
  67. int WEBP = 2; // Bitmap.CompressFormat.WEBP.ordinal();
  68. int RAW = -1;
  69. Bitmap.CompressFormat[] mapping = {
  70. Bitmap.CompressFormat.JPEG,
  71. Bitmap.CompressFormat.PNG,
  72. Bitmap.CompressFormat.WEBP
  73. };
  74. }
  75. /**
  76. * Supported Output results.
  77. */
  78. @StringDef({Results.BASE_64, Results.DATA_URI, Results.TEMP_FILE, Results.ZIP_BASE_64})
  79. public @interface Results {
  80. /**
  81. * Save screenshot as temp file on device.
  82. */
  83. String TEMP_FILE = "tmpfile";
  84. /**
  85. * Base 64 encoded image.
  86. */
  87. String BASE_64 = "base64";
  88. /**
  89. * Zipped RAW image in base 64 encoding.
  90. */
  91. String ZIP_BASE_64 = "zip-base64";
  92. /**
  93. * Base64 data uri.
  94. */
  95. String DATA_URI = "data-uri";
  96. }
  97. //endregion
  98. //region Static members
  99. /**
  100. * Image output buffer used as a source for base64 encoding
  101. */
  102. private static byte[] outputBuffer = new byte[PREALLOCATE_SIZE];
  103. //endregion
  104. //region Class members
  105. private final int tag;
  106. private final String extension;
  107. @Formats
  108. private final int format;
  109. private final double quality;
  110. private final Integer width;
  111. private final Integer height;
  112. private final File output;
  113. @Results
  114. private final String result;
  115. private final Promise promise;
  116. private final Boolean snapshotContentContainer;
  117. @SuppressWarnings({"unused", "FieldCanBeLocal"})
  118. private final ReactApplicationContext reactContext;
  119. private final Activity currentActivity;
  120. //endregion
  121. //region Constructors
  122. @SuppressWarnings("WeakerAccess")
  123. public ViewShot(
  124. final int tag,
  125. final String extension,
  126. @Formats final int format,
  127. final double quality,
  128. @Nullable Integer width,
  129. @Nullable Integer height,
  130. final File output,
  131. @Results final String result,
  132. final Boolean snapshotContentContainer,
  133. final ReactApplicationContext reactContext,
  134. final Activity currentActivity,
  135. final Promise promise) {
  136. this.tag = tag;
  137. this.extension = extension;
  138. this.format = format;
  139. this.quality = quality;
  140. this.width = width;
  141. this.height = height;
  142. this.output = output;
  143. this.result = result;
  144. this.snapshotContentContainer = snapshotContentContainer;
  145. this.reactContext = reactContext;
  146. this.currentActivity = currentActivity;
  147. this.promise = promise;
  148. }
  149. //endregion
  150. //region Overrides
  151. @Override
  152. public void execute(NativeViewHierarchyManager nativeViewHierarchyManager) {
  153. final View view;
  154. if (tag == -1) {
  155. view = currentActivity.getWindow().getDecorView().findViewById(android.R.id.content);
  156. } else {
  157. view = nativeViewHierarchyManager.resolveView(tag);
  158. }
  159. if (view == null) {
  160. Log.e(TAG, "No view found with reactTag: " + tag, new AssertionError());
  161. promise.reject(ERROR_UNABLE_TO_SNAPSHOT, "No view found with reactTag: " + tag);
  162. return;
  163. }
  164. try {
  165. final ReusableByteArrayOutputStream stream = new ReusableByteArrayOutputStream(outputBuffer);
  166. stream.setSize(proposeSize(view));
  167. outputBuffer = stream.innerBuffer();
  168. if (Results.TEMP_FILE.equals(result) && Formats.RAW == this.format) {
  169. saveToRawFileOnDevice(view);
  170. } else if (Results.TEMP_FILE.equals(result) && Formats.RAW != this.format) {
  171. saveToTempFileOnDevice(view);
  172. } else if (Results.BASE_64.equals(result) || Results.ZIP_BASE_64.equals(result)) {
  173. saveToBase64String(view);
  174. } else if (Results.DATA_URI.equals(result)) {
  175. saveToDataUriString(view);
  176. }
  177. } catch (final Throwable ex) {
  178. Log.e(TAG, "Failed to capture view snapshot", ex);
  179. promise.reject(ERROR_UNABLE_TO_SNAPSHOT, "Failed to capture view snapshot");
  180. }
  181. }
  182. //endregion
  183. //region Implementation
  184. private void saveToTempFileOnDevice(@NonNull final View view) throws IOException {
  185. final FileOutputStream fos = new FileOutputStream(output);
  186. captureView(view, fos);
  187. promise.resolve(Uri.fromFile(output).toString());
  188. }
  189. private void saveToRawFileOnDevice(@NonNull final View view) throws IOException {
  190. final String uri = Uri.fromFile(output).toString();
  191. final FileOutputStream fos = new FileOutputStream(output);
  192. final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer);
  193. final Point size = captureView(view, os);
  194. // in case of buffer grow that will be a new array with bigger size
  195. outputBuffer = os.innerBuffer();
  196. final int length = os.size();
  197. final String resolution = String.format(Locale.US, "%d:%d|", size.x, size.y);
  198. fos.write(resolution.getBytes(Charset.forName("US-ASCII")));
  199. fos.write(outputBuffer, 0, length);
  200. fos.close();
  201. promise.resolve(uri);
  202. }
  203. private void saveToDataUriString(@NonNull final View view) throws IOException {
  204. final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer);
  205. captureView(view, os);
  206. outputBuffer = os.innerBuffer();
  207. final int length = os.size();
  208. final String data = Base64.encodeToString(outputBuffer, 0, length, Base64.NO_WRAP);
  209. // correct the extension if JPG
  210. final String imageFormat = "jpg".equals(extension) ? "jpeg" : extension;
  211. promise.resolve("data:image/" + imageFormat + ";base64," + data);
  212. }
  213. private void saveToBase64String(@NonNull final View view) throws IOException {
  214. final boolean isRaw = Formats.RAW == this.format;
  215. final boolean isZippedBase64 = Results.ZIP_BASE_64.equals(this.result);
  216. final ReusableByteArrayOutputStream os = new ReusableByteArrayOutputStream(outputBuffer);
  217. final Point size = captureView(view, os);
  218. // in case of buffer grow that will be a new array with bigger size
  219. outputBuffer = os.innerBuffer();
  220. final int length = os.size();
  221. final String resolution = String.format(Locale.US, "%d:%d|", size.x, size.y);
  222. final String header = (isRaw ? resolution : "");
  223. final String data;
  224. if (isZippedBase64) {
  225. final Deflater deflater = new Deflater();
  226. deflater.setInput(outputBuffer, 0, length);
  227. deflater.finish();
  228. final ReusableByteArrayOutputStream zipped = new ReusableByteArrayOutputStream(new byte[32]);
  229. byte[] buffer = new byte[1024];
  230. while (!deflater.finished()) {
  231. int count = deflater.deflate(buffer); // returns the generated code... index
  232. zipped.write(buffer, 0, count);
  233. }
  234. data = header + Base64.encodeToString(zipped.innerBuffer(), 0, zipped.size(), Base64.NO_WRAP);
  235. } else {
  236. data = header + Base64.encodeToString(outputBuffer, 0, length, Base64.NO_WRAP);
  237. }
  238. promise.resolve(data);
  239. }
  240. @NonNull
  241. private List<View> getAllChildren(@NonNull final View v) {
  242. if (!(v instanceof ViewGroup)) {
  243. final ArrayList<View> viewArrayList = new ArrayList<>();
  244. viewArrayList.add(v);
  245. return viewArrayList;
  246. }
  247. final ArrayList<View> result = new ArrayList<>();
  248. ViewGroup viewGroup = (ViewGroup) v;
  249. for (int i = 0; i < viewGroup.getChildCount(); i++) {
  250. View child = viewGroup.getChildAt(i);
  251. //Do not add any parents, just add child elements
  252. result.addAll(getAllChildren(child));
  253. }
  254. return result;
  255. }
  256. /**
  257. * Wrap {@link #captureViewImpl(View, OutputStream)} call and on end close output stream.
  258. */
  259. private Point captureView(@NonNull final View view, @NonNull final OutputStream os) throws IOException {
  260. try {
  261. DebugViews.longDebug(TAG, DebugViews.logViewHierarchy(this.currentActivity));
  262. return captureViewImpl(view, os);
  263. } finally {
  264. os.close();
  265. }
  266. }
  267. /**
  268. * Screenshot a view and return the captured bitmap.
  269. *
  270. * @param view the view to capture
  271. * @return screenshot resolution, Width * Height
  272. */
  273. private Point captureViewImpl(@NonNull final View view, @NonNull final OutputStream os) {
  274. int w = view.getWidth();
  275. int h = view.getHeight();
  276. if (w <= 0 || h <= 0) {
  277. throw new RuntimeException("Impossible to snapshot the view: view is invalid");
  278. }
  279. // evaluate real height
  280. if (snapshotContentContainer) {
  281. h = 0;
  282. ScrollView scrollView = (ScrollView) view;
  283. for (int i = 0; i < scrollView.getChildCount(); i++) {
  284. h += scrollView.getChildAt(i).getHeight();
  285. }
  286. }
  287. final Point resolution = new Point(w, h);
  288. Bitmap bitmap = getBitmapForScreenshot(w, h);
  289. final Paint paint = new Paint();
  290. paint.setAntiAlias(true);
  291. paint.setFilterBitmap(true);
  292. paint.setDither(true);
  293. // Uncomment next line if you want to wait attached android studio debugger:
  294. // Debug.waitForDebugger();
  295. final Canvas c = new Canvas(bitmap);
  296. view.draw(c);
  297. //after view is drawn, go through children
  298. final List<View> childrenList = getAllChildren(view);
  299. for (final View child : childrenList) {
  300. // skip any child that we don't know how to process
  301. if (!(child instanceof TextureView)) continue;
  302. // skip all invisible to user child views
  303. if (child.getVisibility() != VISIBLE) continue;
  304. final TextureView tvChild = (TextureView) child;
  305. tvChild.setOpaque(false); // <-- switch off background fill
  306. // NOTE (olku): get re-usable bitmap. TextureView should use bitmaps with matching size,
  307. // otherwise content of the TextureView will be scaled to provided bitmap dimensions
  308. final Bitmap childBitmapBuffer = tvChild.getBitmap(getExactBitmapForScreenshot(child.getWidth(), child.getHeight()));
  309. final int countCanvasSave = c.save();
  310. applyTransformations(c, view, child);
  311. // due to re-use of bitmaps for screenshot, we can get bitmap that is bigger in size than requested
  312. c.drawBitmap(childBitmapBuffer, 0, 0, paint);
  313. c.restoreToCount(countCanvasSave);
  314. recycleBitmap(childBitmapBuffer);
  315. }
  316. if (width != null && height != null && (width != w || height != h)) {
  317. final Bitmap scaledBitmap = Bitmap.createScaledBitmap(bitmap, width, height, true);
  318. recycleBitmap(bitmap);
  319. bitmap = scaledBitmap;
  320. }
  321. // special case, just save RAW ARGB array without any compression
  322. if (Formats.RAW == this.format && os instanceof ReusableByteArrayOutputStream) {
  323. final int total = w * h * ARGB_SIZE;
  324. final ReusableByteArrayOutputStream rbaos = cast(os);
  325. bitmap.copyPixelsToBuffer(rbaos.asBuffer(total));
  326. rbaos.setSize(total);
  327. } else {
  328. final Bitmap.CompressFormat cf = Formats.mapping[this.format];
  329. bitmap.compress(cf, (int) (100.0 * quality), os);
  330. }
  331. recycleBitmap(bitmap);
  332. return resolution; // return image width and height
  333. }
  334. /**
  335. * Concat all the transformation matrix's from parent to child.
  336. */
  337. @NonNull
  338. @SuppressWarnings("UnusedReturnValue")
  339. private Matrix applyTransformations(final Canvas c, @NonNull final View root, @NonNull final View child) {
  340. final Matrix transform = new Matrix();
  341. final LinkedList<View> ms = new LinkedList<>();
  342. // find all parents of the child view
  343. View iterator = child;
  344. do {
  345. ms.add(iterator);
  346. iterator = (View) iterator.getParent();
  347. } while (iterator != root);
  348. // apply transformations from parent --> child order
  349. Collections.reverse(ms);
  350. for (final View v : ms) {
  351. c.save();
  352. // apply each view transformations, so each child will be affected by them
  353. final float dx = v.getLeft() + ((v != child) ? v.getPaddingLeft() : 0) + v.getTranslationX();
  354. final float dy = v.getTop() + ((v != child) ? v.getPaddingTop() : 0) + v.getTranslationY();
  355. c.translate(dx, dy);
  356. c.rotate(v.getRotation(), v.getPivotX(), v.getPivotY());
  357. c.scale(v.getScaleX(), v.getScaleY());
  358. // compute the matrix just for any future use
  359. transform.postTranslate(dx, dy);
  360. transform.postRotate(v.getRotation(), v.getPivotX(), v.getPivotY());
  361. transform.postScale(v.getScaleX(), v.getScaleY());
  362. }
  363. return transform;
  364. }
  365. @SuppressWarnings("unchecked")
  366. private static <T extends A, A> T cast(final A instance) {
  367. return (T) instance;
  368. }
  369. //endregion
  370. //region Cache re-usable bitmaps
  371. /**
  372. * Synchronization guard.
  373. */
  374. private static final Object guardBitmaps = new Object();
  375. /**
  376. * Reusable bitmaps for screenshots.
  377. */
  378. private static final Set<Bitmap> weakBitmaps = Collections.newSetFromMap(new WeakHashMap<Bitmap, Boolean>());
  379. /**
  380. * Propose allocation size of the array output stream.
  381. */
  382. private static int proposeSize(@NonNull final View view) {
  383. final int w = view.getWidth();
  384. final int h = view.getHeight();
  385. return Math.min(w * h * ARGB_SIZE, 32);
  386. }
  387. /**
  388. * Return bitmap to set of available.
  389. */
  390. private static void recycleBitmap(@NonNull final Bitmap bitmap) {
  391. synchronized (guardBitmaps) {
  392. weakBitmaps.add(bitmap);
  393. }
  394. }
  395. /**
  396. * Try to find a bitmap for screenshot in reusable set and if not found create a new one.
  397. */
  398. @NonNull
  399. private static Bitmap getBitmapForScreenshot(final int width, final int height) {
  400. synchronized (guardBitmaps) {
  401. for (final Bitmap bmp : weakBitmaps) {
  402. if (bmp.getWidth() == width && bmp.getHeight() == height) {
  403. weakBitmaps.remove(bmp);
  404. bmp.eraseColor(Color.TRANSPARENT);
  405. return bmp;
  406. }
  407. }
  408. }
  409. return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
  410. }
  411. /**
  412. * Try to find a bitmap with exact width and height for screenshot in reusable set and if
  413. * not found create a new one.
  414. */
  415. @NonNull
  416. private static Bitmap getExactBitmapForScreenshot(final int width, final int height) {
  417. synchronized (guardBitmaps) {
  418. for (final Bitmap bmp : weakBitmaps) {
  419. if (bmp.getWidth() == width && bmp.getHeight() == height) {
  420. weakBitmaps.remove(bmp);
  421. bmp.eraseColor(Color.TRANSPARENT);
  422. return bmp;
  423. }
  424. }
  425. }
  426. return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
  427. }
  428. //endregion
  429. //region Nested declarations
  430. /**
  431. * Stream that can re-use pre-allocated buffer.
  432. */
  433. @SuppressWarnings("WeakerAccess")
  434. public static class ReusableByteArrayOutputStream extends ByteArrayOutputStream {
  435. private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
  436. public ReusableByteArrayOutputStream(@NonNull final byte[] buffer) {
  437. super(0);
  438. this.buf = buffer;
  439. }
  440. /**
  441. * Get access to inner buffer without any memory copy operations.
  442. */
  443. public byte[] innerBuffer() {
  444. return this.buf;
  445. }
  446. @NonNull
  447. public ByteBuffer asBuffer(final int size) {
  448. if (this.buf.length < size) {
  449. grow(size);
  450. }
  451. return ByteBuffer.wrap(this.buf);
  452. }
  453. public void setSize(final int size) {
  454. this.count = size;
  455. }
  456. /**
  457. * Increases the capacity to ensure that it can hold at least the
  458. * number of elements specified by the minimum capacity argument.
  459. *
  460. * @param minCapacity the desired minimum capacity
  461. */
  462. protected void grow(int minCapacity) {
  463. // overflow-conscious code
  464. int oldCapacity = buf.length;
  465. int newCapacity = oldCapacity << 1;
  466. if (newCapacity - minCapacity < 0)
  467. newCapacity = minCapacity;
  468. if (newCapacity - MAX_ARRAY_SIZE > 0)
  469. newCapacity = hugeCapacity(minCapacity);
  470. buf = Arrays.copyOf(buf, newCapacity);
  471. }
  472. protected static int hugeCapacity(int minCapacity) {
  473. if (minCapacity < 0) // overflow
  474. throw new OutOfMemoryError();
  475. return (minCapacity > MAX_ARRAY_SIZE) ?
  476. Integer.MAX_VALUE :
  477. MAX_ARRAY_SIZE;
  478. }
  479. }
  480. //endregion
  481. }