@sansavision/apphelm-react-native-replay 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ApphelmReactNativeReplay.podspec +20 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +3 -0
- package/README.md +54 -2
- package/android/build.gradle +30 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/dev/apphelm/reactnative/replay/ApphelmReplayModule.java +150 -0
- package/android/src/main/java/dev/apphelm/reactnative/replay/ApphelmReplayPackage.java +21 -0
- package/android/src/main/java/dev/apphelm/reactnative/replay/ReplayTaps.java +116 -0
- package/android/src/main/java/dev/apphelm/reactnative/replay/ReplayViewDescriber.java +309 -0
- package/index.d.ts +18 -0
- package/index.js +26 -0
- package/ios/ApphelmReplay.mm +335 -0
- package/package.json +14 -4
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = "ApphelmReactNativeReplay"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = package["description"]
|
|
9
|
+
s.homepage = "https://apphelm.dev"
|
|
10
|
+
s.license = { :type => "Proprietary", :file => "LICENSE" }
|
|
11
|
+
s.authors = "SansaVision"
|
|
12
|
+
s.platforms = { :ios => "13.4" }
|
|
13
|
+
s.source = { :git => "https://github.com/Sansa-Organisation/apphelm.git", :tag => "v#{s.version}" }
|
|
14
|
+
s.source_files = "ios/**/*.{h,m,mm}"
|
|
15
|
+
s.requires_arc = true
|
|
16
|
+
s.frameworks = "UIKit"
|
|
17
|
+
s.pod_target_xcconfig = { "CLANG_CXX_LANGUAGE_STANDARD" => "c++20" }
|
|
18
|
+
|
|
19
|
+
s.dependency "React-Core"
|
|
20
|
+
end
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.8.0 (prepared 7 Nov 2026, not published)
|
|
4
|
+
|
|
5
|
+
- First version (for SDK 0.8.0): the native half of `@sansavision/apphelm/react-native-replay`. An
|
|
6
|
+
Android module and an iOS module that describe the app's native views (the AppHelm Kotlin and
|
|
7
|
+
Swift SDKs' wireframe rules, with React Native's text views recognised) and note taps, for the
|
|
8
|
+
JavaScript recorder. Secure text fields are never read. `replayCapture` is null when the module
|
|
9
|
+
is not linked; `ApphelmReplayMask` and `ApphelmReplayNoCapture` mark views. Checked with
|
|
10
|
+
`checks/run-checks.sh` (javac and clang against stubs, the Android view walk on Robolectric, the
|
|
11
|
+
iOS view walk in the iOS simulator); not yet built in a React Native app or run on a device.
|
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sansavision/apphelm-react-native-replay
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The native half of AppHelm's React Native session replay. It walks the native views your app shows (React Native renders real `UIView`s and Android `View`s) and describes them to `@sansavision/apphelm/react-native-replay`, which turns them into wireframes, masks them, samples them and uploads them. The rest of the AppHelm React Native SDK has no native code: install this package only if you want replays.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @sansavision/apphelm @sansavision/apphelm-react-native-replay
|
|
7
|
+
cd ios && pod install
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
React Native's autolinking picks the module up (Android: `ApphelmReplayPackage`; iOS: the `ApphelmReactNativeReplay` pod). Rebuild the app afterwards: a JavaScript reload is not enough.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createReactNativeSdk } from "@sansavision/apphelm/react-native";
|
|
14
|
+
import { mobileReplay } from "@sansavision/apphelm/react-native-replay";
|
|
15
|
+
import { replayCapture } from "@sansavision/apphelm-react-native-replay";
|
|
16
|
+
|
|
17
|
+
const apphelm = createReactNativeSdk({
|
|
18
|
+
apiKey, workspaceId, productId, storage: AsyncStorage, appState: AppState,
|
|
19
|
+
// Replay uploads with the product's project key and an origin it allows, like the browser SDK.
|
|
20
|
+
surveyKey: "ahl_pub_…", surveyOrigin: "https://app.example.com",
|
|
21
|
+
replay: mobileReplay({ capture: replayCapture, sessionSampleRate: 0.1, onErrorSampleRate: 1 }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Once the person agreed to session replay (your consent prompt; AppHelm does not ask):
|
|
25
|
+
apphelm.setReplayConsent(true);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Call `apphelm.screen(name)` (or `navigationTracker()`) as you would anyway: a replay names its screens from it, and sign-in, sign-up, checkout, payment, billing and admin screens are never recorded.
|
|
29
|
+
|
|
30
|
+
## What it records
|
|
31
|
+
|
|
32
|
+
- A **wireframe** of the screen each time it changes, checked once a second: each view's box, background colour, border, corner radius, and its text (masked as below). Images are empty boxes; nothing is ever captured as pixels.
|
|
33
|
+
- **Taps**: where, and when. Nothing else about touches.
|
|
34
|
+
- Screen names, and when the app goes to the background and comes back.
|
|
35
|
+
|
|
36
|
+
## Masking
|
|
37
|
+
|
|
38
|
+
- `<TextInput secureTextEntry>` (iOS `secureTextEntry` or a password `textContentType`; Android a password input type or a `password` autofill hint) is **never read** by the native module: it is sent as a flag and drawn as `********`.
|
|
39
|
+
- Every other text input shows asterisks (`maskAllTextInputs`, default `true`).
|
|
40
|
+
- Labels and button titles are shown unless `maskAllText: true`. This follows the AppHelm Swift and Kotlin SDKs; Sentry's `mobileReplayIntegration` masks all text by default.
|
|
41
|
+
- `<ApphelmReplayMask>` (or `testID` containing `apphelm-mask`) masks the text inside it; `<ApphelmReplayNoCapture>` (or `testID` containing `apphelm-no-capture`) draws its area as an empty box and reads nothing inside it. Both are `View`s with `collapsable={false}` and set `testID`.
|
|
42
|
+
- React Native's text views are recognised as text: `ReactTextView` (a `TextView`) and, through its `getText()`, a text view that is not a `TextView` (React Native 0.80's `PreparedLayoutTextView`) on Android; `RCTParagraphComponentView` (new architecture, its `attributedText`) and `RCTTextView` (old architecture, its accessibility label) on iOS.
|
|
43
|
+
|
|
44
|
+
## Sampling and replay on error
|
|
45
|
+
|
|
46
|
+
`sessionSampleRate` (default 1) is the share of recordings kept whole. With `onErrorSampleRate` above 0, a recording that wasn't picked is kept in memory only (the last 30 seconds, at most about 2 MB) and uploaded when an exception is captured and kept: after `sampleRate`, `ignoreErrors` and the other filters, and after `beforeSend`, which sees the event before it carries `replay_session_id`. Messages never upload a buffer. Error events carry the replay's id as `replay_session_id`. An uncaught fatal error keeps the replay in `storage` until it is sent, on the next launch if need be (not if consent was withdrawn meanwhile).
|
|
47
|
+
|
|
48
|
+
## Limits
|
|
49
|
+
|
|
50
|
+
- **Not yet run in a React Native app, or on a device, by AppHelm.** What was checked (`checks/run-checks.sh`): the Java module compiles with `javac -Werror -Xlint:all` against `android.jar` and stubs of React Native's classes, and its view walk and tap callback ran on Robolectric's Android 15 against plain Android views (EditText, TextView, Button, Switch, ImageView, a view shaped like `PreparedLayoutTextView`); the iOS module type-checks with `clang++ -Werror` against stubs of React Native's headers, and its view walk ran on real UIKit in the iOS 26.5 simulator against UIKit views and classes named like React Native's text views. The tap hook on iOS (`sendEvent:`), the bridge calls, autolinking and React Native's own view classes were not exercised.
|
|
51
|
+
- One window: the key window on iOS, the current Activity's window on Android. React Native's `<Modal>` is a separate window (a Dialog) on Android, so it isn't recorded there.
|
|
52
|
+
- Backgrounds React Native draws with its own drawables (`CSSBackgroundDrawable` and similar) are read through their `getColor()` or `getBackgroundColor()` where they have one; gradients, shadows and images are not drawn.
|
|
53
|
+
- Expo Go has no custom native modules: use a development build. `replayCapture` is `null` when the module isn't linked; `mobileReplay` then records nothing.
|
|
54
|
+
|
|
55
|
+
Requires React Native 0.71 or later.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The Android half of @sansavision/apphelm-react-native-replay, built by the app's Gradle
|
|
2
|
+
// through React Native's autolinking. It needs nothing beyond React Native and the Android SDK.
|
|
3
|
+
apply plugin: "com.android.library"
|
|
4
|
+
|
|
5
|
+
def safeExtGet(prop, fallback) {
|
|
6
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
android {
|
|
10
|
+
namespace "dev.apphelm.reactnative.replay"
|
|
11
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 35)
|
|
12
|
+
defaultConfig {
|
|
13
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
14
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
|
15
|
+
}
|
|
16
|
+
compileOptions {
|
|
17
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
18
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
repositories {
|
|
23
|
+
google()
|
|
24
|
+
mavenCentral()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
dependencies {
|
|
28
|
+
// Resolved by the React Native Gradle plugin to the app's react-android.
|
|
29
|
+
implementation "com.facebook.react:react-native:+"
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
package dev.apphelm.reactnative.replay;
|
|
2
|
+
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.os.Handler;
|
|
5
|
+
import android.os.Looper;
|
|
6
|
+
import android.view.View;
|
|
7
|
+
import android.view.Window;
|
|
8
|
+
import com.facebook.react.bridge.Promise;
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
10
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
|
11
|
+
import com.facebook.react.bridge.ReactMethod;
|
|
12
|
+
import java.lang.ref.WeakReference;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Android half of AppHelm's React Native session replay: describes the resumed Activity's
|
|
16
|
+
* views (what React Native rendered: ReactTextView, ReactEditText, ReactImageView…) for the
|
|
17
|
+
* JavaScript recorder in @sansavision/apphelm/react-native-replay, and notes taps. The view walk is
|
|
18
|
+
* the AppHelm Kotlin SDK's (ReplayViewCapture.kt): a wireframe, never pixels, and the text of a
|
|
19
|
+
* password field is never read. Nothing runs before JavaScript calls {@code start} (after the
|
|
20
|
+
* developer declared replay consent); every failure resolves to false or null.
|
|
21
|
+
*/
|
|
22
|
+
public class ApphelmReplayModule extends ReactContextBaseJavaModule {
|
|
23
|
+
public static final String NAME = "ApphelmReplay";
|
|
24
|
+
|
|
25
|
+
private final Handler main = new Handler(Looper.getMainLooper());
|
|
26
|
+
private final ReplayViewDescriber describer = new ReplayViewDescriber();
|
|
27
|
+
private final ReplayTaps taps = new ReplayTaps();
|
|
28
|
+
/** Touched on the main thread only. */
|
|
29
|
+
private boolean started = false;
|
|
30
|
+
private WeakReference<Window> tapWindow = new WeakReference<>(null);
|
|
31
|
+
private String lastTree = null;
|
|
32
|
+
|
|
33
|
+
public ApphelmReplayModule(ReactApplicationContext context) {
|
|
34
|
+
super(context);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Override
|
|
38
|
+
public String getName() {
|
|
39
|
+
return NAME;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@ReactMethod
|
|
43
|
+
public void start(final Promise promise) {
|
|
44
|
+
main.post(new Runnable() {
|
|
45
|
+
@Override
|
|
46
|
+
public void run() {
|
|
47
|
+
try {
|
|
48
|
+
started = true;
|
|
49
|
+
lastTree = null;
|
|
50
|
+
Activity activity = getReactApplicationContext().getCurrentActivity();
|
|
51
|
+
if (activity != null) attachTaps(activity.getWindow());
|
|
52
|
+
promise.resolve(true);
|
|
53
|
+
} catch (Throwable error) {
|
|
54
|
+
promise.resolve(false);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@ReactMethod
|
|
61
|
+
public void stop(final Promise promise) {
|
|
62
|
+
main.post(new Runnable() {
|
|
63
|
+
@Override
|
|
64
|
+
public void run() {
|
|
65
|
+
stopOnMain();
|
|
66
|
+
promise.resolve(null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The screen now, as JSON: {@code {"w":…,"h":…,"root":{…}|null,"taps":[[x,y,ms],…]}} in dp. With
|
|
73
|
+
* {@code force} false, root is null when the views describe the same as last time.
|
|
74
|
+
*/
|
|
75
|
+
@ReactMethod
|
|
76
|
+
public void snapshot(final boolean force, final Promise promise) {
|
|
77
|
+
main.post(new Runnable() {
|
|
78
|
+
@Override
|
|
79
|
+
public void run() {
|
|
80
|
+
try {
|
|
81
|
+
promise.resolve(snapshotOnMain(force));
|
|
82
|
+
} catch (Throwable error) {
|
|
83
|
+
promise.resolve(null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private String snapshotOnMain(boolean force) {
|
|
90
|
+
if (!started) return null;
|
|
91
|
+
Activity activity = getReactApplicationContext().getCurrentActivity();
|
|
92
|
+
Window window = activity == null ? null : activity.getWindow();
|
|
93
|
+
if (window == null) return null;
|
|
94
|
+
attachTaps(window);
|
|
95
|
+
View decor = window.getDecorView();
|
|
96
|
+
float density = decor.getResources().getDisplayMetrics().density;
|
|
97
|
+
if (!(density > 0)) density = 1f;
|
|
98
|
+
String tree = describer.describe(decor, density);
|
|
99
|
+
boolean changed = force || tree == null || !tree.equals(lastTree);
|
|
100
|
+
lastTree = tree;
|
|
101
|
+
StringBuilder out = new StringBuilder(tree == null ? 64 : tree.length() + 96);
|
|
102
|
+
out.append("{\"w\":").append(ReplayViewDescriber.number(decor.getWidth() / density))
|
|
103
|
+
.append(",\"h\":").append(ReplayViewDescriber.number(decor.getHeight() / density))
|
|
104
|
+
.append(",\"root\":").append(changed && tree != null ? tree : "null")
|
|
105
|
+
.append(",\"taps\":");
|
|
106
|
+
taps.drain(out);
|
|
107
|
+
return out.append('}').toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Wraps the window's callback to note taps; a window wrapped by someone after us is left as it is. */
|
|
111
|
+
private void attachTaps(Window window) {
|
|
112
|
+
if (window == null || tapWindow.get() == window) return;
|
|
113
|
+
detachTaps();
|
|
114
|
+
Window.Callback original = window.getCallback();
|
|
115
|
+
if (original == null) return;
|
|
116
|
+
if (!(original instanceof ReplayTaps.TapCallback)) window.setCallback(new ReplayTaps.TapCallback(original, window, taps));
|
|
117
|
+
tapWindow = new WeakReference<>(window);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private void detachTaps() {
|
|
121
|
+
Window window = tapWindow.get();
|
|
122
|
+
tapWindow = new WeakReference<>(null);
|
|
123
|
+
if (window == null) return;
|
|
124
|
+
Window.Callback current = window.getCallback();
|
|
125
|
+
if (current instanceof ReplayTaps.TapCallback) window.setCallback(((ReplayTaps.TapCallback) current).original);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private void stopOnMain() {
|
|
129
|
+
started = false;
|
|
130
|
+
lastTree = null;
|
|
131
|
+
try {
|
|
132
|
+
detachTaps();
|
|
133
|
+
} catch (Throwable ignored) {
|
|
134
|
+
// Best effort.
|
|
135
|
+
}
|
|
136
|
+
taps.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The window callback must not outlive the React instance (a reload, the app closing). */
|
|
140
|
+
@Override
|
|
141
|
+
public void invalidate() {
|
|
142
|
+
main.post(new Runnable() {
|
|
143
|
+
@Override
|
|
144
|
+
public void run() {
|
|
145
|
+
stopOnMain();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
super.invalidate();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package dev.apphelm.reactnative.replay;
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage;
|
|
4
|
+
import com.facebook.react.bridge.NativeModule;
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext;
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager;
|
|
7
|
+
import java.util.Collections;
|
|
8
|
+
import java.util.List;
|
|
9
|
+
|
|
10
|
+
/** Registered by React Native's autolinking. */
|
|
11
|
+
public class ApphelmReplayPackage implements ReactPackage {
|
|
12
|
+
@Override
|
|
13
|
+
public List<NativeModule> createNativeModules(ReactApplicationContext context) {
|
|
14
|
+
return Collections.<NativeModule>singletonList(new ApphelmReplayModule(context));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Override
|
|
18
|
+
public List<ViewManager> createViewManagers(ReactApplicationContext context) {
|
|
19
|
+
return Collections.emptyList();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
package dev.apphelm.reactnative.replay;
|
|
2
|
+
|
|
3
|
+
import android.os.SystemClock;
|
|
4
|
+
import android.view.ActionMode;
|
|
5
|
+
import android.view.KeyEvent;
|
|
6
|
+
import android.view.KeyboardShortcutGroup;
|
|
7
|
+
import android.view.Menu;
|
|
8
|
+
import android.view.MenuItem;
|
|
9
|
+
import android.view.MotionEvent;
|
|
10
|
+
import android.view.SearchEvent;
|
|
11
|
+
import android.view.View;
|
|
12
|
+
import android.view.Window;
|
|
13
|
+
import android.view.WindowManager;
|
|
14
|
+
import android.view.accessibility.AccessibilityEvent;
|
|
15
|
+
import java.util.ArrayList;
|
|
16
|
+
import java.util.List;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Taps (a press released within 16 dp of where it began, in under 600 ms), noted from a wrapped
|
|
20
|
+
* Window.Callback as the AppHelm Kotlin SDK's TapCallback does, and handed to JavaScript with
|
|
21
|
+
* each snapshot. Only the position and the time are kept.
|
|
22
|
+
*/
|
|
23
|
+
final class ReplayTaps {
|
|
24
|
+
private static final int MAX_TAPS = 50;
|
|
25
|
+
private final List<double[]> taps = new ArrayList<>();
|
|
26
|
+
|
|
27
|
+
synchronized void add(double x, double y, double at) {
|
|
28
|
+
if (taps.size() >= MAX_TAPS) taps.remove(0);
|
|
29
|
+
taps.add(new double[] {x, y, at});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
synchronized void clear() {
|
|
33
|
+
taps.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Appends the taps as a JSON array and forgets them. */
|
|
37
|
+
synchronized void drain(StringBuilder out) {
|
|
38
|
+
out.append('[');
|
|
39
|
+
for (int index = 0; index < taps.size(); index += 1) {
|
|
40
|
+
double[] tap = taps.get(index);
|
|
41
|
+
if (index > 0) out.append(',');
|
|
42
|
+
out.append('[').append(ReplayViewDescriber.number(tap[0])).append(',').append(ReplayViewDescriber.number(tap[1])).append(',').append((long) tap[2]).append(']');
|
|
43
|
+
}
|
|
44
|
+
out.append(']');
|
|
45
|
+
taps.clear();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Passes every window callback through and notes taps. */
|
|
49
|
+
static final class TapCallback implements Window.Callback {
|
|
50
|
+
final Window.Callback original;
|
|
51
|
+
private final Window window;
|
|
52
|
+
private final ReplayTaps taps;
|
|
53
|
+
private float downX;
|
|
54
|
+
private float downY;
|
|
55
|
+
private long downAt;
|
|
56
|
+
|
|
57
|
+
TapCallback(Window.Callback original, Window window, ReplayTaps taps) {
|
|
58
|
+
this.original = original;
|
|
59
|
+
this.window = window;
|
|
60
|
+
this.taps = taps;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Override
|
|
64
|
+
public boolean dispatchTouchEvent(MotionEvent event) {
|
|
65
|
+
try {
|
|
66
|
+
if (event != null) note(event);
|
|
67
|
+
} catch (Throwable ignored) {
|
|
68
|
+
// Replay never breaks a touch.
|
|
69
|
+
}
|
|
70
|
+
return original.dispatchTouchEvent(event);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private void note(MotionEvent event) {
|
|
74
|
+
int action = event.getActionMasked();
|
|
75
|
+
if (action == MotionEvent.ACTION_DOWN) {
|
|
76
|
+
downX = event.getX();
|
|
77
|
+
downY = event.getY();
|
|
78
|
+
downAt = event.getEventTime();
|
|
79
|
+
} else if (action == MotionEvent.ACTION_UP) {
|
|
80
|
+
float density = window.getDecorView().getResources().getDisplayMetrics().density;
|
|
81
|
+
if (!(density > 0)) density = 1f;
|
|
82
|
+
float slop = 16 * density;
|
|
83
|
+
if (event.getEventTime() - downAt < 600 && Math.abs(event.getX() - downX) < slop && Math.abs(event.getY() - downY) < slop) {
|
|
84
|
+
// Event times are uptime; JavaScript wants wall-clock milliseconds.
|
|
85
|
+
double at = System.currentTimeMillis() - (SystemClock.uptimeMillis() - event.getEventTime());
|
|
86
|
+
taps.add(event.getX() / density, event.getY() / density, at);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Override public boolean dispatchKeyEvent(KeyEvent event) { return original.dispatchKeyEvent(event); }
|
|
92
|
+
@Override public boolean dispatchKeyShortcutEvent(KeyEvent event) { return original.dispatchKeyShortcutEvent(event); }
|
|
93
|
+
@Override public boolean dispatchTrackballEvent(MotionEvent event) { return original.dispatchTrackballEvent(event); }
|
|
94
|
+
@Override public boolean dispatchGenericMotionEvent(MotionEvent event) { return original.dispatchGenericMotionEvent(event); }
|
|
95
|
+
@Override public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { return original.dispatchPopulateAccessibilityEvent(event); }
|
|
96
|
+
@Override public View onCreatePanelView(int featureId) { return original.onCreatePanelView(featureId); }
|
|
97
|
+
@Override public boolean onCreatePanelMenu(int featureId, Menu menu) { return original.onCreatePanelMenu(featureId, menu); }
|
|
98
|
+
@Override public boolean onPreparePanel(int featureId, View view, Menu menu) { return original.onPreparePanel(featureId, view, menu); }
|
|
99
|
+
@Override public boolean onMenuOpened(int featureId, Menu menu) { return original.onMenuOpened(featureId, menu); }
|
|
100
|
+
@Override public boolean onMenuItemSelected(int featureId, MenuItem item) { return original.onMenuItemSelected(featureId, item); }
|
|
101
|
+
@Override public void onWindowAttributesChanged(WindowManager.LayoutParams attrs) { original.onWindowAttributesChanged(attrs); }
|
|
102
|
+
@Override public void onContentChanged() { original.onContentChanged(); }
|
|
103
|
+
@Override public void onWindowFocusChanged(boolean hasFocus) { original.onWindowFocusChanged(hasFocus); }
|
|
104
|
+
@Override public void onAttachedToWindow() { original.onAttachedToWindow(); }
|
|
105
|
+
@Override public void onDetachedFromWindow() { original.onDetachedFromWindow(); }
|
|
106
|
+
@Override public void onPanelClosed(int featureId, Menu menu) { original.onPanelClosed(featureId, menu); }
|
|
107
|
+
@Override public boolean onSearchRequested() { return original.onSearchRequested(); }
|
|
108
|
+
@Override public boolean onSearchRequested(SearchEvent searchEvent) { return original.onSearchRequested(searchEvent); }
|
|
109
|
+
@Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback) { return original.onWindowStartingActionMode(callback); }
|
|
110
|
+
@Override public ActionMode onWindowStartingActionMode(ActionMode.Callback callback, int type) { return original.onWindowStartingActionMode(callback, type); }
|
|
111
|
+
@Override public void onActionModeStarted(ActionMode mode) { original.onActionModeStarted(mode); }
|
|
112
|
+
@Override public void onActionModeFinished(ActionMode mode) { original.onActionModeFinished(mode); }
|
|
113
|
+
@Override public void onProvideKeyboardShortcuts(List<KeyboardShortcutGroup> data, Menu menu, int deviceId) { original.onProvideKeyboardShortcuts(data, menu, deviceId); }
|
|
114
|
+
@Override public void onPointerCaptureChanged(boolean hasCapture) { original.onPointerCaptureChanged(hasCapture); }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
package dev.apphelm.reactnative.replay;
|
|
2
|
+
|
|
3
|
+
import android.graphics.Color;
|
|
4
|
+
import android.graphics.drawable.ColorDrawable;
|
|
5
|
+
import android.graphics.drawable.Drawable;
|
|
6
|
+
import android.graphics.drawable.GradientDrawable;
|
|
7
|
+
import android.os.Build;
|
|
8
|
+
import android.text.InputType;
|
|
9
|
+
import android.text.method.PasswordTransformationMethod;
|
|
10
|
+
import android.view.View;
|
|
11
|
+
import android.view.ViewGroup;
|
|
12
|
+
import android.webkit.WebView;
|
|
13
|
+
import android.widget.Button;
|
|
14
|
+
import android.widget.CompoundButton;
|
|
15
|
+
import android.widget.EditText;
|
|
16
|
+
import android.widget.ImageView;
|
|
17
|
+
import android.widget.TextView;
|
|
18
|
+
import java.lang.reflect.Method;
|
|
19
|
+
import java.util.HashMap;
|
|
20
|
+
import java.util.Locale;
|
|
21
|
+
import java.util.Map;
|
|
22
|
+
import java.util.WeakHashMap;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Describes a view tree as the JSON tree @sansavision/apphelm/react-native-replay builds its
|
|
26
|
+
* wireframe from (NativeViewNode: short keys, dp). The rules are the AppHelm Kotlin SDK's
|
|
27
|
+
* ReplayViewCapture.describe: EditText is an input (a password one is secure and its text is never
|
|
28
|
+
* read), CompoundButton a toggle, Button a button, TextView text, ImageView an image, WebView a web
|
|
29
|
+
* view; a field, button, toggle or image is a leaf. React Native's own views are subclasses of
|
|
30
|
+
* these (ReactEditText, ReactTextView, ReactSwitch, ReactImageView), so they are recognised as
|
|
31
|
+
* they are; a React Native text view that is not a TextView (PreparedLayoutTextView) is read
|
|
32
|
+
* through its getText(). Hidden views are left out; the walk stops at 3,000 views.
|
|
33
|
+
* Main thread only.
|
|
34
|
+
*/
|
|
35
|
+
final class ReplayViewDescriber {
|
|
36
|
+
static final int MAX_NODES = 3_000;
|
|
37
|
+
static final int MAX_TEXT = 500;
|
|
38
|
+
private static final int MAX_DEPTH = 200;
|
|
39
|
+
|
|
40
|
+
private final WeakHashMap<View, Integer> ids = new WeakHashMap<>();
|
|
41
|
+
private int nextId = 0;
|
|
42
|
+
private int count = 0;
|
|
43
|
+
private final int[] location = new int[2];
|
|
44
|
+
/** getText() of view classes that are not TextViews, and colour getters of drawable classes; null when a class has none. */
|
|
45
|
+
private final Map<Class<?>, Method> textGetters = new HashMap<>();
|
|
46
|
+
private final Map<Class<?>, Method> colourGetters = new HashMap<>();
|
|
47
|
+
private final Map<Class<?>, Method> innerDrawables = new HashMap<>();
|
|
48
|
+
|
|
49
|
+
/** The tree from {@code root}, or null when the root itself is hidden. */
|
|
50
|
+
String describe(View root, float density) {
|
|
51
|
+
count = 0;
|
|
52
|
+
StringBuilder out = new StringBuilder(16 * 1024);
|
|
53
|
+
return describe(root, density, out, 0) ? out.toString() : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private int idOf(View view) {
|
|
57
|
+
Integer id = ids.get(view);
|
|
58
|
+
if (id == null) {
|
|
59
|
+
id = ++nextId;
|
|
60
|
+
ids.put(view, id);
|
|
61
|
+
}
|
|
62
|
+
return id;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Appends the view's node; false (nothing appended) when it is hidden or the budget is spent. */
|
|
66
|
+
private boolean describe(View view, float density, StringBuilder out, int depth) {
|
|
67
|
+
if (count >= MAX_NODES || depth > MAX_DEPTH) return false;
|
|
68
|
+
if (view.getVisibility() != View.VISIBLE) return false;
|
|
69
|
+
float alpha = view.getAlpha();
|
|
70
|
+
if (alpha <= 0.01f) return false;
|
|
71
|
+
count += 1;
|
|
72
|
+
view.getLocationInWindow(location);
|
|
73
|
+
String kind = "view";
|
|
74
|
+
CharSequence text = null;
|
|
75
|
+
Integer foreground = null;
|
|
76
|
+
float fontSize = 0;
|
|
77
|
+
boolean secure = false;
|
|
78
|
+
Boolean on = null;
|
|
79
|
+
if (view instanceof EditText) {
|
|
80
|
+
EditText field = (EditText) view;
|
|
81
|
+
kind = "input";
|
|
82
|
+
secure = isPasswordInput(field.getInputType(), field.getTransformationMethod() instanceof PasswordTransformationMethod) || hasPasswordHint(field);
|
|
83
|
+
// A password field's text is never read.
|
|
84
|
+
if (!secure) text = field.getText();
|
|
85
|
+
} else if (view instanceof CompoundButton) {
|
|
86
|
+
kind = "toggle";
|
|
87
|
+
on = ((CompoundButton) view).isChecked();
|
|
88
|
+
text = ((CompoundButton) view).getText();
|
|
89
|
+
} else if (view instanceof Button) {
|
|
90
|
+
kind = "button";
|
|
91
|
+
text = ((Button) view).getText();
|
|
92
|
+
} else if (view instanceof TextView) {
|
|
93
|
+
kind = "text";
|
|
94
|
+
text = ((TextView) view).getText();
|
|
95
|
+
} else if (view instanceof ImageView) {
|
|
96
|
+
kind = "image";
|
|
97
|
+
} else if (view instanceof WebView) {
|
|
98
|
+
kind = "web";
|
|
99
|
+
} else {
|
|
100
|
+
Method getter = textGetter(view.getClass());
|
|
101
|
+
if (getter != null) {
|
|
102
|
+
try {
|
|
103
|
+
Object value = getter.invoke(view);
|
|
104
|
+
if (value instanceof CharSequence) {
|
|
105
|
+
kind = "text";
|
|
106
|
+
text = (CharSequence) value;
|
|
107
|
+
}
|
|
108
|
+
} catch (Throwable ignored) {
|
|
109
|
+
// Not text after all.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (view instanceof TextView) {
|
|
114
|
+
TextView label = (TextView) view;
|
|
115
|
+
foreground = label.getCurrentTextColor();
|
|
116
|
+
fontSize = label.getTextSize() / density;
|
|
117
|
+
}
|
|
118
|
+
String background = null;
|
|
119
|
+
float corner = 0;
|
|
120
|
+
Drawable drawable = view.getBackground();
|
|
121
|
+
if (drawable instanceof ColorDrawable) {
|
|
122
|
+
background = hex(((ColorDrawable) drawable).getColor());
|
|
123
|
+
} else if (drawable instanceof GradientDrawable) {
|
|
124
|
+
GradientDrawable gradient = (GradientDrawable) drawable;
|
|
125
|
+
if (gradient.getColor() != null) background = hex(gradient.getColor().getDefaultColor());
|
|
126
|
+
corner = gradient.getCornerRadius() / density;
|
|
127
|
+
} else if (drawable != null) {
|
|
128
|
+
background = reflectedColour(drawable, 0);
|
|
129
|
+
}
|
|
130
|
+
boolean noCapture = marked(view, "ph-no-capture", "apphelm-no-capture");
|
|
131
|
+
boolean mask = marked(view, "ph-mask", "apphelm-mask");
|
|
132
|
+
|
|
133
|
+
out.append("{\"i\":").append(idOf(view))
|
|
134
|
+
.append(",\"k\":\"").append(kind).append('"')
|
|
135
|
+
.append(",\"x\":").append(number(location[0] / density))
|
|
136
|
+
.append(",\"y\":").append(number(location[1] / density))
|
|
137
|
+
.append(",\"w\":").append(number(view.getWidth() / density))
|
|
138
|
+
.append(",\"h\":").append(number(view.getHeight() / density));
|
|
139
|
+
if (alpha < 0.999f) out.append(",\"a\":").append(number(alpha));
|
|
140
|
+
if (background != null) out.append(",\"bg\":\"").append(background).append('"');
|
|
141
|
+
if (foreground != null) {
|
|
142
|
+
String colour = hex(foreground);
|
|
143
|
+
if (colour != null) out.append(",\"fg\":\"").append(colour).append('"');
|
|
144
|
+
}
|
|
145
|
+
if (corner >= 1) out.append(",\"r\":").append(number(corner));
|
|
146
|
+
if (fontSize > 0) out.append(",\"fs\":").append(number(fontSize));
|
|
147
|
+
if (secure) out.append(",\"s\":1");
|
|
148
|
+
if (on != null) out.append(",\"on\":").append(on ? "true" : "false");
|
|
149
|
+
if (noCapture) {
|
|
150
|
+
// Left out: an empty box, nothing inside read.
|
|
151
|
+
out.append(",\"nc\":1}");
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
if (mask) out.append(",\"m\":1");
|
|
155
|
+
if (text != null && text.length() > 0) {
|
|
156
|
+
out.append(",\"t\":");
|
|
157
|
+
string(out, text.length() > MAX_TEXT ? text.subSequence(0, MAX_TEXT) : text);
|
|
158
|
+
}
|
|
159
|
+
if (view instanceof ViewGroup && kind.equals("view")) {
|
|
160
|
+
ViewGroup group = (ViewGroup) view;
|
|
161
|
+
int start = out.length();
|
|
162
|
+
out.append(",\"c\":[");
|
|
163
|
+
int mark = out.length();
|
|
164
|
+
for (int index = 0; index < group.getChildCount(); index += 1) {
|
|
165
|
+
View child = group.getChildAt(index);
|
|
166
|
+
if (child == null) continue;
|
|
167
|
+
int before = out.length();
|
|
168
|
+
if (before > mark) out.append(',');
|
|
169
|
+
if (!describe(child, density, out, depth + 1)) out.setLength(before);
|
|
170
|
+
}
|
|
171
|
+
if (out.length() == mark) out.setLength(start);
|
|
172
|
+
else out.append(']');
|
|
173
|
+
}
|
|
174
|
+
out.append('}');
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** True for any password input type or a password transformation (the Kotlin SDK's isPasswordInput). */
|
|
179
|
+
static boolean isPasswordInput(int inputType, boolean transformed) {
|
|
180
|
+
int variation = inputType & (InputType.TYPE_MASK_CLASS | InputType.TYPE_MASK_VARIATION);
|
|
181
|
+
return transformed
|
|
182
|
+
|| variation == (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD)
|
|
183
|
+
|| variation == (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD)
|
|
184
|
+
|| variation == (InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)
|
|
185
|
+
|| variation == (InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** React Native's autoComplete="password" / "new-password" (autofill hints, Android 8+). */
|
|
189
|
+
private static boolean hasPasswordHint(View view) {
|
|
190
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false;
|
|
191
|
+
String[] hints = view.getAutofillHints();
|
|
192
|
+
if (hints == null) return false;
|
|
193
|
+
for (String hint : hints) if (hint != null && hint.toLowerCase(Locale.ROOT).contains("password")) return true;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A marker in the view's tag (React Native's testID) or its content description (accessibilityLabel). */
|
|
198
|
+
private static boolean marked(View view, String first, String second) {
|
|
199
|
+
Object tag = view.getTag();
|
|
200
|
+
if (tag instanceof String && (((String) tag).contains(first) || ((String) tag).contains(second))) return true;
|
|
201
|
+
CharSequence description = view.getContentDescription();
|
|
202
|
+
if (description == null) return false;
|
|
203
|
+
String value = description.toString();
|
|
204
|
+
return value.contains(first) || value.contains(second);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A public no-argument getText() on a view class named like a text view, cached per class. */
|
|
208
|
+
private Method textGetter(Class<?> type) {
|
|
209
|
+
if (textGetters.containsKey(type)) return textGetters.get(type);
|
|
210
|
+
Method found = null;
|
|
211
|
+
String name = type.getSimpleName();
|
|
212
|
+
// Never a field (or a wrapper of one): only a view that draws text, such as PreparedLayoutTextView.
|
|
213
|
+
if (name.contains("Text") && !name.contains("Input") && !name.contains("Edit") && !name.contains("Field") && !name.contains("Password") && !name.contains("Secure")) {
|
|
214
|
+
try {
|
|
215
|
+
Method method = type.getMethod("getText");
|
|
216
|
+
if (CharSequence.class.isAssignableFrom(method.getReturnType())) found = method;
|
|
217
|
+
} catch (Throwable ignored) {
|
|
218
|
+
// No text.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
textGetters.put(type, found);
|
|
222
|
+
return found;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The colour of a drawable AppHelm doesn't know (React Native's CSSBackgroundDrawable,
|
|
227
|
+
* ReactViewBackgroundDrawable, or the BackgroundDrawable inside a CompositeBackgroundDrawable):
|
|
228
|
+
* its int getColor() or getBackgroundColor(), one drawable deep.
|
|
229
|
+
*/
|
|
230
|
+
private String reflectedColour(Drawable drawable, int depth) {
|
|
231
|
+
Class<?> type = drawable.getClass();
|
|
232
|
+
Method getter;
|
|
233
|
+
if (colourGetters.containsKey(type)) getter = colourGetters.get(type);
|
|
234
|
+
else {
|
|
235
|
+
getter = intGetter(type, "getColor");
|
|
236
|
+
if (getter == null) getter = intGetter(type, "getBackgroundColor");
|
|
237
|
+
colourGetters.put(type, getter);
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
if (getter != null) return hex((Integer) getter.invoke(drawable));
|
|
241
|
+
} catch (Throwable ignored) {
|
|
242
|
+
// No colour.
|
|
243
|
+
}
|
|
244
|
+
if (depth > 0) return null;
|
|
245
|
+
Method inner;
|
|
246
|
+
if (innerDrawables.containsKey(type)) inner = innerDrawables.get(type);
|
|
247
|
+
else {
|
|
248
|
+
inner = null;
|
|
249
|
+
try {
|
|
250
|
+
Method method = type.getMethod("getBackground");
|
|
251
|
+
if (Drawable.class.isAssignableFrom(method.getReturnType())) inner = method;
|
|
252
|
+
} catch (Throwable ignored) {
|
|
253
|
+
// None.
|
|
254
|
+
}
|
|
255
|
+
innerDrawables.put(type, inner);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
Object value = inner == null ? null : inner.invoke(drawable);
|
|
259
|
+
if (value instanceof Drawable && value != drawable) {
|
|
260
|
+
Drawable nested = (Drawable) value;
|
|
261
|
+
if (nested instanceof ColorDrawable) return hex(((ColorDrawable) nested).getColor());
|
|
262
|
+
return reflectedColour(nested, depth + 1);
|
|
263
|
+
}
|
|
264
|
+
} catch (Throwable ignored) {
|
|
265
|
+
// No colour.
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private static Method intGetter(Class<?> type, String name) {
|
|
271
|
+
try {
|
|
272
|
+
Method method = type.getMethod(name);
|
|
273
|
+
return method.getReturnType() == int.class ? method : null;
|
|
274
|
+
} catch (Throwable ignored) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
static String hex(int color) {
|
|
280
|
+
if (Color.alpha(color) < 3) return null;
|
|
281
|
+
return String.format(Locale.ROOT, "#%06x", color & 0xFFFFFF);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A dp value with at most two decimals. */
|
|
285
|
+
static String number(double value) {
|
|
286
|
+
if (Double.isNaN(value) || Double.isInfinite(value)) return "0";
|
|
287
|
+
long rounded = Math.round(value * 100);
|
|
288
|
+
if (rounded % 100 == 0) return Long.toString(rounded / 100);
|
|
289
|
+
return Double.toString(rounded / 100.0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
static void string(StringBuilder out, CharSequence value) {
|
|
293
|
+
out.append('"');
|
|
294
|
+
for (int index = 0; index < value.length(); index += 1) {
|
|
295
|
+
char c = value.charAt(index);
|
|
296
|
+
switch (c) {
|
|
297
|
+
case '"': out.append("\\\""); break;
|
|
298
|
+
case '\\': out.append("\\\\"); break;
|
|
299
|
+
case '\n': out.append("\\n"); break;
|
|
300
|
+
case '\r': out.append("\\r"); break;
|
|
301
|
+
case '\t': out.append("\\t"); break;
|
|
302
|
+
default:
|
|
303
|
+
if (c < 0x20 || c == 0x2028 || c == 0x2029) out.append(String.format(Locale.ROOT, "\\u%04x", (int) c));
|
|
304
|
+
else out.append(c);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
out.append('"');
|
|
308
|
+
}
|
|
309
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
/** The native view capture, for `mobileReplay({ capture })`; null when the native module isn't linked. */
|
|
5
|
+
export declare const replayCapture: {
|
|
6
|
+
/** React Native's `Platform.OS`. */
|
|
7
|
+
platform: string;
|
|
8
|
+
/** Start noting taps; false when the module can't. */
|
|
9
|
+
start(): Promise<boolean>;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
/** The screen as JSON (`NativeSnapshot`), or null. */
|
|
12
|
+
snapshot(force: boolean): Promise<string | null>;
|
|
13
|
+
} | null;
|
|
14
|
+
|
|
15
|
+
/** A View whose text, and its children's, is masked in session replay. It sets `testID` to `apphelm-mask`. */
|
|
16
|
+
export declare function ApphelmReplayMask(props: ViewProps): ReactElement;
|
|
17
|
+
/** A View left out of session replay (an empty box). It sets `testID` to `apphelm-no-capture`. */
|
|
18
|
+
export declare function ApphelmReplayNoCapture(props: ViewProps): ReactElement;
|
package/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The native module (android/ and ios/) as the `capture` that @sansavision/apphelm/react-native-replay's
|
|
2
|
+
// `mobileReplay` takes. When the module isn't linked (Expo Go, a build made before installing this
|
|
3
|
+
// package) it is null and nothing is recorded. No call rejects.
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { NativeModules, Platform, View } from "react-native";
|
|
6
|
+
|
|
7
|
+
const native = NativeModules.ApphelmReplay;
|
|
8
|
+
|
|
9
|
+
export const replayCapture = native && typeof native.start === "function" && typeof native.stop === "function" && typeof native.snapshot === "function"
|
|
10
|
+
? {
|
|
11
|
+
platform: Platform.OS,
|
|
12
|
+
start: () => native.start().then((started) => started === true, () => false),
|
|
13
|
+
stop: () => native.stop().then(() => undefined, () => undefined),
|
|
14
|
+
snapshot: (force) => native.snapshot(force === true).then((json) => (typeof json === "string" && json.length > 0 ? json : null), () => null),
|
|
15
|
+
}
|
|
16
|
+
: null;
|
|
17
|
+
|
|
18
|
+
/** A View whose text, and its children's, is masked in session replay (asterisks). */
|
|
19
|
+
export function ApphelmReplayMask(props) {
|
|
20
|
+
return createElement(View, { ...props, testID: "apphelm-mask", collapsable: false });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A View left out of session replay: drawn as an empty box, nothing inside it read. */
|
|
24
|
+
export function ApphelmReplayNoCapture(props) {
|
|
25
|
+
return createElement(View, { ...props, testID: "apphelm-no-capture", collapsable: false });
|
|
26
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// The iOS half of @sansavision/apphelm-react-native-replay: describes the key window's views (what
|
|
2
|
+
// React Native rendered: RCTParagraphComponentView / RCTTextView, RCTUITextField, RCTImageView…)
|
|
3
|
+
// for the JavaScript recorder in @sansavision/apphelm/react-native-replay, and notes taps. The view
|
|
4
|
+
// walk is the AppHelm Swift SDK's (ReplayUIKit.swift): a wireframe, never pixels, and the text of a
|
|
5
|
+
// secure field is never read. Taps come from UIApplication's sendEvent:, as sentry-cocoa's replay
|
|
6
|
+
// notes touches, not from a gesture recognizer that could hold back React Native's touch handler.
|
|
7
|
+
// Nothing runs before JavaScript calls `start` (after the developer declared replay consent). No
|
|
8
|
+
// call ever rejects.
|
|
9
|
+
|
|
10
|
+
#import <Foundation/Foundation.h>
|
|
11
|
+
#import <UIKit/UIKit.h>
|
|
12
|
+
#import <React/RCTBridgeModule.h>
|
|
13
|
+
#import <React/RCTInvalidating.h>
|
|
14
|
+
#import <objc/runtime.h>
|
|
15
|
+
|
|
16
|
+
namespace {
|
|
17
|
+
const NSUInteger kMaxNodes = 3000;
|
|
18
|
+
const NSUInteger kMaxText = 500;
|
|
19
|
+
const NSUInteger kMaxDepth = 200;
|
|
20
|
+
const NSUInteger kMaxTaps = 50;
|
|
21
|
+
|
|
22
|
+
// Touched on the main thread only: sendEvent: and the module's methods (methodQueue) run there.
|
|
23
|
+
bool tapsEnabled = false;
|
|
24
|
+
NSMutableArray<NSArray<NSNumber *> *> *pendingTaps = nil;
|
|
25
|
+
NSMapTable<UITouch *, NSValue *> *touchStarts = nil;
|
|
26
|
+
IMP originalSendEvent = NULL;
|
|
27
|
+
char replayIdKey;
|
|
28
|
+
NSInteger nextReplayId = 0;
|
|
29
|
+
|
|
30
|
+
void noteEvent(UIEvent *event)
|
|
31
|
+
{
|
|
32
|
+
if (!tapsEnabled || event.type != UIEventTypeTouches) return;
|
|
33
|
+
for (UITouch *touch in event.allTouches) {
|
|
34
|
+
UIWindow *window = touch.window;
|
|
35
|
+
if (!window) continue;
|
|
36
|
+
CGPoint point = [touch locationInView:window];
|
|
37
|
+
if (touch.phase == UITouchPhaseBegan) {
|
|
38
|
+
// The start point and time, packed as a rect: origin = point, size.width = timestamp.
|
|
39
|
+
[touchStarts setObject:[NSValue valueWithCGRect:CGRectMake(point.x, point.y, touch.timestamp, 0)] forKey:touch];
|
|
40
|
+
} else if (touch.phase == UITouchPhaseEnded) {
|
|
41
|
+
NSValue *start = [touchStarts objectForKey:touch];
|
|
42
|
+
[touchStarts removeObjectForKey:touch];
|
|
43
|
+
if (!start) continue;
|
|
44
|
+
CGRect began = start.CGRectValue;
|
|
45
|
+
if (touch.timestamp - began.size.width < 0.6 && fabs(point.x - began.origin.x) < 16 && fabs(point.y - began.origin.y) < 16) {
|
|
46
|
+
if (pendingTaps.count >= kMaxTaps) [pendingTaps removeObjectAtIndex:0];
|
|
47
|
+
double at = [[NSDate date] timeIntervalSince1970] * 1000.0;
|
|
48
|
+
[pendingTaps addObject:@[ @(round(point.x * 100) / 100), @(round(point.y * 100) / 100), @((long long)at) ]];
|
|
49
|
+
}
|
|
50
|
+
} else if (touch.phase == UITouchPhaseCancelled) {
|
|
51
|
+
[touchStarts removeObjectForKey:touch];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
void apphelmSendEvent(id application, SEL selector, UIEvent *event)
|
|
57
|
+
{
|
|
58
|
+
@try {
|
|
59
|
+
noteEvent(event);
|
|
60
|
+
} @catch (__unused NSException *exception) {
|
|
61
|
+
// Replay never breaks a touch.
|
|
62
|
+
}
|
|
63
|
+
((void (*)(id, SEL, UIEvent *))originalSendEvent)(application, selector, event);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
void installTapHook()
|
|
67
|
+
{
|
|
68
|
+
static dispatch_once_t once;
|
|
69
|
+
dispatch_once(&once, ^{
|
|
70
|
+
pendingTaps = [NSMutableArray array];
|
|
71
|
+
touchStarts = [NSMapTable weakToStrongObjectsMapTable];
|
|
72
|
+
Method method = class_getInstanceMethod([UIApplication class], @selector(sendEvent:));
|
|
73
|
+
if (method) originalSendEvent = method_setImplementation(method, (IMP)apphelmSendEvent);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
NSString *hexColour(UIColor *color)
|
|
78
|
+
{
|
|
79
|
+
if (!color) return nil;
|
|
80
|
+
CGFloat red = 0, green = 0, blue = 0, alpha = 0;
|
|
81
|
+
if (![color getRed:&red green:&green blue:&blue alpha:&alpha] || alpha <= 0.01) return nil;
|
|
82
|
+
auto byte = [](CGFloat value) { return (int)lround(MIN(1.0, MAX(0.0, value)) * 255.0); };
|
|
83
|
+
return [NSString stringWithFormat:@"#%02x%02x%02x", byte(red), byte(green), byte(blue)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
NSNumber *points(CGFloat value)
|
|
87
|
+
{
|
|
88
|
+
if (isnan(value) || isinf(value)) return @0;
|
|
89
|
+
return @(round(value * 100) / 100);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
NSInteger idOf(UIView *view)
|
|
93
|
+
{
|
|
94
|
+
NSNumber *stored = objc_getAssociatedObject(view, &replayIdKey);
|
|
95
|
+
if (stored) return stored.integerValue;
|
|
96
|
+
nextReplayId += 1;
|
|
97
|
+
objc_setAssociatedObject(view, &replayIdKey, @(nextReplayId), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
|
98
|
+
return nextReplayId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
bool isKind(UIView *view, NSString *className)
|
|
102
|
+
{
|
|
103
|
+
Class type = NSClassFromString(className);
|
|
104
|
+
return type && [view isKindOfClass:type];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
bool marked(UIView *view, NSString *first, NSString *second)
|
|
108
|
+
{
|
|
109
|
+
// React Native's testID is the accessibilityIdentifier.
|
|
110
|
+
NSString *identifier = view.accessibilityIdentifier;
|
|
111
|
+
return identifier && ([identifier containsString:first] || [identifier containsString:second]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
void putText(NSMutableDictionary *node, NSString *text)
|
|
115
|
+
{
|
|
116
|
+
if (text.length == 0) return;
|
|
117
|
+
node[@"t"] = text.length > kMaxText ? [text substringToIndex:kMaxText] : text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** React Native's text views (RCTParagraphComponentView on the new architecture, RCTTextView on the old) draw an attributed string, not a UILabel. */
|
|
121
|
+
void describeReactText(UIView *view, NSMutableDictionary *node)
|
|
122
|
+
{
|
|
123
|
+
node[@"k"] = @"text";
|
|
124
|
+
NSAttributedString *attributed = nil;
|
|
125
|
+
if ([view respondsToSelector:@selector(attributedText)]) {
|
|
126
|
+
id value = [view valueForKey:@"attributedText"];
|
|
127
|
+
if ([value isKindOfClass:[NSAttributedString class]]) attributed = value;
|
|
128
|
+
}
|
|
129
|
+
if (attributed.length > 0) {
|
|
130
|
+
putText(node, attributed.string);
|
|
131
|
+
UIFont *font = [attributed attribute:NSFontAttributeName atIndex:0 effectiveRange:NULL];
|
|
132
|
+
UIColor *color = [attributed attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL];
|
|
133
|
+
if ([font isKindOfClass:[UIFont class]]) node[@"fs"] = points(font.pointSize);
|
|
134
|
+
if ([color isKindOfClass:[UIColor class]]) {
|
|
135
|
+
NSString *hex = hexColour(color);
|
|
136
|
+
if (hex) node[@"fg"] = hex;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// RCTTextView's accessibilityLabel is its text when the app set none.
|
|
140
|
+
putText(node, view.accessibilityLabel);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class Describer {
|
|
145
|
+
public:
|
|
146
|
+
explicit Describer(UIWindow *window) : window_(window) {}
|
|
147
|
+
|
|
148
|
+
NSMutableDictionary *describe(UIView *view, NSUInteger depth)
|
|
149
|
+
{
|
|
150
|
+
if (count_ >= kMaxNodes || depth > kMaxDepth || view.isHidden || view.alpha <= 0.01) return nil;
|
|
151
|
+
count_ += 1;
|
|
152
|
+
CGRect frame = [view convertRect:view.bounds toView:window_];
|
|
153
|
+
NSMutableDictionary *node = [NSMutableDictionary dictionaryWithCapacity:12];
|
|
154
|
+
node[@"i"] = @(idOf(view));
|
|
155
|
+
node[@"x"] = points(frame.origin.x);
|
|
156
|
+
node[@"y"] = points(frame.origin.y);
|
|
157
|
+
node[@"w"] = points(frame.size.width);
|
|
158
|
+
node[@"h"] = points(frame.size.height);
|
|
159
|
+
if (view.alpha < 0.999) node[@"a"] = points(view.alpha);
|
|
160
|
+
NSString *background = hexColour(view.backgroundColor);
|
|
161
|
+
if (!background && view.layer.backgroundColor) background = hexColour([UIColor colorWithCGColor:view.layer.backgroundColor]);
|
|
162
|
+
if (background) node[@"bg"] = background;
|
|
163
|
+
if (view.layer.borderWidth > 0 && view.layer.borderColor) {
|
|
164
|
+
NSString *border = hexColour([UIColor colorWithCGColor:view.layer.borderColor]);
|
|
165
|
+
if (border) {
|
|
166
|
+
node[@"bc"] = border;
|
|
167
|
+
node[@"bw"] = points(view.layer.borderWidth);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (view.layer.cornerRadius >= 1) node[@"r"] = points(view.layer.cornerRadius);
|
|
171
|
+
NSString *kind = @"view";
|
|
172
|
+
if ([view isKindOfClass:[UITextField class]]) {
|
|
173
|
+
UITextField *field = (UITextField *)view;
|
|
174
|
+
kind = @"input";
|
|
175
|
+
bool secure = field.isSecureTextEntry || [field.textContentType isEqualToString:UITextContentTypePassword] || [field.textContentType isEqualToString:UITextContentTypeNewPassword];
|
|
176
|
+
// A secure field's text is never read.
|
|
177
|
+
if (secure) node[@"s"] = @1;
|
|
178
|
+
else putText(node, field.text);
|
|
179
|
+
NSString *colour = hexColour(field.textColor);
|
|
180
|
+
if (colour) node[@"fg"] = colour;
|
|
181
|
+
if (field.font) node[@"fs"] = points(field.font.pointSize);
|
|
182
|
+
} else if ([view isKindOfClass:[UITextView class]]) {
|
|
183
|
+
UITextView *text = (UITextView *)view;
|
|
184
|
+
kind = text.isEditable ? @"input" : @"text";
|
|
185
|
+
bool secure = text.isSecureTextEntry || [text.textContentType isEqualToString:UITextContentTypePassword] || [text.textContentType isEqualToString:UITextContentTypeNewPassword];
|
|
186
|
+
if (secure) {
|
|
187
|
+
kind = @"input";
|
|
188
|
+
node[@"s"] = @1;
|
|
189
|
+
} else putText(node, text.text);
|
|
190
|
+
NSString *colour = hexColour(text.textColor);
|
|
191
|
+
if (colour) node[@"fg"] = colour;
|
|
192
|
+
if (text.font) node[@"fs"] = points(text.font.pointSize);
|
|
193
|
+
} else if ([view isKindOfClass:[UILabel class]]) {
|
|
194
|
+
UILabel *label = (UILabel *)view;
|
|
195
|
+
kind = @"text";
|
|
196
|
+
putText(node, label.text);
|
|
197
|
+
NSString *colour = hexColour(label.textColor);
|
|
198
|
+
if (colour) node[@"fg"] = colour;
|
|
199
|
+
node[@"fs"] = points(label.font.pointSize);
|
|
200
|
+
} else if ([view isKindOfClass:[UIButton class]]) {
|
|
201
|
+
UIButton *button = (UIButton *)view;
|
|
202
|
+
kind = @"button";
|
|
203
|
+
putText(node, [button titleForState:UIControlStateNormal] ?: button.titleLabel.text);
|
|
204
|
+
NSString *colour = hexColour([button titleColorForState:UIControlStateNormal]);
|
|
205
|
+
if (colour) node[@"fg"] = colour;
|
|
206
|
+
if (button.titleLabel.font) node[@"fs"] = points(button.titleLabel.font.pointSize);
|
|
207
|
+
} else if ([view isKindOfClass:[UISwitch class]]) {
|
|
208
|
+
UISwitch *toggle = (UISwitch *)view;
|
|
209
|
+
kind = @"toggle";
|
|
210
|
+
node[@"on"] = @(toggle.isOn);
|
|
211
|
+
NSString *colour = hexColour(toggle.isOn ? (toggle.onTintColor ?: [UIColor systemGreenColor]) : toggle.backgroundColor);
|
|
212
|
+
if (colour) node[@"bg"] = colour;
|
|
213
|
+
} else if ([view isKindOfClass:[UIImageView class]] || isKind(view, @"RCTImageView") || isKind(view, @"RCTImageComponentView")) {
|
|
214
|
+
kind = @"image";
|
|
215
|
+
} else if (isKind(view, @"RCTParagraphComponentView") || isKind(view, @"RCTTextView")) {
|
|
216
|
+
describeReactText(view, node);
|
|
217
|
+
kind = node[@"k"];
|
|
218
|
+
} else if ([NSStringFromClass([view class]) containsString:@"WKWebView"]) {
|
|
219
|
+
// WebKit is not linked for this check; the class name is enough.
|
|
220
|
+
kind = @"web";
|
|
221
|
+
}
|
|
222
|
+
node[@"k"] = kind;
|
|
223
|
+
if (marked(view, @"ph-no-capture", @"apphelm-no-capture")) {
|
|
224
|
+
// Left out: an empty box, nothing inside read.
|
|
225
|
+
[node removeObjectForKey:@"t"];
|
|
226
|
+
node[@"nc"] = @1;
|
|
227
|
+
return node;
|
|
228
|
+
}
|
|
229
|
+
if (marked(view, @"ph-mask", @"apphelm-mask")) node[@"m"] = @1;
|
|
230
|
+
if ([kind isEqualToString:@"view"] || [kind isEqualToString:@"text"]) {
|
|
231
|
+
NSMutableArray *children = [NSMutableArray array];
|
|
232
|
+
for (UIView *child in view.subviews) {
|
|
233
|
+
NSMutableDictionary *described = describe(child, depth + 1);
|
|
234
|
+
if (described) [children addObject:described];
|
|
235
|
+
}
|
|
236
|
+
if (children.count > 0) node[@"c"] = children;
|
|
237
|
+
}
|
|
238
|
+
return node;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private:
|
|
242
|
+
UIWindow *window_;
|
|
243
|
+
NSUInteger count_ = 0;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
UIWindow *keyWindow()
|
|
247
|
+
{
|
|
248
|
+
UIApplication *application = [UIApplication sharedApplication];
|
|
249
|
+
for (UIScene *scene in application.connectedScenes) {
|
|
250
|
+
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
|
|
251
|
+
for (UIWindow *window in ((UIWindowScene *)scene).windows) {
|
|
252
|
+
if (window.isKeyWindow) return window;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return nil;
|
|
256
|
+
}
|
|
257
|
+
} // namespace
|
|
258
|
+
|
|
259
|
+
@interface ApphelmReplay : NSObject <RCTBridgeModule, RCTInvalidating>
|
|
260
|
+
@end
|
|
261
|
+
|
|
262
|
+
@implementation ApphelmReplay {
|
|
263
|
+
NSString *_lastTree;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
RCT_EXPORT_MODULE(ApphelmReplay)
|
|
267
|
+
|
|
268
|
+
+ (BOOL)requiresMainQueueSetup
|
|
269
|
+
{
|
|
270
|
+
return NO;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// UIKit is main-thread only: every method runs there.
|
|
274
|
+
- (dispatch_queue_t)methodQueue
|
|
275
|
+
{
|
|
276
|
+
return dispatch_get_main_queue();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
RCT_EXPORT_METHOD(start : (RCTPromiseResolveBlock)resolve reject : (__unused RCTPromiseRejectBlock)reject)
|
|
280
|
+
{
|
|
281
|
+
installTapHook();
|
|
282
|
+
tapsEnabled = originalSendEvent != NULL;
|
|
283
|
+
[pendingTaps removeAllObjects];
|
|
284
|
+
_lastTree = nil;
|
|
285
|
+
resolve(@YES);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
RCT_EXPORT_METHOD(stop : (RCTPromiseResolveBlock)resolve reject : (__unused RCTPromiseRejectBlock)reject)
|
|
289
|
+
{
|
|
290
|
+
tapsEnabled = false;
|
|
291
|
+
[pendingTaps removeAllObjects];
|
|
292
|
+
[touchStarts removeAllObjects];
|
|
293
|
+
_lastTree = nil;
|
|
294
|
+
resolve([NSNull null]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// The screen now, as JSON: {"w":…,"h":…,"root":{…}|null,"taps":[[x,y,ms],…]} in points. With
|
|
298
|
+
/// `force` NO, root is null when the views describe the same as last time.
|
|
299
|
+
RCT_EXPORT_METHOD(snapshot : (BOOL)force resolve : (RCTPromiseResolveBlock)resolve reject : (__unused RCTPromiseRejectBlock)reject)
|
|
300
|
+
{
|
|
301
|
+
@try {
|
|
302
|
+
UIWindow *window = keyWindow();
|
|
303
|
+
if (!window) {
|
|
304
|
+
resolve([NSNull null]);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
Describer describer(window);
|
|
308
|
+
NSMutableDictionary *root = describer.describe(window, 0);
|
|
309
|
+
NSString *tree = nil;
|
|
310
|
+
if (root) {
|
|
311
|
+
NSData *data = [NSJSONSerialization dataWithJSONObject:root options:0 error:nil];
|
|
312
|
+
if (data) tree = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
313
|
+
}
|
|
314
|
+
BOOL changed = force || !tree || ![tree isEqualToString:_lastTree];
|
|
315
|
+
_lastTree = tree;
|
|
316
|
+
NSData *tapData = [NSJSONSerialization dataWithJSONObject:(pendingTaps ?: @[]) options:0 error:nil];
|
|
317
|
+
NSString *taps = tapData ? [[NSString alloc] initWithData:tapData encoding:NSUTF8StringEncoding] : @"[]";
|
|
318
|
+
[pendingTaps removeAllObjects];
|
|
319
|
+
NSString *out = [NSString stringWithFormat:@"{\"w\":%@,\"h\":%@,\"root\":%@,\"taps\":%@}", points(window.bounds.size.width), points(window.bounds.size.height), changed && tree ? tree : @"null", taps];
|
|
320
|
+
resolve(out);
|
|
321
|
+
} @catch (__unused NSException *exception) {
|
|
322
|
+
resolve([NSNull null]);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
- (void)invalidate
|
|
327
|
+
{
|
|
328
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
329
|
+
tapsEnabled = false;
|
|
330
|
+
[pendingTaps removeAllObjects];
|
|
331
|
+
[touchStarts removeAllObjects];
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
@end
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/apphelm-react-native-replay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "The native half of AppHelm's React Native session replay: describes the app's native views (Android and iOS) as wireframes for @sansavision/apphelm/react-native-replay. Never pixels; secure text is never read.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": { "types": "./index.d.ts", "import": "./index.js" }
|
|
11
|
+
},
|
|
12
|
+
"files": ["index.js", "index.d.ts", "ApphelmReactNativeReplay.podspec", "ios/ApphelmReplay.mm", "android/build.gradle", "android/src/main", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
13
|
+
"peerDependencies": { "react": ">=18", "react-native": ">=0.71" },
|
|
14
|
+
"peerDependenciesMeta": { "react": { "optional": true }, "react-native": { "optional": true } },
|
|
15
|
+
"publishConfig": { "access": "public", "provenance": false }
|
|
16
|
+
}
|