Skip to content

Commit a7efffc

Browse files
committed
0.3.2 — captureFrame / shareFrame on QuickPoseView ref
Customers on Expo couldn't screenshot the camera + overlay because SurfaceView (Android) and AVCaptureVideoPreviewLayer (iOS) are hardware surfaces that react-native-view-shot can't read. Solve this at the plugin level: - Imperative ref methods captureFrame() and shareFrame(title?) on QuickPoseView. No third-party deps on either platform. - iOS: grab the latest per-frame composite UIImage the SDK already hands us (we now auto-add overlayHasCameraAsBackground(0) internally so the stashed image contains the camera). Write PNG to tmp, expose file URI, or present UIActivityViewController for share. - Android: PixelCopy the SurfaceView into a Bitmap on a HandlerThread, write PNG via a plugin-registered FileProvider (content:// URI), or launch an ACTION_SEND chooser directly. - QuickPoseView on iOS now keeps a reactTag->instance registry so the capture module can find it on both Paper and Fabric without relying on the UIManager viewRegistry (which is empty under Fabric). - example/App.tsx adds a Share Screenshot button.
1 parent 866fc6a commit a7efffc

15 files changed

Lines changed: 372 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,20 @@
22

33
All notable changes to `@quickpose/react-native` will be documented in this file.
44

5+
## [0.3.2] - 2026-04-15
6+
7+
### Added
8+
- `captureFrame()` and `shareFrame(title?)` imperative methods on `QuickPoseView` via ref. `captureFrame()` returns a platform-local URI (`file://` iOS, `content://` Android) to a PNG of the current camera + overlay composite. `shareFrame()` opens the native share sheet in one call. The `example/` app now includes a "Share Screenshot" button.
9+
- `QuickPoseViewRef` type exported for `useRef<QuickPoseViewRef>(null)`.
10+
11+
### Fixed
12+
- Android camera preview is composited with the overlay on a single `SurfaceView`, so `react-native-view-shot` can't capture it. The new native path uses `PixelCopy` + a plugin-scoped `FileProvider`, so apps on Expo / bare RN can share screenshots without a third-party dep.
13+
514
## [0.3.1] - 2026-04-15
615

716
### Added
817
- `QuickPoseThresholdCounter` and `FixedSizeRingBuffer` — JS utilities ported 1:1 from the iOS / Android SDKs so sample code reads the same across platforms.
9-
- New `example-gated-fitness/` sample app demonstrating pose-gated rep counting: counting is paused while the SDK emits pose-check feedback (user not yet in position), so false reps during setup are avoided. Includes a live horizontal progress bar of the fitness measure.
18+
- New `example-counter/` sample app demonstrating pose-gated rep counting: counting is paused while the SDK emits pose-check feedback (user not yet in position), so false reps during setup are avoided. Includes a live horizontal progress bar of the fitness measure.
1019

1120
## [0.3.0] - 2026-04-15
1221

android/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,16 @@
22
<uses-permission android:name="android.permission.CAMERA" />
33
<uses-feature android:name="android.hardware.camera" />
44
<uses-feature android:name="android.hardware.camera.autofocus" />
5+
6+
<application>
7+
<provider
8+
android:name="androidx.core.content.FileProvider"
9+
android:authorities="${applicationId}.quickpose.fileprovider"
10+
android:exported="false"
11+
android:grantUriPermissions="true">
12+
<meta-data
13+
android:name="android.support.FILE_PROVIDER_PATHS"
14+
android:resource="@xml/quickpose_file_paths" />
15+
</provider>
16+
</application>
517
</manifest>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package ai.quickpose.reactnative
2+
3+
import android.content.Intent
4+
import android.graphics.Bitmap
5+
import android.os.Handler
6+
import android.os.HandlerThread
7+
import android.view.PixelCopy
8+
import android.view.SurfaceView
9+
import android.view.View
10+
import android.view.ViewGroup
11+
import androidx.core.content.FileProvider
12+
import com.facebook.react.bridge.Promise
13+
import com.facebook.react.bridge.ReactApplicationContext
14+
import com.facebook.react.bridge.ReactContextBaseJavaModule
15+
import com.facebook.react.bridge.ReactMethod
16+
import com.facebook.react.uimanager.UIManagerHelper
17+
import java.io.File
18+
import java.io.FileOutputStream
19+
20+
class QuickPoseCaptureModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
21+
22+
override fun getName(): String = "QuickPoseCaptureModule"
23+
24+
@ReactMethod
25+
fun captureFrame(viewTag: Int, promise: Promise) {
26+
captureToFile(viewTag) { result ->
27+
result.fold(
28+
onSuccess = { file -> promise.resolve("file://${file.absolutePath}") },
29+
onFailure = { err -> promise.reject("capture_failed", err) }
30+
)
31+
}
32+
}
33+
34+
@ReactMethod
35+
fun shareFrame(viewTag: Int, title: String?, promise: Promise) {
36+
captureToFile(viewTag) { result ->
37+
result.fold(
38+
onSuccess = { file ->
39+
try {
40+
val ctx = reactApplicationContext
41+
val authority = "${ctx.packageName}.quickpose.fileprovider"
42+
val uri = FileProvider.getUriForFile(ctx, authority, file)
43+
val intent = Intent(Intent.ACTION_SEND).apply {
44+
type = "image/png"
45+
putExtra(Intent.EXTRA_STREAM, uri)
46+
if (!title.isNullOrEmpty()) putExtra(Intent.EXTRA_SUBJECT, title)
47+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
48+
}
49+
val chooser = Intent.createChooser(intent, title ?: "Share").apply {
50+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
51+
}
52+
ctx.startActivity(chooser)
53+
promise.resolve(null)
54+
} catch (e: Exception) {
55+
promise.reject("share_failed", e)
56+
}
57+
},
58+
onFailure = { err -> promise.reject("capture_failed", err) }
59+
)
60+
}
61+
}
62+
63+
private fun captureToFile(viewTag: Int, onDone: (Result<File>) -> Unit) {
64+
val ctx = reactApplicationContext
65+
ctx.runOnUiQueueThread {
66+
try {
67+
val view = UIManagerHelper.getUIManager(ctx, viewTag)?.resolveView(viewTag)
68+
?: return@runOnUiQueueThread onDone(Result.failure(IllegalStateException("Could not resolve view $viewTag")))
69+
val surfaceView = findSurfaceView(view)
70+
?: return@runOnUiQueueThread onDone(Result.failure(IllegalStateException("No SurfaceView under QuickPoseView")))
71+
val width = surfaceView.width
72+
val height = surfaceView.height
73+
if (width == 0 || height == 0) {
74+
return@runOnUiQueueThread onDone(Result.failure(IllegalStateException("Surface has zero size")))
75+
}
76+
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
77+
val thread = HandlerThread("QuickPoseCapture")
78+
thread.start()
79+
val handler = Handler(thread.looper)
80+
PixelCopy.request(surfaceView, bitmap, { copyResult ->
81+
try {
82+
if (copyResult != PixelCopy.SUCCESS) {
83+
onDone(Result.failure(IllegalStateException("PixelCopy failed with $copyResult")))
84+
return@request
85+
}
86+
val file = File(ctx.cacheDir, "quickpose_${System.currentTimeMillis()}.png")
87+
FileOutputStream(file).use { out ->
88+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
89+
}
90+
onDone(Result.success(file))
91+
} catch (e: Exception) {
92+
onDone(Result.failure(e))
93+
} finally {
94+
bitmap.recycle()
95+
thread.quitSafely()
96+
}
97+
}, handler)
98+
} catch (e: Exception) {
99+
onDone(Result.failure(e))
100+
}
101+
}
102+
}
103+
104+
private fun findSurfaceView(root: View): SurfaceView? {
105+
if (root is SurfaceView) return root
106+
if (root is ViewGroup) {
107+
for (i in 0 until root.childCount) {
108+
val hit = findSurfaceView(root.getChildAt(i))
109+
if (hit != null) return hit
110+
}
111+
}
112+
return null
113+
}
114+
}

android/src/main/java/ai/quickpose/reactnative/QuickPosePackage.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import com.facebook.react.uimanager.ViewManager
77

88
class QuickPosePackage : ReactPackage {
99
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10-
return emptyList()
10+
return listOf(QuickPoseCaptureModule(reactContext))
1111
}
1212

1313
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<paths>
3+
<cache-path name="quickpose_cache" path="." />
4+
</paths>

example-counter/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# example-gated-fitness
1+
# example-counter
22

33
Minimal sample showing how to gate rep counting on the SDK's pose-check
44
feedback — so counting only starts once the user is in the correct starting

example-counter/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ PODS:
88
- hermes-engine (0.76.9):
99
- hermes-engine/Pre-built (= 0.76.9)
1010
- hermes-engine/Pre-built (0.76.9)
11-
- quickpose-react-native (0.3.0):
11+
- quickpose-react-native (0.3.1):
1212
- DoubleConversion
1313
- glog
1414
- hermes-engine
@@ -1830,7 +1830,7 @@ SPEC CHECKSUMS:
18301830
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
18311831
glog: 12f7fefc143443b7948b8af549b6c9456ff6dcb1
18321832
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
1833-
quickpose-react-native: 7b6989f48090f1c2a01f0c7a2c2f436f0ef46714
1833+
quickpose-react-native: 931c7bacacfc6abe06a388df29229d71f2275e42
18341834
QuickPoseCamera: 77b30a222ef4fabfd24305060a2a6d43658854c9
18351835
QuickPoseCore: 81c66625a930ac5ef8fa702d20a1086ec6e504b0
18361836
QuickPoseSwiftUI: e69fec45353402f539c8bba489954fd44def5dea

example/App.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
SafeAreaView,
1010
Linking,
1111
} from 'react-native';
12-
import {QuickPoseView} from '@quickpose/react-native';
12+
import {QuickPoseView, QuickPoseViewRef} from '@quickpose/react-native';
1313
import {QUICKPOSE_SDK_KEY} from './sdkConfig';
1414

1515
const FEATURE_CATEGORIES: Record<string, {label: string; feature: string}[]> = {
@@ -69,6 +69,7 @@ const FEATURE_CATEGORIES: Record<string, {label: string; feature: string}[]> = {
6969
const CATEGORY_NAMES = Object.keys(FEATURE_CATEGORIES);
7070

7171
const App = () => {
72+
const poseRef = useRef<QuickPoseViewRef>(null);
7273
const [selectedCategory, setSelectedCategory] = useState(CATEGORY_NAMES[0]);
7374
const [selectedFeatureIdx, setSelectedFeatureIdx] = useState(0);
7475
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
@@ -127,6 +128,7 @@ const App = () => {
127128
return (
128129
<View style={styles.container}>
129130
<QuickPoseView
131+
ref={poseRef}
130132
sdkKey={QUICKPOSE_SDK_KEY}
131133
features={[currentFeature.feature]}
132134
useFrontCamera={true}
@@ -186,6 +188,17 @@ const App = () => {
186188
)}
187189

188190
<SafeAreaView style={styles.bottomBranding}>
191+
<TouchableOpacity
192+
style={styles.shareButton}
193+
onPress={async () => {
194+
try {
195+
await poseRef.current?.shareFrame(currentFeature.label);
196+
} catch (e) {
197+
console.warn('shareFrame failed', e);
198+
}
199+
}}>
200+
<Text style={styles.shareLabel}>Share Screenshot</Text>
201+
</TouchableOpacity>
189202
<TouchableOpacity onPress={() => Linking.openURL('https://quickpose.ai')}>
190203
<Text style={styles.brandingText}>Powered by QuickPose.ai</Text>
191204
</TouchableOpacity>
@@ -392,6 +405,18 @@ const styles = StyleSheet.create({
392405
fontSize: 12,
393406
fontWeight: '500',
394407
},
408+
shareButton: {
409+
backgroundColor: 'white',
410+
paddingHorizontal: 20,
411+
paddingVertical: 10,
412+
borderRadius: 22,
413+
marginBottom: 8,
414+
},
415+
shareLabel: {
416+
color: '#111',
417+
fontSize: 14,
418+
fontWeight: '600',
419+
},
395420
});
396421

397422
export default App;

example/ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ PODS:
88
- hermes-engine (0.76.9):
99
- hermes-engine/Pre-built (= 0.76.9)
1010
- hermes-engine/Pre-built (0.76.9)
11-
- quickpose-react-native (0.2.6):
11+
- quickpose-react-native (0.3.1):
1212
- DoubleConversion
1313
- glog
1414
- hermes-engine
@@ -1830,7 +1830,7 @@ SPEC CHECKSUMS:
18301830
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
18311831
glog: 12f7fefc143443b7948b8af549b6c9456ff6dcb1
18321832
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
1833-
quickpose-react-native: d2f780b1e79d838dd9e55d9691bc26b7b23dba1a
1833+
quickpose-react-native: 931c7bacacfc6abe06a388df29229d71f2275e42
18341834
QuickPoseCamera: 77b30a222ef4fabfd24305060a2a6d43658854c9
18351835
QuickPoseCore: 81c66625a930ac5ef8fa702d20a1086ec6e504b0
18361836
QuickPoseSwiftUI: e69fec45353402f539c8bba489954fd44def5dea

ios/QuickPoseCaptureModule.m

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
@interface RCT_EXTERN_MODULE(QuickPoseCaptureModule, NSObject)
4+
5+
RCT_EXTERN_METHOD(captureFrame:(nonnull NSNumber *)viewTag
6+
resolver:(RCTPromiseResolveBlock)resolve
7+
rejecter:(RCTPromiseRejectBlock)reject)
8+
9+
RCT_EXTERN_METHOD(shareFrame:(nonnull NSNumber *)viewTag
10+
title:(NSString *)title
11+
resolver:(RCTPromiseResolveBlock)resolve
12+
rejecter:(RCTPromiseRejectBlock)reject)
13+
14+
@end

0 commit comments

Comments
 (0)