@multiplayer-app/session-recorder-react-native 0.0.1-beta.4 → 0.0.1-beta.5
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/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingModule.kt +202 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/ScreenMaskingPackage.kt +16 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderModule.kt +202 -0
- package/android/src/main/java/com/multiplayer/sessionrecorder/SessionRecorderPackage.kt +16 -0
- package/dist/components/MaskableComponent.d.ts +22 -0
- package/dist/components/MaskableComponent.js +1 -0
- package/dist/components/MaskableComponent.js.map +1 -0
- package/dist/components/MaskableTextInput.d.ts +14 -0
- package/dist/components/MaskableTextInput.js +1 -0
- package/dist/components/MaskableTextInput.js.map +1 -0
- package/dist/components/SessionRecorderWidget/FinalPopover.js +1 -1
- package/dist/components/SessionRecorderWidget/FinalPopover.js.map +1 -1
- package/dist/components/SessionRecorderWidget/FloatingButton.js +1 -1
- package/dist/components/SessionRecorderWidget/FloatingButton.js.map +1 -1
- package/dist/components/SessionRecorderWidget/InitialPopover.js +1 -1
- package/dist/components/SessionRecorderWidget/InitialPopover.js.map +1 -1
- package/dist/components/SessionRecorderWidget/ModalContainer.js +1 -1
- package/dist/components/SessionRecorderWidget/ModalContainer.js.map +1 -1
- package/dist/components/SessionRecorderWidget/ModalHeader.d.ts +6 -0
- package/dist/components/SessionRecorderWidget/ModalHeader.js +1 -0
- package/dist/components/SessionRecorderWidget/ModalHeader.js.map +1 -0
- package/dist/components/SessionRecorderWidget/icons.d.ts +1 -0
- package/dist/components/SessionRecorderWidget/icons.js +1 -1
- package/dist/components/SessionRecorderWidget/icons.js.map +1 -1
- package/dist/components/SessionRecorderWidget/styles.d.ts +39 -22
- package/dist/components/SessionRecorderWidget/styles.js +1 -1
- package/dist/components/SessionRecorderWidget/styles.js.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/config/defaults.js +1 -1
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/masking.js +1 -1
- package/dist/config/masking.js.map +1 -1
- package/dist/native/ScreenMasking.d.ts +21 -0
- package/dist/native/ScreenMasking.js +1 -0
- package/dist/native/ScreenMasking.js.map +1 -0
- package/dist/native/SessionRecorderNative.d.ts +21 -0
- package/dist/native/SessionRecorderNative.js +1 -0
- package/dist/native/SessionRecorderNative.js.map +1 -0
- package/dist/recorder/screenRecorder.d.ts +1 -0
- package/dist/recorder/screenRecorder.js +1 -1
- package/dist/recorder/screenRecorder.js.map +1 -1
- package/dist/recorder/screenshotManager.d.ts +10 -0
- package/dist/recorder/screenshotManager.js +1 -0
- package/dist/recorder/screenshotManager.js.map +1 -0
- package/dist/services/screenMaskingService.d.ts +39 -0
- package/dist/services/screenMaskingService.js +1 -0
- package/dist/services/screenMaskingService.js.map +1 -0
- package/dist/types/session-recorder.d.ts +6 -0
- package/dist/types/session-recorder.js.map +1 -1
- package/dist/utils/componentRegistry.d.ts +64 -0
- package/dist/utils/componentRegistry.js +1 -0
- package/dist/utils/componentRegistry.js.map +1 -0
- package/dist/utils/reactNativeHierarchyExtractor.d.ts +38 -0
- package/dist/utils/reactNativeHierarchyExtractor.js +1 -0
- package/dist/utils/reactNativeHierarchyExtractor.js.map +1 -0
- package/dist/utils/screenshotMasker.d.ts +96 -0
- package/dist/utils/screenshotMasker.js +1 -0
- package/dist/utils/screenshotMasker.js.map +1 -0
- package/dist/utils/viewHierarchyTracker.d.ts +89 -0
- package/dist/utils/viewHierarchyTracker.js +1 -0
- package/dist/utils/viewHierarchyTracker.js.map +1 -0
- package/ios/ScreenMasking.m +12 -0
- package/ios/ScreenMasking.podspec +21 -0
- package/ios/ScreenMasking.swift +205 -0
- package/ios/SessionRecorder.m +12 -0
- package/ios/SessionRecorder.podspec +21 -0
- package/ios/SessionRecorder.swift +205 -0
- package/package.json +10 -1
- package/react-native.config.js +15 -0
- package/src/components/SessionRecorderWidget/FinalPopover.tsx +5 -16
- package/src/components/SessionRecorderWidget/FloatingButton.tsx +14 -27
- package/src/components/SessionRecorderWidget/InitialPopover.tsx +3 -9
- package/src/components/SessionRecorderWidget/ModalContainer.tsx +77 -29
- package/src/components/SessionRecorderWidget/ModalHeader.tsx +24 -0
- package/src/components/SessionRecorderWidget/icons.tsx +9 -0
- package/src/components/SessionRecorderWidget/styles.ts +48 -35
- package/src/components/index.ts +3 -1
- package/src/config/defaults.ts +1 -0
- package/src/config/masking.ts +1 -0
- package/src/native/ScreenMasking.ts +34 -0
- package/src/native/SessionRecorderNative.ts +34 -0
- package/src/recorder/screenRecorder.ts +31 -2
- package/src/services/screenMaskingService.ts +114 -0
- package/src/types/session-recorder.ts +7 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@objc(SessionRecorder)
|
|
5
|
+
class SessionRecorder: NSObject {
|
|
6
|
+
|
|
7
|
+
@objc func captureAndMask(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
8
|
+
DispatchQueue.main.async {
|
|
9
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
10
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
15
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
16
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
17
|
+
UIGraphicsEndImageContext()
|
|
18
|
+
|
|
19
|
+
guard let image = screenshot else {
|
|
20
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Apply masking to sensitive elements
|
|
25
|
+
let maskedImage = self.applyMasking(to: image, in: window)
|
|
26
|
+
|
|
27
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
28
|
+
let base64 = data.base64EncodedString()
|
|
29
|
+
resolve(base64)
|
|
30
|
+
} else {
|
|
31
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@objc func captureAndMaskWithOptions(_ options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
37
|
+
DispatchQueue.main.async {
|
|
38
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
39
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
44
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
45
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
46
|
+
UIGraphicsEndImageContext()
|
|
47
|
+
|
|
48
|
+
guard let image = screenshot else {
|
|
49
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Apply masking with custom options
|
|
54
|
+
let maskedImage = self.applyMaskingWithOptions(to: image, in: window, options: options)
|
|
55
|
+
|
|
56
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
57
|
+
let base64 = data.base64EncodedString()
|
|
58
|
+
resolve(base64)
|
|
59
|
+
} else {
|
|
60
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func applyMasking(to image: UIImage, in window: UIWindow) -> UIImage {
|
|
66
|
+
return applyMaskingWithOptions(to: image, in: window, options: [:])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func applyMaskingWithOptions(to image: UIImage, in window: UIWindow, options: NSDictionary) -> UIImage {
|
|
70
|
+
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
|
|
71
|
+
guard let context = UIGraphicsGetCurrentContext() else { return image }
|
|
72
|
+
|
|
73
|
+
// Draw the original image
|
|
74
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
75
|
+
|
|
76
|
+
// Find and mask sensitive elements
|
|
77
|
+
let sensitiveElements = findSensitiveElements(in: window)
|
|
78
|
+
|
|
79
|
+
for element in sensitiveElements {
|
|
80
|
+
let frame = element.frame
|
|
81
|
+
let maskingType = getMaskingType(for: element)
|
|
82
|
+
|
|
83
|
+
switch maskingType {
|
|
84
|
+
case .blur:
|
|
85
|
+
applyBlurMask(in: context, frame: frame)
|
|
86
|
+
case .rectangle:
|
|
87
|
+
applyRectangleMask(in: context, frame: frame)
|
|
88
|
+
case .pixelate:
|
|
89
|
+
applyPixelateMask(in: context, frame: frame, image: image)
|
|
90
|
+
case .none:
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let maskedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image
|
|
96
|
+
UIGraphicsEndImageContext()
|
|
97
|
+
|
|
98
|
+
return maskedImage
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func findSensitiveElements(in view: UIView) -> [UIView] {
|
|
102
|
+
var sensitiveElements: [UIView] = []
|
|
103
|
+
|
|
104
|
+
func traverseView(_ view: UIView) {
|
|
105
|
+
// Check if this view should be masked
|
|
106
|
+
if shouldMaskView(view) {
|
|
107
|
+
sensitiveElements.append(view)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recursively check subviews
|
|
111
|
+
for subview in view.subviews {
|
|
112
|
+
traverseView(subview)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
traverseView(view)
|
|
117
|
+
return sensitiveElements
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func shouldMaskView(_ view: UIView) -> Bool {
|
|
121
|
+
// Check for UITextField - mask all text fields when inputMasking is enabled
|
|
122
|
+
if view is UITextField {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check for UITextView - mask all text views when inputMasking is enabled
|
|
127
|
+
if view is UITextView {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func getMaskingType(for view: UIView) -> MaskingType {
|
|
135
|
+
// Default masking type for all text inputs
|
|
136
|
+
return .rectangle
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func applyBlurMask(in context: CGContext, frame: CGRect) {
|
|
140
|
+
// Create a blur effect
|
|
141
|
+
context.setFillColor(UIColor.black.withAlphaComponent(0.8).cgColor)
|
|
142
|
+
context.fill(frame)
|
|
143
|
+
|
|
144
|
+
// Add some noise to make it look blurred
|
|
145
|
+
context.setFillColor(UIColor.white.withAlphaComponent(0.3).cgColor)
|
|
146
|
+
for _ in 0..<20 {
|
|
147
|
+
let randomX = frame.origin.x + CGFloat.random(in: 0...frame.width)
|
|
148
|
+
let randomY = frame.origin.y + CGFloat.random(in: 0...frame.height)
|
|
149
|
+
let randomSize = CGFloat.random(in: 2...8)
|
|
150
|
+
context.fillEllipse(in: CGRect(x: randomX, y: randomY, width: randomSize, height: randomSize))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func applyRectangleMask(in context: CGContext, frame: CGRect) {
|
|
155
|
+
// Simple rectangle fill
|
|
156
|
+
context.setFillColor(UIColor.gray.cgColor)
|
|
157
|
+
context.fill(frame)
|
|
158
|
+
|
|
159
|
+
// Add some text-like pattern
|
|
160
|
+
context.setFillColor(UIColor.darkGray.cgColor)
|
|
161
|
+
let lineHeight: CGFloat = 4
|
|
162
|
+
let spacing: CGFloat = 8
|
|
163
|
+
|
|
164
|
+
for i in stride(from: frame.origin.y + spacing, to: frame.origin.y + frame.height - spacing, by: lineHeight + spacing) {
|
|
165
|
+
let lineWidth = CGFloat.random(in: frame.width * 0.3...frame.width * 0.8)
|
|
166
|
+
let lineX = frame.origin.x + CGFloat.random(in: 0...(frame.width - lineWidth))
|
|
167
|
+
context.fill(CGRect(x: lineX, y: i, width: lineWidth, height: lineHeight))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func applyPixelateMask(in context: CGContext, frame: CGRect, image: UIImage) {
|
|
172
|
+
// Create a pixelated effect
|
|
173
|
+
let pixelSize: CGFloat = 8
|
|
174
|
+
let pixelCountX = Int(frame.width / pixelSize)
|
|
175
|
+
let pixelCountY = Int(frame.height / pixelSize)
|
|
176
|
+
|
|
177
|
+
for x in 0..<pixelCountX {
|
|
178
|
+
for y in 0..<pixelCountY {
|
|
179
|
+
let pixelFrame = CGRect(
|
|
180
|
+
x: frame.origin.x + CGFloat(x) * pixelSize,
|
|
181
|
+
y: frame.origin.y + CGFloat(y) * pixelSize,
|
|
182
|
+
width: pixelSize,
|
|
183
|
+
height: pixelSize
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Use a random color for each pixel
|
|
187
|
+
let randomColor = UIColor(
|
|
188
|
+
red: CGFloat.random(in: 0...1),
|
|
189
|
+
green: CGFloat.random(in: 0...1),
|
|
190
|
+
blue: CGFloat.random(in: 0...1),
|
|
191
|
+
alpha: 1.0
|
|
192
|
+
)
|
|
193
|
+
context.setFillColor(randomColor.cgColor)
|
|
194
|
+
context.fill(pixelFrame)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private enum MaskingType {
|
|
201
|
+
case blur
|
|
202
|
+
case rectangle
|
|
203
|
+
case pixelate
|
|
204
|
+
case none
|
|
205
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
|
|
3
|
+
@interface RCT_EXTERN_MODULE(SessionRecorder, NSObject)
|
|
4
|
+
|
|
5
|
+
RCT_EXTERN_METHOD(captureAndMask:(RCTPromiseResolveBlock)resolve
|
|
6
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
7
|
+
|
|
8
|
+
RCT_EXTERN_METHOD(captureAndMaskWithOptions:(NSDictionary *)options
|
|
9
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
10
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
11
|
+
|
|
12
|
+
@end
|
|
@@ -0,0 +1,21 @@
|
|
|
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 = "SessionRecorder"
|
|
7
|
+
s.version = package["version"]
|
|
8
|
+
s.summary = "Native session recorder module for React Native"
|
|
9
|
+
s.description = "A native module that provides session recording with automatic masking of sensitive UI elements"
|
|
10
|
+
s.homepage = "https://github.com/multiplayer-app/multiplayer-session-recorder-javascript"
|
|
11
|
+
s.license = "MIT"
|
|
12
|
+
s.authors = { "Multiplayer Software, Inc." => "https://www.multiplayer.app" }
|
|
13
|
+
s.platforms = { :ios => "12.0" }
|
|
14
|
+
s.source = { :git => "https://github.com/multiplayer-app/multiplayer-session-recorder-javascript.git", :tag => "#{s.version}" }
|
|
15
|
+
|
|
16
|
+
s.source_files = "ios/**/*.{h,m,mm,swift}"
|
|
17
|
+
s.requires_arc = true
|
|
18
|
+
|
|
19
|
+
s.dependency "React-Core"
|
|
20
|
+
s.dependency "React"
|
|
21
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@objc(SessionRecorder)
|
|
5
|
+
class SessionRecorder: NSObject {
|
|
6
|
+
|
|
7
|
+
@objc func captureAndMask(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
8
|
+
DispatchQueue.main.async {
|
|
9
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
10
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
15
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
16
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
17
|
+
UIGraphicsEndImageContext()
|
|
18
|
+
|
|
19
|
+
guard let image = screenshot else {
|
|
20
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Apply masking to sensitive elements
|
|
25
|
+
let maskedImage = self.applyMasking(to: image, in: window)
|
|
26
|
+
|
|
27
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
28
|
+
let base64 = data.base64EncodedString()
|
|
29
|
+
resolve(base64)
|
|
30
|
+
} else {
|
|
31
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@objc func captureAndMaskWithOptions(_ options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
37
|
+
DispatchQueue.main.async {
|
|
38
|
+
guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
|
|
39
|
+
reject("NO_WINDOW", "Unable to get key window", nil)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
UIGraphicsBeginImageContextWithOptions(window.bounds.size, false, UIScreen.main.scale)
|
|
44
|
+
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
45
|
+
let screenshot = UIGraphicsGetImageFromCurrentImageContext()
|
|
46
|
+
UIGraphicsEndImageContext()
|
|
47
|
+
|
|
48
|
+
guard let image = screenshot else {
|
|
49
|
+
reject("CAPTURE_FAILED", "Failed to capture screen", nil)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Apply masking with custom options
|
|
54
|
+
let maskedImage = self.applyMaskingWithOptions(to: image, in: window, options: options)
|
|
55
|
+
|
|
56
|
+
if let data = maskedImage.jpegData(compressionQuality: 0.5) {
|
|
57
|
+
let base64 = data.base64EncodedString()
|
|
58
|
+
resolve(base64)
|
|
59
|
+
} else {
|
|
60
|
+
reject("ENCODING_FAILED", "Failed to encode image", nil)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func applyMasking(to image: UIImage, in window: UIWindow) -> UIImage {
|
|
66
|
+
return applyMaskingWithOptions(to: image, in: window, options: [:])
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func applyMaskingWithOptions(to image: UIImage, in window: UIWindow, options: NSDictionary) -> UIImage {
|
|
70
|
+
UIGraphicsBeginImageContextWithOptions(image.size, false, image.scale)
|
|
71
|
+
guard let context = UIGraphicsGetCurrentContext() else { return image }
|
|
72
|
+
|
|
73
|
+
// Draw the original image
|
|
74
|
+
image.draw(in: CGRect(origin: .zero, size: image.size))
|
|
75
|
+
|
|
76
|
+
// Find and mask sensitive elements
|
|
77
|
+
let sensitiveElements = findSensitiveElements(in: window)
|
|
78
|
+
|
|
79
|
+
for element in sensitiveElements {
|
|
80
|
+
let frame = element.frame
|
|
81
|
+
let maskingType = getMaskingType(for: element)
|
|
82
|
+
|
|
83
|
+
switch maskingType {
|
|
84
|
+
case .blur:
|
|
85
|
+
applyBlurMask(in: context, frame: frame)
|
|
86
|
+
case .rectangle:
|
|
87
|
+
applyRectangleMask(in: context, frame: frame)
|
|
88
|
+
case .pixelate:
|
|
89
|
+
applyPixelateMask(in: context, frame: frame, image: image)
|
|
90
|
+
case .none:
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let maskedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image
|
|
96
|
+
UIGraphicsEndImageContext()
|
|
97
|
+
|
|
98
|
+
return maskedImage
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func findSensitiveElements(in view: UIView) -> [UIView] {
|
|
102
|
+
var sensitiveElements: [UIView] = []
|
|
103
|
+
|
|
104
|
+
func traverseView(_ view: UIView) {
|
|
105
|
+
// Check if this view should be masked
|
|
106
|
+
if shouldMaskView(view) {
|
|
107
|
+
sensitiveElements.append(view)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Recursively check subviews
|
|
111
|
+
for subview in view.subviews {
|
|
112
|
+
traverseView(subview)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
traverseView(view)
|
|
117
|
+
return sensitiveElements
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func shouldMaskView(_ view: UIView) -> Bool {
|
|
121
|
+
// Check for UITextField - mask all text fields when inputMasking is enabled
|
|
122
|
+
if view is UITextField {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Check for UITextView - mask all text views when inputMasking is enabled
|
|
127
|
+
if view is UITextView {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func getMaskingType(for view: UIView) -> MaskingType {
|
|
135
|
+
// Default masking type for all text inputs
|
|
136
|
+
return .rectangle
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private func applyBlurMask(in context: CGContext, frame: CGRect) {
|
|
140
|
+
// Create a blur effect
|
|
141
|
+
context.setFillColor(UIColor.black.withAlphaComponent(0.8).cgColor)
|
|
142
|
+
context.fill(frame)
|
|
143
|
+
|
|
144
|
+
// Add some noise to make it look blurred
|
|
145
|
+
context.setFillColor(UIColor.white.withAlphaComponent(0.3).cgColor)
|
|
146
|
+
for _ in 0..<20 {
|
|
147
|
+
let randomX = frame.origin.x + CGFloat.random(in: 0...frame.width)
|
|
148
|
+
let randomY = frame.origin.y + CGFloat.random(in: 0...frame.height)
|
|
149
|
+
let randomSize = CGFloat.random(in: 2...8)
|
|
150
|
+
context.fillEllipse(in: CGRect(x: randomX, y: randomY, width: randomSize, height: randomSize))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private func applyRectangleMask(in context: CGContext, frame: CGRect) {
|
|
155
|
+
// Simple rectangle fill
|
|
156
|
+
context.setFillColor(UIColor.gray.cgColor)
|
|
157
|
+
context.fill(frame)
|
|
158
|
+
|
|
159
|
+
// Add some text-like pattern
|
|
160
|
+
context.setFillColor(UIColor.darkGray.cgColor)
|
|
161
|
+
let lineHeight: CGFloat = 4
|
|
162
|
+
let spacing: CGFloat = 8
|
|
163
|
+
|
|
164
|
+
for i in stride(from: frame.origin.y + spacing, to: frame.origin.y + frame.height - spacing, by: lineHeight + spacing) {
|
|
165
|
+
let lineWidth = CGFloat.random(in: frame.width * 0.3...frame.width * 0.8)
|
|
166
|
+
let lineX = frame.origin.x + CGFloat.random(in: 0...(frame.width - lineWidth))
|
|
167
|
+
context.fill(CGRect(x: lineX, y: i, width: lineWidth, height: lineHeight))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private func applyPixelateMask(in context: CGContext, frame: CGRect, image: UIImage) {
|
|
172
|
+
// Create a pixelated effect
|
|
173
|
+
let pixelSize: CGFloat = 8
|
|
174
|
+
let pixelCountX = Int(frame.width / pixelSize)
|
|
175
|
+
let pixelCountY = Int(frame.height / pixelSize)
|
|
176
|
+
|
|
177
|
+
for x in 0..<pixelCountX {
|
|
178
|
+
for y in 0..<pixelCountY {
|
|
179
|
+
let pixelFrame = CGRect(
|
|
180
|
+
x: frame.origin.x + CGFloat(x) * pixelSize,
|
|
181
|
+
y: frame.origin.y + CGFloat(y) * pixelSize,
|
|
182
|
+
width: pixelSize,
|
|
183
|
+
height: pixelSize
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Use a random color for each pixel
|
|
187
|
+
let randomColor = UIColor(
|
|
188
|
+
red: CGFloat.random(in: 0...1),
|
|
189
|
+
green: CGFloat.random(in: 0...1),
|
|
190
|
+
blue: CGFloat.random(in: 0...1),
|
|
191
|
+
alpha: 1.0
|
|
192
|
+
)
|
|
193
|
+
context.setFillColor(randomColor.cgColor)
|
|
194
|
+
context.fill(pixelFrame)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private enum MaskingType {
|
|
201
|
+
case blur
|
|
202
|
+
case rectangle
|
|
203
|
+
case pixelate
|
|
204
|
+
case none
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplayer-app/session-recorder-react-native",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.5",
|
|
4
4
|
"description": "Multiplayer Fullstack Session Recorder for React Native",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Multiplayer Software, Inc.",
|
|
@@ -88,5 +88,14 @@
|
|
|
88
88
|
},
|
|
89
89
|
"optionalDependencies": {
|
|
90
90
|
"expo-constants": "^15.0.0"
|
|
91
|
+
},
|
|
92
|
+
"react-native": {
|
|
93
|
+
"ios": {
|
|
94
|
+
"podspecPath": "./ios/SessionRecorder.podspec"
|
|
95
|
+
},
|
|
96
|
+
"android": {
|
|
97
|
+
"sourceDir": "./android",
|
|
98
|
+
"packageImportPath": "import com.multiplayer.sessionrecorder.SessionRecorderPackage;"
|
|
99
|
+
}
|
|
91
100
|
}
|
|
92
101
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dependencies: {
|
|
3
|
+
'react-native-session-recorder': {
|
|
4
|
+
platforms: {
|
|
5
|
+
android: {
|
|
6
|
+
sourceDir: '../android',
|
|
7
|
+
packageImportPath: 'import com.multiplayer.sessionrecorder.SessionRecorderPackage;'
|
|
8
|
+
},
|
|
9
|
+
ios: {
|
|
10
|
+
podspecPath: '../ios/SessionRecorder.podspec'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { View, Text, Pressable, TextInput,
|
|
2
|
+
import { View, Text, Pressable, TextInput, Alert } from 'react-native'
|
|
3
3
|
import { WidgetTextOverridesConfig } from '../../types'
|
|
4
4
|
import { sharedStyles } from './styles'
|
|
5
|
+
import ModalHeader from './ModalHeader'
|
|
5
6
|
|
|
6
7
|
interface FinalPopoverProps {
|
|
7
8
|
textOverrides: WidgetTextOverridesConfig
|
|
@@ -11,13 +12,7 @@ interface FinalPopoverProps {
|
|
|
11
12
|
isSubmitting: boolean
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const FinalPopover: React.FC<FinalPopoverProps> = ({
|
|
15
|
-
textOverrides,
|
|
16
|
-
onStopRecording,
|
|
17
|
-
onCancelSession,
|
|
18
|
-
onClose,
|
|
19
|
-
isSubmitting
|
|
20
|
-
}) => {
|
|
15
|
+
const FinalPopover: React.FC<FinalPopoverProps> = ({ textOverrides, onStopRecording, onCancelSession, isSubmitting }) => {
|
|
21
16
|
const [comment, setComment] = useState('')
|
|
22
17
|
|
|
23
18
|
const handleStopRecording = async () => {
|
|
@@ -30,17 +25,11 @@ const FinalPopover: React.FC<FinalPopoverProps> = ({
|
|
|
30
25
|
|
|
31
26
|
return (
|
|
32
27
|
<View style={sharedStyles.popoverContent}>
|
|
33
|
-
<
|
|
34
|
-
<Pressable onPress={() => Linking.openURL('https://www.multiplayer.app')}>
|
|
35
|
-
<Text style={sharedStyles.logoText}>Multiplayer</Text>
|
|
36
|
-
</Pressable>
|
|
28
|
+
<ModalHeader>
|
|
37
29
|
<Pressable onPress={onCancelSession} style={sharedStyles.cancelButton}>
|
|
38
30
|
<Text style={sharedStyles.cancelButtonText}>{textOverrides.cancelButtonText}</Text>
|
|
39
31
|
</Pressable>
|
|
40
|
-
|
|
41
|
-
<Text style={sharedStyles.closeButtonText}>×</Text>
|
|
42
|
-
</Pressable>
|
|
43
|
-
</View>
|
|
32
|
+
</ModalHeader>
|
|
44
33
|
|
|
45
34
|
<View style={sharedStyles.popoverBody}>
|
|
46
35
|
<Text style={sharedStyles.title}>{textOverrides.finalTitle}</Text>
|
|
@@ -8,20 +8,21 @@ interface FloatingButtonProps {
|
|
|
8
8
|
sessionState: SessionState | null
|
|
9
9
|
onPress: () => void
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
const buttonSize = 52
|
|
12
13
|
const rightOffset = 20
|
|
13
14
|
const topOffset = Platform.OS === 'ios' ? 60 : 40
|
|
14
15
|
|
|
15
16
|
const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }) => {
|
|
16
|
-
const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current
|
|
17
|
-
const lastPosition = useRef({ top: topOffset, right: rightOffset })
|
|
18
|
-
const storageService = useRef(StorageService.getInstance()).current
|
|
17
|
+
const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current
|
|
18
|
+
const lastPosition = useRef({ top: topOffset, right: rightOffset })
|
|
19
|
+
const storageService = useRef(StorageService.getInstance()).current
|
|
19
20
|
|
|
20
21
|
const screenBounds = useMemo(() => {
|
|
21
22
|
const { width, height } = Dimensions.get('window')
|
|
22
23
|
|
|
23
24
|
return {
|
|
24
|
-
minTop: topOffset,
|
|
25
|
+
minTop: topOffset,
|
|
25
26
|
maxTop: height - buttonSize,
|
|
26
27
|
minRight: 0,
|
|
27
28
|
maxRight: width - buttonSize
|
|
@@ -32,14 +33,12 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }
|
|
|
32
33
|
useEffect(() => {
|
|
33
34
|
const savedPosition = storageService.getFloatingButtonPosition()
|
|
34
35
|
if (savedPosition) {
|
|
35
|
-
|
|
36
|
-
const { width, height } = Dimensions.get('window')
|
|
36
|
+
const { width } = Dimensions.get('window')
|
|
37
37
|
const top = savedPosition.y
|
|
38
38
|
const right = width - savedPosition.x - buttonSize
|
|
39
39
|
lastPosition.current = { top, right }
|
|
40
40
|
position.setValue({ x: right, y: top })
|
|
41
41
|
} else {
|
|
42
|
-
// Set default position
|
|
43
42
|
position.setValue({ x: lastPosition.current.right, y: lastPosition.current.top })
|
|
44
43
|
}
|
|
45
44
|
}, [])
|
|
@@ -48,7 +47,6 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }
|
|
|
48
47
|
PanResponder.create({
|
|
49
48
|
onStartShouldSetPanResponder: () => true,
|
|
50
49
|
onMoveShouldSetPanResponder: (evt, gestureState) => {
|
|
51
|
-
// Only start dragging if movement is significant enough
|
|
52
50
|
const distance = Math.sqrt(gestureState.dx * gestureState.dx + gestureState.dy * gestureState.dy)
|
|
53
51
|
return distance > 5
|
|
54
52
|
},
|
|
@@ -59,7 +57,7 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }
|
|
|
59
57
|
onPanResponderMove: (evt, gestureState) => {
|
|
60
58
|
// Calculate new position based on gesture movement
|
|
61
59
|
const newTop = lastPosition.current.top + gestureState.dy
|
|
62
|
-
const newRight = lastPosition.current.right - gestureState.dx
|
|
60
|
+
const newRight = lastPosition.current.right - gestureState.dx
|
|
63
61
|
|
|
64
62
|
// Update position during drag
|
|
65
63
|
position.setValue({ x: newRight, y: newTop })
|
|
@@ -74,7 +72,7 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }
|
|
|
74
72
|
} else {
|
|
75
73
|
// Calculate new position after dragging
|
|
76
74
|
const newTop = lastPosition.current.top + gestureState.dy
|
|
77
|
-
const newRight = lastPosition.current.right - gestureState.dx
|
|
75
|
+
const newRight = lastPosition.current.right - gestureState.dx
|
|
78
76
|
|
|
79
77
|
// Clamp to screen bounds
|
|
80
78
|
const clampedTop = Math.max(screenBounds.minTop, Math.min(screenBounds.maxTop, newTop))
|
|
@@ -99,31 +97,20 @@ const FloatingButton: React.FC<FloatingButtonProps> = ({ sessionState, onPress }
|
|
|
99
97
|
).current
|
|
100
98
|
|
|
101
99
|
// Memoized button icon and color for performance
|
|
102
|
-
const
|
|
103
|
-
switch (sessionState) {
|
|
104
|
-
case SessionState.started:
|
|
105
|
-
return <CapturingIcon size={24} color='white' />
|
|
106
|
-
case SessionState.paused:
|
|
107
|
-
return <PausedIcon size={24} color='white' />
|
|
108
|
-
default:
|
|
109
|
-
return <RecordIcon size={19} color='white' />
|
|
110
|
-
}
|
|
111
|
-
}, [sessionState])
|
|
112
|
-
|
|
113
|
-
const buttonColor = useMemo(() => {
|
|
100
|
+
const content = useMemo(() => {
|
|
114
101
|
switch (sessionState) {
|
|
115
102
|
case SessionState.started:
|
|
116
|
-
return '#FF4444'
|
|
103
|
+
return { icon: <CapturingIcon size={28} color='white' />, color: '#FF4444' }
|
|
117
104
|
case SessionState.paused:
|
|
118
|
-
return '#FFA500'
|
|
105
|
+
return { icon: <PausedIcon size={28} color='white' />, color: '#FFA500' }
|
|
119
106
|
default:
|
|
120
|
-
return '#
|
|
107
|
+
return { icon: <RecordIcon size={28} color='#718096' />, color: '#ffffff' }
|
|
121
108
|
}
|
|
122
109
|
}, [sessionState])
|
|
123
110
|
|
|
124
111
|
return (
|
|
125
112
|
<Animated.View style={[styles.draggableButton, { top: position.y, right: position.x }]} {...panResponder.panHandlers}>
|
|
126
|
-
<View style={[styles.floatingButton, { backgroundColor:
|
|
113
|
+
<View style={[styles.floatingButton, { backgroundColor: content.color }]}>{content.icon}</View>
|
|
127
114
|
</Animated.View>
|
|
128
115
|
)
|
|
129
116
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { View, Text, Pressable,
|
|
2
|
+
import { View, Text, Pressable, Alert, Switch } from 'react-native'
|
|
3
3
|
import { SessionType } from '@multiplayer-app/session-recorder-common'
|
|
4
4
|
import { WidgetTextOverridesConfig } from '../../types'
|
|
5
5
|
import { sharedStyles } from './styles'
|
|
6
|
+
import ModalHeader from './ModalHeader'
|
|
6
7
|
|
|
7
8
|
interface InitialPopoverProps {
|
|
8
9
|
textOverrides: WidgetTextOverridesConfig
|
|
@@ -34,14 +35,7 @@ const InitialPopover: React.FC<InitialPopoverProps> = ({
|
|
|
34
35
|
|
|
35
36
|
return (
|
|
36
37
|
<View style={sharedStyles.popoverContent}>
|
|
37
|
-
<
|
|
38
|
-
<Pressable onPress={() => Linking.openURL('https://www.multiplayer.app')}>
|
|
39
|
-
<Text style={sharedStyles.logoText}>Multiplayer</Text>
|
|
40
|
-
</Pressable>
|
|
41
|
-
<Pressable onPress={onClose} style={sharedStyles.closeButton}>
|
|
42
|
-
<Text style={sharedStyles.closeButtonText}>×</Text>
|
|
43
|
-
</Pressable>
|
|
44
|
-
</View>
|
|
38
|
+
<ModalHeader />
|
|
45
39
|
|
|
46
40
|
<View style={sharedStyles.popoverBody}>
|
|
47
41
|
{showContinuousRecording && (
|