Skip to content

Commit

Permalink
Support capture SurfaceView on Android (#427)
Browse files Browse the repository at this point in the history
* Add possibility to capture SurfaceView content

* Use PixelCopy to include content of SurfaceView in screenshot

* Disable SurfaceView screenshot by default, but allow to enable it

Co-authored-by: Alexande B <abobrikovich@gmail.com>

* Use main looper for PixelCopy request to avoid blocking

* Create new bitmap on PixelCopy.request for correct view position

* Update react-native-video & example

* Use react-native-video v6 alpha (Use the exoplayer intergration by default)
* Add another video view and enable surface view mode

* Use applyTransformations for draw surface view content

Co-authored-by: Alexande B <abobrikovich@gmail.com>
  • Loading branch information
jhen0409 and CAMOBAP authored Aug 20, 2022
1 parent e7fa41a commit b137f4e
Show file tree
Hide file tree
Showing 10 changed files with 163 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ public void captureRef(int tag, ReadableMap options, Promise promise) {
final String resultStreamFormat = options.getString("result");
final String fileName = options.hasKey("fileName") ? options.getString("fileName") : null;
final Boolean snapshotContentContainer = options.getBoolean("snapshotContentContainer");
final boolean handleGLSurfaceView = options.hasKey("handleGLSurfaceViewOnAndroid") && options.getBoolean("handleGLSurfaceViewOnAndroid");

try {
File outputFile = null;
Expand All @@ -99,7 +100,7 @@ public void captureRef(int tag, ReadableMap options, Promise promise) {
uiManager.addUIBlock(new ViewShot(
tag, extension, imageFormat, quality,
scaleWidth, scaleHeight, outputFile, resultStreamFormat,
snapshotContentContainer, reactContext, activity, promise)
snapshotContentContainer, reactContext, activity, handleGLSurfaceView, promise)
);
} catch (final Throwable ex) {
Log.e(RNVIEW_SHOT, "Failed to snapshot view tag " + tag, ex);
Expand Down
83 changes: 62 additions & 21 deletions android/src/main/java/fr/greweb/reactnativeviewshot/ViewShot.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
import android.graphics.Paint;
import android.graphics.Point;
import android.net.Uri;

import android.os.Build;
import android.os.Handler;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.StringDef;

import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.PixelCopy;
import android.view.SurfaceView;
import android.view.TextureView;
import android.view.View;
import android.view.ViewGroup;
Expand All @@ -44,6 +48,8 @@
import java.util.Locale;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.zip.Deflater;

import javax.annotation.Nullable;
Expand Down Expand Up @@ -71,6 +77,10 @@ public class ViewShot implements UIBlock, LifecycleEventListener {
* ARGB size in bytes.
*/
private static final int ARGB_SIZE = 4;
/**
* Wait timeout for surface view capture.
*/
private static final int SURFACE_VIEW_READ_PIXELS_TIMEOUT = 5;

private HandlerThread mBgThread;
private Handler mBgHandler;
Expand Down Expand Up @@ -157,6 +167,7 @@ public void run() {
private final Boolean snapshotContentContainer;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final ReactApplicationContext reactContext;
private final boolean handleGLSurfaceView;
private final Activity currentActivity;
//endregion

Expand All @@ -174,6 +185,7 @@ public ViewShot(
final Boolean snapshotContentContainer,
final ReactApplicationContext reactContext,
final Activity currentActivity,
final boolean handleGLSurfaceView,
final Promise promise) {
this.tag = tag;
this.extension = extension;
Expand All @@ -186,6 +198,7 @@ public ViewShot(
this.snapshotContentContainer = snapshotContentContainer;
this.reactContext = reactContext;
this.currentActivity = currentActivity;
this.handleGLSurfaceView = handleGLSurfaceView;
this.promise = promise;

reactContext.addLifecycleEventListener(this);
Expand Down Expand Up @@ -405,26 +418,54 @@ private Point captureViewImpl(@NonNull final View view, @NonNull final OutputStr

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() != VISIBLE) continue;

final TextureView tvChild = (TextureView) child;
tvChild.setOpaque(false); // <-- switch off background fill

// NOTE (olku): get re-usable bitmap. TextureView should use bitmaps with matching size,
// otherwise content of the TextureView will be scaled to provided bitmap dimensions
final Bitmap childBitmapBuffer = tvChild.getBitmap(getExactBitmapForScreenshot(child.getWidth(), child.getHeight()));

final int countCanvasSave = c.save();
applyTransformations(c, view, child);

// due to re-use of bitmaps for screenshot, we can get bitmap that is bigger in size than requested
c.drawBitmap(childBitmapBuffer, 0, 0, paint);

c.restoreToCount(countCanvasSave);
recycleBitmap(childBitmapBuffer);
if (child instanceof TextureView) {
// skip all invisible to user child views
if (child.getVisibility() != VISIBLE) continue;

final TextureView tvChild = (TextureView) child;
tvChild.setOpaque(false); // <-- switch off background fill

// NOTE (olku): get re-usable bitmap. TextureView should use bitmaps with matching size,
// otherwise content of the TextureView will be scaled to provided bitmap dimensions
final Bitmap childBitmapBuffer = tvChild.getBitmap(getExactBitmapForScreenshot(child.getWidth(), child.getHeight()));

final int countCanvasSave = c.save();
applyTransformations(c, view, child);

// due to re-use of bitmaps for screenshot, we can get bitmap that is bigger in size than requested
c.drawBitmap(childBitmapBuffer, 0, 0, paint);

c.restoreToCount(countCanvasSave);
recycleBitmap(childBitmapBuffer);
} else if (child instanceof SurfaceView && handleGLSurfaceView) {
final SurfaceView svChild = (SurfaceView)child;
final CountDownLatch latch = new CountDownLatch(1);

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final Bitmap childBitmapBuffer = getExactBitmapForScreenshot(child.getWidth(), child.getHeight());
try {
PixelCopy.request(svChild, childBitmapBuffer, new PixelCopy.OnPixelCopyFinishedListener() {
@Override
public void onPixelCopyFinished(int copyResult) {
final int countCanvasSave = c.save();
applyTransformations(c, view, child);
c.drawBitmap(childBitmapBuffer, 0, 0, paint);
c.restoreToCount(countCanvasSave);
recycleBitmap(childBitmapBuffer);
latch.countDown();
}
}, new Handler(Looper.getMainLooper()));
latch.await(SURFACE_VIEW_READ_PIXELS_TIMEOUT, TimeUnit.SECONDS);
} catch (Exception e) {
Log.e(TAG, "Cannot PixelCopy for " + svChild, e);
}
} else {
Bitmap cache = svChild.getDrawingCache();
if (cache != null) {
c.drawBitmap(svChild.getDrawingCache(), 0, 0, paint);
}
}
}
}

if (width != null && height != null && (width != w || height != h)) {
Expand Down
16 changes: 8 additions & 8 deletions example/Example/ios/Example.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@

/* Begin PBXBuildFile section */
00E356F31AD99517003FC87E /* ExampleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 00E356F21AD99517003FC87E /* ExampleTests.m */; };
0C80B921A6F3F58F76C31292 /* libPods-Example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-Example.a */; };
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
7699B88040F8A987B510C191 /* libPods-Example-ExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19F6CBCC0A4E27FBF8BF4A61 /* libPods-Example-ExampleTests.a */; };
18B88FE9BEF1140FF52CCEC0 /* libPods-Example-ExampleTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 169C542F436F5321740B320E /* libPods-Example-ExampleTests.a */; };
5988C3FBC6135EB858E1BD70 /* libPods-Example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7AEBA4E17C14A4CB566F0E2B /* libPods-Example.a */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */

Expand All @@ -36,11 +36,11 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Example/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Example/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Example/main.m; sourceTree = "<group>"; };
19F6CBCC0A4E27FBF8BF4A61 /* libPods-Example-ExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Example-ExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
169C542F436F5321740B320E /* libPods-Example-ExampleTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Example-ExampleTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
3B4392A12AC88292D35C810B /* Pods-Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.debug.xcconfig"; path = "Target Support Files/Pods-Example/Pods-Example.debug.xcconfig"; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example.release.xcconfig"; path = "Target Support Files/Pods-Example/Pods-Example.release.xcconfig"; sourceTree = "<group>"; };
5B7EB9410499542E8C5724F5 /* Pods-Example-ExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example-ExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-Example-ExampleTests/Pods-Example-ExampleTests.debug.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-Example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Example.a"; sourceTree = BUILT_PRODUCTS_DIR; };
7AEBA4E17C14A4CB566F0E2B /* libPods-Example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Example.a"; sourceTree = BUILT_PRODUCTS_DIR; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = Example/LaunchScreen.storyboard; sourceTree = "<group>"; };
89C6BE57DB24E9ADA2F236DE /* Pods-Example-ExampleTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Example-ExampleTests.release.xcconfig"; path = "Target Support Files/Pods-Example-ExampleTests/Pods-Example-ExampleTests.release.xcconfig"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
Expand All @@ -51,15 +51,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
7699B88040F8A987B510C191 /* libPods-Example-ExampleTests.a in Frameworks */,
18B88FE9BEF1140FF52CCEC0 /* libPods-Example-ExampleTests.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0C80B921A6F3F58F76C31292 /* libPods-Example.a in Frameworks */,
5988C3FBC6135EB858E1BD70 /* libPods-Example.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -100,8 +100,8 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-Example.a */,
19F6CBCC0A4E27FBF8BF4A61 /* libPods-Example-ExampleTests.a */,
7AEBA4E17C14A4CB566F0E2B /* libPods-Example.a */,
169C542F436F5321740B320E /* libPods-Example-ExampleTests.a */,
);
name = Frameworks;
sourceTree = "<group>";
Expand Down
16 changes: 12 additions & 4 deletions example/Example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ PODS:
- glog (0.3.5)
- libevent (2.1.12)
- OpenSSL-Universal (1.1.1100)
- PromisesObjC (2.1.1)
- PromisesSwift (2.1.1):
- PromisesObjC (= 2.1.1)
- RCT-Folly (2021.06.28.00-v2):
- boost
- DoubleConversion
Expand Down Expand Up @@ -292,10 +295,11 @@ PODS:
- ReactCommon/turbomodule/core
- react-native-slider (4.2.2):
- React-Core
- react-native-video (5.2.0):
- react-native-video (6.0.0-alpha.1):
- React-Core
- react-native-video/Video (= 5.2.0)
- react-native-video/Video (5.2.0):
- react-native-video/Video (= 6.0.0-alpha.1)
- react-native-video/Video (6.0.0-alpha.1):
- PromisesSwift
- React-Core
- react-native-view-shot (3.3.0-next.0):
- React-Core
Expand Down Expand Up @@ -499,6 +503,8 @@ SPEC REPOS:
- fmt
- libevent
- OpenSSL-Universal
- PromisesObjC
- PromisesSwift
- SocketRocket
- YogaKit

Expand Down Expand Up @@ -613,6 +619,8 @@ SPEC CHECKSUMS:
glog: 476ee3e89abb49e07f822b48323c51c57124b572
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
PromisesObjC: ab77feca74fa2823e7af4249b8326368e61014cb
PromisesSwift: 99fddfe4a0ec88a56486644c0da106694c92a604
RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8
RCTRequired: 3e917ea5377751094f38145fdece525aa90545a0
RCTTypeSafety: c43c072a4bd60feb49a9570b0517892b4305c45e
Expand All @@ -629,7 +637,7 @@ SPEC CHECKSUMS:
react-native-maps: 8b8bfada2c86205a7f5a07dd1f92f29b33ea83aa
react-native-safe-area-context: ebf8c413eb8b5f7c392a036a315eb7b46b96845f
react-native-slider: 2f25c919f1dc309b90e2cc8346b8042ecec2102f
react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28
react-native-video: bb6f12a7198db53b261fefb5d609dc77417acc8b
react-native-view-shot: 47106be5202c2554b4f106845616a8a158aae614
react-native-webview: 8ec7ddf9eb4ddcd92b32cee7907efec19a9ec7cb
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
Expand Down
2 changes: 1 addition & 1 deletion example/Example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"react-native-screens": "^3.13.1",
"react-native-svg": "^12.3.0",
"react-native-svg-uri": "^1.2.3",
"react-native-video": "^5.2.0",
"react-native-video": "^6.0.0-alpha.1",
"react-native-view-shot": "^3.3.0-next.0",
"react-native-webview": "^11.18.2"
},
Expand Down
22 changes: 20 additions & 2 deletions example/Example/src/Full.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const App = () => {
const mapViewRef = useRef();
const webviewRef = useRef();
const videoRef = useRef();
const videoSurfaceRef = useRef();
const transformParentRef = useRef();
const transformRef = useRef();
const surfaceRef = useRef();
Expand Down Expand Up @@ -82,8 +83,9 @@ const App = () => {
}, []);

const capture = useCallback(
ref => {
(ref ? captureRef(ref, config) : captureScreen(config))
(ref, options = {}) => {
const opts = {...config, ...options};
(ref ? captureRef(ref, opts) : captureScreen(opts))
.then(res =>
config.result !== 'file'
? res
Expand Down Expand Up @@ -159,6 +161,12 @@ const App = () => {
<Btn label="📷 MapView" onPress={() => capture(mapViewRef)} />
<Btn label="📷 WebView" onPress={() => capture(webviewRef)} />
<Btn label="📷 Video" onPress={() => capture(videoRef)} />
<Btn
label="📷 Video with SurfaceView (Android)"
onPress={() =>
capture(videoRef, {handleGLSurfaceViewOnAndroid: true})
}
/>
<Btn label="📷 Native Screenshot" onPress={() => capture()} />
<Btn
label="📷 Empty View (should crash)"
Expand Down Expand Up @@ -326,10 +334,20 @@ const App = () => {
</View>
<Video
ref={videoRef}
disableFocus // NOTE: https://github.com/react-native-video/react-native-video/issues/2666
style={{width: 300, height: 300}}
source={require('./broadchurch.mp4')}
volume={0}
repeat
/>
<Video
ref={videoSurfaceRef}
disableFocus // NOTE: https://github.com/react-native-video/react-native-video/issues/2666
style={{width: 300, height: 300}}
source={require('./broadchurch.mp4')}
volume={0}
repeat
useTextureView={false} // Use SurfaceView
/>
</View>
</ScrollView>
Expand Down
37 changes: 30 additions & 7 deletions example/Example/src/Video.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,47 @@
//@flow
import React, { useState, useCallback } from 'react';
import { SafeAreaView, Image } from 'react-native';
import React, {useState, useCallback} from 'react';
import {SafeAreaView, Image} from 'react-native';
import ViewShot from 'react-native-view-shot';
import Video from 'react-native-video';
import Desc from './Desc';

const dimension = { width: 300, height: 300 };
const sample = require('./broadchurch.mp4');

const VideoExample = () => {
const [source, setSource] = useState(null);
const onCapture = useCallback(uri => setSource({ uri }), []);
const onCapture = useCallback(uri => setSource({uri}), []);
return (
<SafeAreaView>
<ViewShot onCapture={onCapture} captureMode="continuous" style={dimension}>
<Video style={dimension} source={require('./broadchurch.mp4')} volume={0} repeat />
<ViewShot
options={{handleGLSurfaceViewOnAndroid: true}}
onCapture={onCapture}
captureMode="continuous">
<Video
disableFocus // NOTE: https://github.com/react-native-video/react-native-video/issues/2666
style={{width: 180, height: 90}}
source={sample}
volume={0}
repeat
resizeMode="cover"
/>
<Video
disableFocus // NOTE: https://github.com/react-native-video/react-native-video/issues/2666
style={{width: 180, height: 90}}
source={sample}
volume={0}
repeat
resizeMode="cover"
useTextureView={false} // Use SurfaceView
/>
</ViewShot>

<Desc desc="above is a video and below is a continuous screenshot of it" />

<Image fadeDuration={0} source={source} style={dimension} />
<Image
fadeDuration={0}
source={source}
style={{width: '100%', height: 196}}
/>
</SafeAreaView>
);
};
Expand Down
Loading

0 comments on commit b137f4e

Please sign in to comment.