clintplay-react-native 0.1.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/README.md +222 -0
- package/android/build.gradle +50 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/digital/clint/clintplay/ClintplayRNModule.kt +362 -0
- package/android/src/main/java/digital/clint/clintplay/ClintplayRNPackage.kt +49 -0
- package/app.plugin.js +29 -0
- package/clintplay-react-native.podspec +40 -0
- package/dist/index.d.mts +280 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.js +1480 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1461 -0
- package/dist/index.mjs.map +1 -0
- package/ios/ClintplayRN.h +25 -0
- package/ios/ClintplayRN.mm +361 -0
- package/ios/PrivacyInfo.xcprivacy +52 -0
- package/package.json +67 -0
- package/react-native.config.js +22 -0
- package/src/spec/NativeClintplayRN.ts +25 -0
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# clintplay-react-native
|
|
2
|
+
|
|
3
|
+
Session capture SDK for React Native / Expo apps — companion to [`clintplay-sdk`](../sdk) (web). Captures lifecycle, screen dimensions, navigation, JS errors, network requests (Fetch & XHR/Axios), console output, touches (screen-relative & target-relative), and native visual wireframe replay on iOS and Android.
|
|
4
|
+
|
|
5
|
+
> **Status:** `0.1.0` — Production-ready JS runtime and Native TurboModule. Supports native view hierarchy traversal on iOS (`ios/ClintplayRN.mm`) and Android (`android/.../ClintplayRNModule.kt`), visual wireframe session replay, network interception for `fetch` and `XMLHttpRequest`/Axios, automatic navigation tracking, and intelligent auto-masking for sensitive fields.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add clintplay-react-native
|
|
11
|
+
# or
|
|
12
|
+
npm install clintplay-react-native
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Peer deps: `react >=18`, `react-native >=0.74`. Optional peer: `@react-native-async-storage/async-storage` (enables the offline retry queue when present). Tested against Expo SDK 54 / RN 0.81 with New Architecture enabled.
|
|
16
|
+
|
|
17
|
+
In your `app.json`, register the config plugin:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"expo": {
|
|
22
|
+
"plugins": ["clintplay-react-native"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// app/_layout.tsx
|
|
31
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
32
|
+
import {
|
|
33
|
+
Clintplay,
|
|
34
|
+
ClintplayCaptureProvider,
|
|
35
|
+
ClintplayNavigationTracker,
|
|
36
|
+
} from 'clintplay-react-native';
|
|
37
|
+
|
|
38
|
+
Clintplay.init({
|
|
39
|
+
apiUrl: process.env.EXPO_PUBLIC_CLINTPLAY_URL!,
|
|
40
|
+
apiKey: process.env.EXPO_PUBLIC_CLINTPLAY_KEY,
|
|
41
|
+
asyncStorage: AsyncStorage, // enables offline retry queue
|
|
42
|
+
captureWireframe: true, // native visual replay snapshots
|
|
43
|
+
wireframeIntervalMs: 1000, // snapshot interval (default: 1000ms)
|
|
44
|
+
captureNetwork: true, // fetch + XMLHttpRequest/axios (on by default)
|
|
45
|
+
captureConsole: false, // opt-in console.warn/error
|
|
46
|
+
captureConsoleLog: false, // opt-in console.log (requires captureConsole: true)
|
|
47
|
+
// sampleRate: 1.0,
|
|
48
|
+
// debug: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export default function RootLayout() {
|
|
52
|
+
return (
|
|
53
|
+
<ClintplayCaptureProvider>
|
|
54
|
+
<ClintplayNavigationTracker />
|
|
55
|
+
<YourApp />
|
|
56
|
+
</ClintplayCaptureProvider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`init()` is a no-op when `apiUrl` is empty, so it is safe to call unconditionally in all environments.
|
|
62
|
+
|
|
63
|
+
## API Reference
|
|
64
|
+
|
|
65
|
+
| Call | What it does |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `Clintplay.init(config)` | Boot the SDK once at app start. Idempotent. |
|
|
68
|
+
| `Clintplay.identify(userId, traits?)` | Tag the current session with a user id and optional metadata. Pass `null` on logout. |
|
|
69
|
+
| `Clintplay.trackScreen(path, from?)` | Emit a screen navigation event manually. (Alternatively, use `<ClintplayNavigationTracker />` or `useClintplayNavigationTracking()`). |
|
|
70
|
+
| `Clintplay.reportError(err)` | Manually surface a caught error. Uncaught errors are captured automatically via `ErrorUtils.setGlobalHandler`. |
|
|
71
|
+
| `Clintplay.flush()` | Force-flush the event buffer (e.g. on backgrounding). |
|
|
72
|
+
| `Clintplay.stop()` | Tear down listeners, interceptors, and timers. |
|
|
73
|
+
| `Clintplay.current()` | Inspect the current session ID and user ID (for breadcrumbs or support tickets). |
|
|
74
|
+
|
|
75
|
+
## Configuration Options
|
|
76
|
+
|
|
77
|
+
| Option | Type | Default | Description |
|
|
78
|
+
| --- | --- | --- | --- |
|
|
79
|
+
| `apiUrl` | `string` | *(required)* | Backend ingestion URL. If omitted or empty, `init()` safely no-ops. |
|
|
80
|
+
| `apiKey` | `string` | `undefined` | Optional Bearer auth token for ingestion batches. |
|
|
81
|
+
| `enabled` | `boolean` | `true` | Master killswitch. Set to `false` to disable all telemetry. |
|
|
82
|
+
| `sampleRate` | `number` | `1.0` | Probabilistic sampling rate between `0.0` and `1.0`. |
|
|
83
|
+
| `captureWireframe` | `boolean` | `false` | Enable native view-hierarchy visual wireframe capture for session replay. |
|
|
84
|
+
| `wireframeIntervalMs` | `number` | `1000` | Wireframe snapshot interval in milliseconds. |
|
|
85
|
+
| `captureNetwork` | `boolean` | `true` | Intercept `globalThis.fetch` and `XMLHttpRequest` (including Axios) requests. |
|
|
86
|
+
| `captureConsole` | `boolean` | `false` | Intercept `console.warn` and `console.error` logs. |
|
|
87
|
+
| `captureConsoleLog` | `boolean` | `false` | Intercept `console.log` logs when `captureConsole: true`. |
|
|
88
|
+
| `sanitizeUrl` | `(url: string) => string` | `stripQuery` | Custom URL sanitizer function for network requests. Default strips query strings. |
|
|
89
|
+
| `excludeEventTypes` | `EventType[]` | `[]` | Explicitly drop specific event types (`'wireframe'`, `'network'`, `'touch'`, `'log'`, etc.). |
|
|
90
|
+
| `flushIntervalMs` | `number` | `5000` | Periodic buffer flush interval. |
|
|
91
|
+
| `flushSize` | `number` | `50` | Buffer size threshold triggering an immediate flush. |
|
|
92
|
+
| `maxBufferSize` | `number` | `1000` | Ring buffer capacity before oldest events are dropped. |
|
|
93
|
+
| `asyncStorage` | `AsyncStorageLike` | `undefined` | Storage adapter (e.g., `@react-native-async-storage/async-storage`) for offline queue persistence. |
|
|
94
|
+
| `debug` | `boolean` | `false` | Enable verbose diagnostic logging prefixed with `[clintplay-rn]`. |
|
|
95
|
+
|
|
96
|
+
## Capture Sources
|
|
97
|
+
|
|
98
|
+
| Event Type | Source | Default | Details |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| `meta` | SDK Bootstrap & Session Rotation | **On** | Session ID, SDK version, device platform, OS version. |
|
|
101
|
+
| `screen` | `Dimensions` listener | **On** | Screen width, height, scale factor, and orientation (`portrait`/`landscape`). |
|
|
102
|
+
| `lifecycle` | `AppState` listener | **On** | Application state transitions (`active`, `background`, `inactive`). |
|
|
103
|
+
| `error` | `ErrorUtils.setGlobalHandler` | **On** | Uncaught exceptions with message, stack trace, and fatal flag. Non-interfering with Sentry. |
|
|
104
|
+
| `network` | `fetch` & `XMLHttpRequest` / Axios | **On** | HTTP method, URL (sanitized), status code, duration, and errors. Protected against double-counting. |
|
|
105
|
+
| `navigation` | Navigation Trackers & `trackScreen` | **On (Tracker)** | Screen path changes (`to`, `from`). Automatic via `<ClintplayNavigationTracker />` or React Navigation listener. |
|
|
106
|
+
| `touch` | `<ClintplayCaptureProvider>` | **Provider** | Tap / touch gestures with both screen-relative coordinates (`screenX`, `screenY`) and target-relative coordinates (`x`, `y`). |
|
|
107
|
+
| `wireframe` | Native TurboModule | **Opt-in** | Visual UI hierarchy snapshots (view rects, styles, text, masking) across iOS and Android. |
|
|
108
|
+
| `log` | Console Interceptor | **Opt-in** | Console output (`warn`, `error`, and optional `log`). |
|
|
109
|
+
| `identify` | `Clintplay.identify()` | **Manual** | Associating sessions with authenticated users and custom traits. |
|
|
110
|
+
|
|
111
|
+
## Navigation Tracking
|
|
112
|
+
|
|
113
|
+
### Expo Router
|
|
114
|
+
|
|
115
|
+
Add `<ClintplayNavigationTracker />` at the root inside your layout:
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
// app/_layout.tsx
|
|
119
|
+
import { ClintplayCaptureProvider, ClintplayNavigationTracker } from 'clintplay-react-native';
|
|
120
|
+
|
|
121
|
+
export default function RootLayout() {
|
|
122
|
+
return (
|
|
123
|
+
<ClintplayCaptureProvider>
|
|
124
|
+
<ClintplayNavigationTracker />
|
|
125
|
+
<Slot />
|
|
126
|
+
</ClintplayCaptureProvider>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The tracker uses `useSegments()` / `usePathname()` when running under Expo Router to emit navigation events automatically on route changes.
|
|
132
|
+
|
|
133
|
+
### React Navigation
|
|
134
|
+
|
|
135
|
+
Use `createClintplayNavigationStateListener` on your `NavigationContainer`:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import { NavigationContainer } from '@react-navigation/native';
|
|
139
|
+
import { createClintplayNavigationStateListener } from 'clintplay-react-native';
|
|
140
|
+
|
|
141
|
+
const onStateChange = createClintplayNavigationStateListener();
|
|
142
|
+
|
|
143
|
+
export default function App() {
|
|
144
|
+
return (
|
|
145
|
+
<NavigationContainer onStateChange={onStateChange}>
|
|
146
|
+
<RootStackNavigator />
|
|
147
|
+
</NavigationContainer>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Or use the hook directly:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { useClintplayNavigationTracking } from 'clintplay-react-native';
|
|
156
|
+
|
|
157
|
+
function MyNavigationComponent() {
|
|
158
|
+
useClintplayNavigationTracking({
|
|
159
|
+
onNavigate: (to, from) => {
|
|
160
|
+
// custom handling if needed, Clintplay.trackScreen is called by default
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return <NavigationStack />;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Coordinate Tracking (`screenX`, `screenY`)
|
|
168
|
+
|
|
169
|
+
`<ClintplayCaptureProvider>` listens to bubble-phase touch responder events without intercepting touches from downstream buttons or lists.
|
|
170
|
+
|
|
171
|
+
Each touch event captures:
|
|
172
|
+
- `screenX`, `screenY`: Screen-relative coordinates (derived from `nativeEvent.pageX` / `nativeEvent.pageY`). These are ideal for session replay cursor rendering and heatmaps.
|
|
173
|
+
- `x`, `y`: Target-relative coordinates (derived from `nativeEvent.locationX` / `nativeEvent.locationY`).
|
|
174
|
+
|
|
175
|
+
## Privacy, Masking & Redaction
|
|
176
|
+
|
|
177
|
+
### Automatic Masking Heuristics
|
|
178
|
+
|
|
179
|
+
When `captureWireframe: true` is enabled, the native traversal engines on iOS and Android automatically redact sensitive views before building the wireframe payload:
|
|
180
|
+
- **iOS**: Views with `isSecureTextEntry == YES` on `UITextField`, or `textContentType` matching `password`, `newPassword`, `creditCardNumber`, or `oneTimeCode`.
|
|
181
|
+
- **Android**: `EditText` views with `inputType` matching password variations (`TYPE_TEXT_VARIATION_PASSWORD`, `TYPE_TEXT_VARIATION_WEB_PASSWORD`, `TYPE_NUMBER_VARIATION_PASSWORD`, etc.) or when using `PasswordTransformationMethod`.
|
|
182
|
+
|
|
183
|
+
### Explicit Masking Components
|
|
184
|
+
|
|
185
|
+
To redact custom subtrees or images, use the built-in masking components:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import {
|
|
189
|
+
ClintplayMaskView,
|
|
190
|
+
ClintplayMaskTextInput,
|
|
191
|
+
ClintplayMaskImage,
|
|
192
|
+
} from 'clintplay-react-native';
|
|
193
|
+
|
|
194
|
+
// Redact an entire component subtree
|
|
195
|
+
<ClintplayMaskView>
|
|
196
|
+
<Text>Sensitive account balance: $1,000,000</Text>
|
|
197
|
+
</ClintplayMaskView>
|
|
198
|
+
|
|
199
|
+
// Drop-in masked input & image replacements
|
|
200
|
+
<ClintplayMaskTextInput value={ssn} onChangeText={setSsn} />
|
|
201
|
+
<ClintplayMaskImage source={{ uri: photoUrl }} style={styles.photo} />
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Under the hood, these components set `nativeID="clintplay-mask"`, instructing the native TurboModule to redact their geometry and contents.
|
|
205
|
+
|
|
206
|
+
## Offline Retry Queue
|
|
207
|
+
|
|
208
|
+
Provide an `AsyncStorage` instance in `Clintplay.init({ asyncStorage })`. When the device loses network connectivity, failed batches are persisted to disk and automatically drained upon reconnection or next application boot. The queue features an LRU eviction policy with a 5 MiB storage cap.
|
|
209
|
+
|
|
210
|
+
## Known Limitations & Sentry Integration
|
|
211
|
+
|
|
212
|
+
- **Console & Error Interception Order**: If using Sentry or other APM tools alongside `clintplay-react-native`, initialize `Clintplay.init()` after Sentry. Both SDKs maintain chain-safe handlers, but initialization order ensures all breadcrumbs and crash reports route cleanly.
|
|
213
|
+
- **Native View Hierarchy Testing**: Visual wireframe capture requires running on a real device or simulator with the TurboModule linked. When running in web environments or bare Node unit test runners, wireframe capture gracefully falls back to empty node sets.
|
|
214
|
+
|
|
215
|
+
## Scripts
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
pnpm test # vitest run
|
|
219
|
+
pnpm typecheck # tsc --noEmit
|
|
220
|
+
pnpm build # tsup → dist/{cjs,esm,dts}
|
|
221
|
+
```
|
|
222
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
// Match the consumer app's Kotlin version so we don't drag in a
|
|
3
|
+
// conflicting toolchain. Most modern Expo SDK 54 / RN 0.81 apps
|
|
4
|
+
// ship Kotlin 1.9.x.
|
|
5
|
+
ext.kotlin_version = rootProject.ext.kotlin_version ?: '1.9.24'
|
|
6
|
+
|
|
7
|
+
repositories {
|
|
8
|
+
google()
|
|
9
|
+
mavenCentral()
|
|
10
|
+
}
|
|
11
|
+
dependencies {
|
|
12
|
+
classpath "com.android.tools.build:gradle:8.6.0"
|
|
13
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
apply plugin: 'com.android.library'
|
|
18
|
+
apply plugin: 'org.jetbrains.kotlin.android'
|
|
19
|
+
|
|
20
|
+
def getExtOrDefault(name, defaultValue) {
|
|
21
|
+
return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
android {
|
|
25
|
+
namespace 'digital.clint.clintplay'
|
|
26
|
+
compileSdk getExtOrDefault('compileSdkVersion', 34)
|
|
27
|
+
|
|
28
|
+
defaultConfig {
|
|
29
|
+
minSdk getExtOrDefault('minSdkVersion', 24)
|
|
30
|
+
targetSdk getExtOrDefault('targetSdkVersion', 34)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
compileOptions {
|
|
34
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
35
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
kotlinOptions {
|
|
39
|
+
jvmTarget = '17'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
repositories {
|
|
44
|
+
mavenCentral()
|
|
45
|
+
google()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
dependencies {
|
|
49
|
+
implementation 'com.facebook.react:react-android'
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="digital.clint.clintplay"/>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
package digital.clint.clintplay
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.drawable.ColorDrawable
|
|
6
|
+
import android.graphics.drawable.Drawable
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
10
|
+
import android.text.InputType
|
|
11
|
+
import android.text.method.PasswordTransformationMethod
|
|
12
|
+
import android.view.View
|
|
13
|
+
import android.view.ViewGroup
|
|
14
|
+
import android.view.WindowManager
|
|
15
|
+
import android.widget.EditText
|
|
16
|
+
import android.widget.TextView
|
|
17
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
18
|
+
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
19
|
+
import com.facebook.react.bridge.ReactMethod
|
|
20
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
21
|
+
import com.facebook.react.turbomodule.core.interfaces.TurboModule
|
|
22
|
+
import org.json.JSONArray
|
|
23
|
+
import org.json.JSONObject
|
|
24
|
+
import java.lang.reflect.Method
|
|
25
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
26
|
+
import java.util.concurrent.CountDownLatch
|
|
27
|
+
import java.util.concurrent.TimeUnit
|
|
28
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
29
|
+
import java.util.concurrent.atomic.AtomicInteger
|
|
30
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
31
|
+
import kotlin.math.round
|
|
32
|
+
|
|
33
|
+
// Android side of the Clintplay TurboModule.
|
|
34
|
+
//
|
|
35
|
+
// Walks the Android View hierarchy from the active Activity's decorView and
|
|
36
|
+
// serializes each View into an rrweb-shaped wireframe snapshot with
|
|
37
|
+
// coordinate conversion, background colors, extracted text, and
|
|
38
|
+
// privacy auto-masking (clintplay-mask tags, password fields, and sensitive autofill hints).
|
|
39
|
+
//
|
|
40
|
+
// React Native's codegen emits `NativeClintplayRNSpec` from
|
|
41
|
+
// `src/spec/NativeClintplayRN.ts` at build time. The TurboReactPackage
|
|
42
|
+
// in `ClintplayRNPackage.kt` routes calls here.
|
|
43
|
+
@ReactModule(name = ClintplayRNModule.NAME)
|
|
44
|
+
class ClintplayRNModule(reactContext: ReactApplicationContext) :
|
|
45
|
+
ReactContextBaseJavaModule(reactContext), TurboModule {
|
|
46
|
+
|
|
47
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
48
|
+
|
|
49
|
+
init {
|
|
50
|
+
try {
|
|
51
|
+
resolveNativeIdResId(reactContext)
|
|
52
|
+
resolveTestIdResId(reactContext)
|
|
53
|
+
} catch (_: Throwable) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override fun getName(): String = NAME
|
|
57
|
+
|
|
58
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
59
|
+
fun getVersion(): String = VERSION
|
|
60
|
+
|
|
61
|
+
private fun formatColor(color: Int): String {
|
|
62
|
+
val alpha = (color ushr 24) and 0xFF
|
|
63
|
+
val red = (color ushr 16) and 0xFF
|
|
64
|
+
val green = (color ushr 8) and 0xFF
|
|
65
|
+
val blue = color and 0xFF
|
|
66
|
+
return String.format(java.util.Locale.US, "#%02x%02x%02x%02x", red, green, blue, alpha)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun extractBackgroundColor(drawable: Drawable?): String? {
|
|
70
|
+
if (drawable == null) return null
|
|
71
|
+
if (drawable is ColorDrawable) {
|
|
72
|
+
return formatColor(drawable.color)
|
|
73
|
+
}
|
|
74
|
+
val drawableClass = drawable.javaClass
|
|
75
|
+
val cached = getColorMethodCache.computeIfAbsent(drawableClass) { cls ->
|
|
76
|
+
try {
|
|
77
|
+
cls.getMethod("getColor")
|
|
78
|
+
} catch (_: Throwable) {
|
|
79
|
+
NO_METHOD_SENTINEL
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (cached is Method) {
|
|
83
|
+
try {
|
|
84
|
+
val colorObj = cached.invoke(drawable)
|
|
85
|
+
if (colorObj is Int) {
|
|
86
|
+
return formatColor(colorObj)
|
|
87
|
+
}
|
|
88
|
+
} catch (_: Throwable) {}
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fun hasSensitiveAutofillHints(view: View): Boolean {
|
|
94
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
95
|
+
try {
|
|
96
|
+
val hints = view.autofillHints ?: return false
|
|
97
|
+
for (hint in hints) {
|
|
98
|
+
val lower = hint.lowercase(java.util.Locale.US)
|
|
99
|
+
if (lower.contains("password") ||
|
|
100
|
+
lower.contains("creditcard") ||
|
|
101
|
+
lower.contains("credit_card") ||
|
|
102
|
+
lower.contains("otp") ||
|
|
103
|
+
lower.contains("onetimecode") ||
|
|
104
|
+
lower.contains("one_time_code") ||
|
|
105
|
+
lower.contains("cvv") ||
|
|
106
|
+
lower.contains("cvc")) {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (_: Throwable) {}
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fun serializeView(
|
|
116
|
+
view: View?,
|
|
117
|
+
nodeCounter: AtomicInteger,
|
|
118
|
+
density: Float,
|
|
119
|
+
depth: Int = 0,
|
|
120
|
+
): JSONObject? {
|
|
121
|
+
if (depth > 64) {
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (view == null || view.visibility != View.VISIBLE || view.alpha <= 0.001f) {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
val nodeId = nodeCounter.getAndIncrement()
|
|
130
|
+
val nodeObj = JSONObject()
|
|
131
|
+
nodeObj.put("id", nodeId)
|
|
132
|
+
nodeObj.put("type", view.javaClass.simpleName)
|
|
133
|
+
nodeObj.put("className", view.javaClass.name)
|
|
134
|
+
|
|
135
|
+
// Window-relative coordinates (CAND-12)
|
|
136
|
+
val location = IntArray(2)
|
|
137
|
+
view.getLocationInWindow(location)
|
|
138
|
+
|
|
139
|
+
val safeDensity = if (density > 0f) density else 1f
|
|
140
|
+
val rawX = location[0].toDouble() / safeDensity
|
|
141
|
+
val rawY = location[1].toDouble() / safeDensity
|
|
142
|
+
val rawW = view.width.toDouble() / safeDensity
|
|
143
|
+
val rawH = view.height.toDouble() / safeDensity
|
|
144
|
+
|
|
145
|
+
val x = if (rawX.isNaN() || rawX.isInfinite()) 0.0 else round(rawX * 100.0) / 100.0
|
|
146
|
+
val y = if (rawY.isNaN() || rawY.isInfinite()) 0.0 else round(rawY * 100.0) / 100.0
|
|
147
|
+
val w = if (rawW.isNaN() || rawW.isInfinite()) 0.0 else round(rawW * 100.0) / 100.0
|
|
148
|
+
val h = if (rawH.isNaN() || rawH.isInfinite()) 0.0 else round(rawH * 100.0) / 100.0
|
|
149
|
+
|
|
150
|
+
nodeObj.put("x", x)
|
|
151
|
+
nodeObj.put("y", y)
|
|
152
|
+
nodeObj.put("width", w)
|
|
153
|
+
nodeObj.put("height", h)
|
|
154
|
+
nodeObj.put("w", w)
|
|
155
|
+
nodeObj.put("h", h)
|
|
156
|
+
|
|
157
|
+
if (view.id != View.NO_ID && view.id != 0) {
|
|
158
|
+
nodeObj.put("tag", view.id)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
val bgHex = extractBackgroundColor(view.background)
|
|
162
|
+
if (bgHex != null) {
|
|
163
|
+
nodeObj.put("backgroundColor", bgHex)
|
|
164
|
+
nodeObj.put("bg", bgHex)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
val nativeIdRes = resolveNativeIdResId(view.context)
|
|
168
|
+
val nativeIdTag = if (nativeIdRes != 0) view.getTag(nativeIdRes)?.toString() else null
|
|
169
|
+
val testIdRes = resolveTestIdResId(view.context)
|
|
170
|
+
val testIdTag = if (testIdRes != 0) view.getTag(testIdRes)?.toString() else null
|
|
171
|
+
|
|
172
|
+
val tagStr = view.tag?.toString()
|
|
173
|
+
val contentDesc = view.contentDescription?.toString()
|
|
174
|
+
|
|
175
|
+
val identifier = tagStr ?: nativeIdTag ?: testIdTag
|
|
176
|
+
if (!identifier.isNullOrEmpty()) {
|
|
177
|
+
nodeObj.put("accessibilityIdentifier", identifier)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
val isPassword = (view is EditText && run {
|
|
181
|
+
val inputType = view.inputType
|
|
182
|
+
val inputClass = inputType and InputType.TYPE_MASK_CLASS
|
|
183
|
+
val variation = inputType and InputType.TYPE_MASK_VARIATION
|
|
184
|
+
(inputClass == InputType.TYPE_CLASS_TEXT && (
|
|
185
|
+
variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
|
|
186
|
+
variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD ||
|
|
187
|
+
variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
|
188
|
+
)) || (inputClass == InputType.TYPE_CLASS_NUMBER && (
|
|
189
|
+
variation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
|
190
|
+
))
|
|
191
|
+
}) || (view is TextView && view.transformationMethod is PasswordTransformationMethod)
|
|
192
|
+
|
|
193
|
+
val isAutofillSensitive = hasSensitiveAutofillHints(view)
|
|
194
|
+
|
|
195
|
+
val isMasked = tagStr == "clintplay-mask" ||
|
|
196
|
+
contentDesc == "clintplay-mask" ||
|
|
197
|
+
nativeIdTag == "clintplay-mask" ||
|
|
198
|
+
testIdTag == "clintplay-mask" ||
|
|
199
|
+
isPassword ||
|
|
200
|
+
isAutofillSensitive
|
|
201
|
+
|
|
202
|
+
if (!contentDesc.isNullOrEmpty()) {
|
|
203
|
+
nodeObj.put("accessibilityLabel", if (isMasked) "***" else contentDesc)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var extractedText: String? = null
|
|
207
|
+
if (view is TextView) {
|
|
208
|
+
extractedText = view.text?.toString()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (extractedText != null) {
|
|
212
|
+
nodeObj.put("text", if (isMasked) "***" else extractedText)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (isMasked) {
|
|
216
|
+
nodeObj.put("masked", true)
|
|
217
|
+
nodeObj.put("children", JSONArray())
|
|
218
|
+
return nodeObj
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
nodeObj.put("masked", false)
|
|
222
|
+
|
|
223
|
+
val childrenArray = JSONArray()
|
|
224
|
+
if (view is ViewGroup) {
|
|
225
|
+
val childCount = view.childCount
|
|
226
|
+
for (i in 0 until childCount) {
|
|
227
|
+
val childView = view.getChildAt(i) ?: continue
|
|
228
|
+
val childJson = serializeView(childView, nodeCounter, density, depth + 1)
|
|
229
|
+
if (childJson != null) {
|
|
230
|
+
childrenArray.put(childJson)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
nodeObj.put("children", childrenArray)
|
|
235
|
+
|
|
236
|
+
return nodeObj
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private fun captureViewHierarchyOnMainThread(activity: Activity): String {
|
|
240
|
+
return try {
|
|
241
|
+
if (activity.isFinishing || activity.isDestroyed) {
|
|
242
|
+
return FALLBACK_JSON
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
val window = activity.window ?: return FALLBACK_JSON
|
|
246
|
+
|
|
247
|
+
// Check FLAG_SECURE (CAND-08)
|
|
248
|
+
if ((window.attributes.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0) {
|
|
249
|
+
return FALLBACK_JSON
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
val decorView = try {
|
|
253
|
+
window.decorView
|
|
254
|
+
} catch (_: Throwable) {
|
|
255
|
+
return FALLBACK_JSON
|
|
256
|
+
} ?: return FALLBACK_JSON
|
|
257
|
+
|
|
258
|
+
val density = decorView.resources.displayMetrics.density
|
|
259
|
+
val nodeCounter = AtomicInteger(1)
|
|
260
|
+
val rootNode = serializeView(decorView, nodeCounter, density, 0)
|
|
261
|
+
?: return FALLBACK_JSON
|
|
262
|
+
|
|
263
|
+
val ts = System.currentTimeMillis()
|
|
264
|
+
val payload = JSONObject()
|
|
265
|
+
payload.put("version", 1)
|
|
266
|
+
payload.put("ts", ts)
|
|
267
|
+
payload.put("timestamp", ts)
|
|
268
|
+
|
|
269
|
+
val nodes = JSONArray()
|
|
270
|
+
nodes.put(rootNode)
|
|
271
|
+
payload.put("nodes", nodes)
|
|
272
|
+
|
|
273
|
+
payload.toString()
|
|
274
|
+
} catch (_: Throwable) {
|
|
275
|
+
FALLBACK_JSON
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
280
|
+
fun captureViewHierarchy(): String {
|
|
281
|
+
val activity = currentActivity ?: return FALLBACK_JSON
|
|
282
|
+
|
|
283
|
+
if (activity.isFinishing || activity.isDestroyed) {
|
|
284
|
+
return FALLBACK_JSON
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (Looper.myLooper() == Looper.getMainLooper()) {
|
|
288
|
+
return captureViewHierarchyOnMainThread(activity)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
val isCancelled = AtomicBoolean(false)
|
|
292
|
+
val result = AtomicReference(FALLBACK_JSON)
|
|
293
|
+
val latch = CountDownLatch(1)
|
|
294
|
+
|
|
295
|
+
val callback = Runnable {
|
|
296
|
+
if (isCancelled.get()) {
|
|
297
|
+
return@Runnable
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
result.set(captureViewHierarchyOnMainThread(activity))
|
|
301
|
+
} catch (_: Throwable) {
|
|
302
|
+
result.set(FALLBACK_JSON)
|
|
303
|
+
} finally {
|
|
304
|
+
latch.countDown()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
val posted = mainHandler.post(callback)
|
|
309
|
+
if (!posted) {
|
|
310
|
+
return FALLBACK_JSON
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
val completed = try {
|
|
314
|
+
latch.await(250, TimeUnit.MILLISECONDS)
|
|
315
|
+
} catch (_: InterruptedException) {
|
|
316
|
+
Thread.currentThread().interrupt()
|
|
317
|
+
false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (!completed) {
|
|
321
|
+
isCancelled.set(true)
|
|
322
|
+
mainHandler.removeCallbacks(callback)
|
|
323
|
+
return FALLBACK_JSON
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return result.get()
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
companion object {
|
|
330
|
+
const val NAME = "ClintplayRN"
|
|
331
|
+
private const val VERSION = "0.1.0"
|
|
332
|
+
private const val FALLBACK_JSON = "{\"version\":1,\"nodes\":[]}"
|
|
333
|
+
|
|
334
|
+
private val NO_METHOD_SENTINEL = Any()
|
|
335
|
+
private val getColorMethodCache = ConcurrentHashMap<Class<*>, Any>()
|
|
336
|
+
|
|
337
|
+
@Volatile
|
|
338
|
+
private var cachedNativeIdResId: Int? = null
|
|
339
|
+
@Volatile
|
|
340
|
+
private var cachedTestIdResId: Int? = null
|
|
341
|
+
|
|
342
|
+
private fun resolveNativeIdResId(context: Context): Int {
|
|
343
|
+
cachedNativeIdResId?.let { return it }
|
|
344
|
+
var id = context.resources.getIdentifier("view_tag_native_id", "id", context.packageName)
|
|
345
|
+
if (id == 0) {
|
|
346
|
+
id = context.resources.getIdentifier("view_tag_native_id", "id", "com.facebook.react")
|
|
347
|
+
}
|
|
348
|
+
cachedNativeIdResId = id
|
|
349
|
+
return id
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private fun resolveTestIdResId(context: Context): Int {
|
|
353
|
+
cachedTestIdResId?.let { return it }
|
|
354
|
+
var id = context.resources.getIdentifier("react_test_id", "id", context.packageName)
|
|
355
|
+
if (id == 0) {
|
|
356
|
+
id = context.resources.getIdentifier("react_test_id", "id", "com.facebook.react")
|
|
357
|
+
}
|
|
358
|
+
cachedTestIdResId = id
|
|
359
|
+
return id
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
package digital.clint.clintplay
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.TurboReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
import com.facebook.react.uimanager.ViewManager
|
|
9
|
+
|
|
10
|
+
// Registers the Clintplay TurboModule with the consumer's React
|
|
11
|
+
// Native runtime. Supports both New Architecture (TurboModule) and
|
|
12
|
+
// Old Architecture (Paper / Bridge) seamlessly.
|
|
13
|
+
class ClintplayRNPackage : TurboReactPackage() {
|
|
14
|
+
|
|
15
|
+
override fun getModule(
|
|
16
|
+
name: String,
|
|
17
|
+
reactContext: ReactApplicationContext,
|
|
18
|
+
): NativeModule? {
|
|
19
|
+
return if (name == ClintplayRNModule.NAME) {
|
|
20
|
+
ClintplayRNModule(reactContext)
|
|
21
|
+
} else {
|
|
22
|
+
null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
|
|
27
|
+
return ReactModuleInfoProvider {
|
|
28
|
+
mapOf(
|
|
29
|
+
ClintplayRNModule.NAME to ReactModuleInfo(
|
|
30
|
+
ClintplayRNModule.NAME,
|
|
31
|
+
ClintplayRNModule::class.java.name,
|
|
32
|
+
false, // canOverrideExistingModule
|
|
33
|
+
false, // needsEagerInit
|
|
34
|
+
false, // hasConstants
|
|
35
|
+
false, // isCxxModule
|
|
36
|
+
true // isTurboModule
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override fun createNativeModules(
|
|
43
|
+
reactContext: ReactApplicationContext,
|
|
44
|
+
): List<NativeModule> = listOf(ClintplayRNModule(reactContext))
|
|
45
|
+
|
|
46
|
+
override fun createViewManagers(
|
|
47
|
+
reactContext: ReactApplicationContext,
|
|
48
|
+
): List<ViewManager<*, *>> = emptyList()
|
|
49
|
+
}
|