Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RUM-7801] iOS: Session Replay Text Recording in New Architecture #766

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,24 @@ public class DdSessionReplayImplementation: NSObject {
private lazy var sessionReplay: SessionReplayProtocol = sessionReplayProvider()
private let sessionReplayProvider: () -> SessionReplayProtocol
private let uiManager: RCTUIManager
private let fabricWrapper: RCTFabricWrapper

internal init(sessionReplayProvider: @escaping () -> SessionReplayProtocol, uiManager: RCTUIManager) {
internal init(
sessionReplayProvider: @escaping () -> SessionReplayProtocol,
uiManager: RCTUIManager,
fabricWrapper: RCTFabricWrapper
) {
self.sessionReplayProvider = sessionReplayProvider
self.uiManager = uiManager
self.fabricWrapper = fabricWrapper
}

@objc
public convenience init(bridge: RCTBridge) {
self.init(
sessionReplayProvider: { NativeSessionReplay() },
uiManager: bridge.uiManager
uiManager: bridge.uiManager,
fabricWrapper: RCTFabricWrapper()
)
}

Expand All @@ -44,6 +51,7 @@ public class DdSessionReplayImplementation: NSObject {
if (customEndpoint != "") {
customEndpointURL = URL(string: "\(customEndpoint)/api/v2/replay" as String)
}

var sessionReplayConfiguration = SessionReplay.Configuration(
replaySampleRate: Float(replaySampleRate),
textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel),
Expand All @@ -53,7 +61,9 @@ public class DdSessionReplayImplementation: NSObject {
customEndpoint: customEndpointURL
)

sessionReplayConfiguration.setAdditionalNodeRecorders([RCTTextViewRecorder(uiManager: self.uiManager)])
sessionReplayConfiguration.setAdditionalNodeRecorders([
RCTTextViewRecorder(uiManager: uiManager, fabricWrapper: fabricWrapper)
])

if let core = DatadogSDKWrapper.shared.getCoreInstance() {
sessionReplay.enable(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
#import <Foundation/Foundation.h>
#import "RCTTextPropertiesWrapper.h"

@interface RCTFabricWrapper : NSObject

- (nullable RCTTextPropertiesWrapper*)tryToExtractTextPropertiesFromView:(UIView* _Nonnull)view;

@end
106 changes: 106 additions & 0 deletions packages/react-native-session-replay/ios/Sources/RCTFabricWrapper.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

#import "RCTFabricWrapper.h"

#if RCT_NEW_ARCH_ENABLED
#import <React-RCTFabric/React/RCTParagraphComponentView.h>
#import <React-Fabric/react/renderer/components/text/ParagraphProps.h>
namespace rct = facebook::react;
#endif

@implementation RCTFabricWrapper
/**
* Extracts the text properties from the given UIView when the view is of type RCTParagraphComponentView, returns nil otherwise.
*/
- (nullable RCTTextPropertiesWrapper*)tryToExtractTextPropertiesFromView:(UIView *)view {
#if RCT_NEW_ARCH_ENABLED
if (![view isKindOfClass:[RCTParagraphComponentView class]]) {
return nil;
}

// Cast view to RCTParagraphComponentView
RCTParagraphComponentView* paragraphComponentView = (RCTParagraphComponentView *)view;
if (paragraphComponentView == nil) {
return nil;
}

// Retrieve ParagraphProps from shared pointer
const rct::ParagraphProps* props = (rct::ParagraphProps*)paragraphComponentView.props.get();
if (props == nil) {
return nil;
}

// Extract Attributes
RCTTextPropertiesWrapper* textPropertiesWrapper = [[RCTTextPropertiesWrapper alloc] init];
textPropertiesWrapper.text = [RCTFabricWrapper getTextFromView:paragraphComponentView];
textPropertiesWrapper.contentRect = paragraphComponentView.bounds;

rct::TextAttributes textAttributes = props->textAttributes;
textPropertiesWrapper.alignment = [RCTFabricWrapper getAlignmentFromAttributes:textAttributes];
textPropertiesWrapper.foregroundColor = [RCTFabricWrapper getForegroundColorFromAttributes:textAttributes];
textPropertiesWrapper.fontSize = [RCTFabricWrapper getFontSizeFromAttributes:textAttributes];

return textPropertiesWrapper;
#else
return nil;
#endif
}

#if RCT_NEW_ARCH_ENABLED
+ (NSString* _Nonnull)getTextFromView:(RCTParagraphComponentView*)view {
if (view == nil || view.attributedText == nil) {
return RCTTextPropertiesDefaultText;
}

return view.attributedText.string;
}

+ (NSTextAlignment)getAlignmentFromAttributes:(rct::TextAttributes)textAttributes {
const rct::TextAlignment alignment = textAttributes.alignment.has_value() ?
textAttributes.alignment.value() :
rct::TextAlignment::Natural;

switch (alignment) {
case rct::TextAlignment::Natural:
return NSTextAlignmentNatural;

case rct::TextAlignment::Left:
return NSTextAlignmentLeft;

case rct::TextAlignment::Center:
return NSTextAlignmentCenter;

case rct::TextAlignment::Right:
return NSTextAlignmentRight;

case rct::TextAlignment::Justified:
return NSTextAlignmentJustified;

default:
return RCTTextPropertiesDefaultAlignment;
}
}

+ (UIColor* _Nonnull)getForegroundColorFromAttributes:(rct::TextAttributes)textAttributes {
@try {
rct::Color color = *textAttributes.foregroundColor;
UIColor* uiColor = (__bridge UIColor*)color.getUIColor().get();
if (uiColor != nil) {
return uiColor;
}
} @catch (NSException *exception) {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what can throw exception here?


return RCTTextPropertiesDefaultForegroundColor;
}

+ (CGFloat)getFontSizeFromAttributes:(rct::TextAttributes)textAttributes {
// Float is just an alias for CGFloat, but this could change in the future.
_Static_assert(sizeof(rct::Float) == sizeof(CGFloat), "Float and CGFloat are expected to have the same size.");
return isnan(textAttributes.fontSize) ? RCTTextPropertiesDefaultFontSize : (CGFloat)textAttributes.fontSize;
}
#endif
@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

@interface RCTTextPropertiesWrapper : NSObject

extern NSString* const RCTTextPropertiesDefaultText;
extern NSTextAlignment const RCTTextPropertiesDefaultAlignment;
extern UIColor* const RCTTextPropertiesDefaultForegroundColor;
extern CGFloat const RCTTextPropertiesDefaultFontSize;
extern CGRect const RCTTextPropertiesDefaultContentRect;

@property (nonatomic, strong, nonnull) NSString* text;
@property (nonatomic, assign) NSTextAlignment alignment;
@property (nonatomic, strong, nonnull) UIColor* foregroundColor;
@property (nonatomic, assign) CGFloat fontSize;
@property (nonatomic, assign) CGRect contentRect;

- (instancetype _Nonnull) init;

@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
#import "RCTTextPropertiesWrapper.h"

@implementation RCTTextPropertiesWrapper

NSString* const RCTTextPropertiesDefaultText = @"";
NSTextAlignment const RCTTextPropertiesDefaultAlignment = NSTextAlignmentNatural;
UIColor* const RCTTextPropertiesDefaultForegroundColor = [UIColor blackColor];
CGFloat const RCTTextPropertiesDefaultFontSize = 14.0;
CGRect const RCTTextPropertiesDefaultContentRect = CGRectZero;

- (instancetype)init {
self = [super init];
if (self) {
_text = RCTTextPropertiesDefaultText;
_alignment = RCTTextPropertiesDefaultAlignment;
_foregroundColor = RCTTextPropertiesDefaultForegroundColor;
_fontSize = RCTTextPropertiesDefaultFontSize;
_contentRect = RCTTextPropertiesDefaultContentRect;
}
return self;
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,60 @@ internal class RCTTextViewRecorder: SessionReplayNodeRecorder {
internal var identifier = UUID()

internal let uiManager: RCTUIManager
internal let fabricWrapper: RCTFabricWrapper

internal init(uiManager: RCTUIManager) {
internal init(uiManager: RCTUIManager, fabricWrapper: RCTFabricWrapper) {
self.uiManager = uiManager
}

internal func extractTextFromSubViews(
subviews: [RCTShadowView]?
) -> String? {
if let subviews = subviews {
return subviews.compactMap { subview in
if let sub = subview as? RCTRawTextShadowView {
return sub.text
}
if let sub = subview as? RCTVirtualTextShadowView {
// We recursively get all subviews for nested Text components
return extractTextFromSubViews(subviews: sub.reactSubviews())
}
return nil
}.joined()
}
return nil
self.fabricWrapper = fabricWrapper
}

public func semantics(
of view: UIView,
with attributes: SessionReplayViewAttributes,
in context: SessionReplayViewTreeRecordingContext
) -> SessionReplayNodeSemantics? {
guard
let textProperties = fabricWrapper.tryToExtractTextProperties(from: view) ?? tryToExtractTextProperties(view: view)
else {
return view is RCTTextView ? SessionReplayInvisibleElement.constant : nil
}

let builder = RCTTextViewWireframesBuilder(
wireframeID: context.ids.nodeID(view: view, nodeRecorder: self),
attributes: attributes,
text: textProperties.text,
textAlignment: textProperties.alignment,
textColor: textProperties.foregroundColor,
textObfuscator: textObfuscator(context),
fontSize: textProperties.fontSize,
contentRect: textProperties.contentRect
)

return SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [
SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder)
])
}

internal func tryToExtractTextFromSubViews(
subviews: [RCTShadowView]?
) -> String? {
guard let subviews = subviews else {
return nil
}

return subviews.compactMap { subview in
if let sub = subview as? RCTRawTextShadowView {
return sub.text
}
if let sub = subview as? RCTVirtualTextShadowView {
// We recursively get all subviews for nested Text components
return tryToExtractTextFromSubViews(subviews: sub.reactSubviews())
}
return nil
}.joined()
}

private func tryToExtractTextProperties(view: UIView) -> RCTTextPropertiesWrapper? {
guard let textView = view as? RCTTextView else {
return nil
}
Expand All @@ -56,41 +82,35 @@ internal class RCTTextViewRecorder: SessionReplayNodeRecorder {
shadowView = uiManager.shadowView(forReactTag: tag) as? RCTTextShadowView
}

if let shadow = shadowView {
// TODO: RUM-2173 check performance is ok
let text = extractTextFromSubViews(
subviews: shadow.reactSubviews()
)
guard let shadow = shadowView else {
return nil
}

let builder = RCTTextViewWireframesBuilder(
wireframeID: context.ids.nodeID(view: textView, nodeRecorder: self),
attributes: attributes,
text: text,
textAlignment: shadow.textAttributes.alignment,
textColor: shadow.textAttributes.foregroundColor?.cgColor,
textObfuscator: textObfuscator(context),
fontSize: shadow.textAttributes.fontSize,
contentRect: shadow.contentFrame
)
let node = SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder)
return SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [node])
let textProperties = RCTTextPropertiesWrapper()

// TODO: RUM-2173 check performance is ok
if let text = tryToExtractTextFromSubViews(subviews: shadow.reactSubviews()) {
textProperties.text = text
}
return SessionReplayInvisibleElement.constant
}
}

// Black color. This is the default for RN: https://github.com/facebook/react-native/blob/a5ee029cd02a636136058d82919480eeeb700067/packages/react-native/Libraries/Text/RCTTextAttributes.mm#L250
let DEFAULT_COLOR = UIColor.black.cgColor
if let foregroundColor = shadow.textAttributes.foregroundColor {
textProperties.foregroundColor = foregroundColor
}

// Default font size for RN: https://github.com/facebook/react-native/blob/16dff523b0a16d7fa9b651062c386885c2f48a6b/packages/react-native/React/Views/RCTFont.mm#L396
let DEFAULT_FONT_SIZE = CGFloat(14)
textProperties.alignment = shadow.textAttributes.alignment
textProperties.fontSize = shadow.textAttributes.fontSize
textProperties.contentRect = shadow.contentFrame

return textProperties
}
}

internal struct RCTTextViewWireframesBuilder: SessionReplayNodeWireframesBuilder {
let wireframeID: WireframeID
let attributes: SessionReplayViewAttributes
let text: String?
let text: String
var textAlignment: NSTextAlignment
let textColor: CGColor?
let textColor: UIColor
let textObfuscator: SessionReplayTextObfuscating
let fontSize: CGFloat
let contentRect: CGRect
Expand Down Expand Up @@ -140,12 +160,12 @@ internal struct RCTTextViewWireframesBuilder: SessionReplayNodeWireframesBuilder
id: wireframeID,
frame: relativeIntersectedRect,
clip: attributes.clip,
text: textObfuscator.mask(text: text ?? ""),
text: textObfuscator.mask(text: text),
textFrame: textFrame,
// Text alignment is top for all RCTTextView components.
// Text alignment is top for all RCTTextView and RCTParagraphComponentView components.
textAlignment: .init(systemTextAlignment: textAlignment, vertical: .top),
textColor: textColor ?? DEFAULT_COLOR,
fontOverride: SessionReplayWireframesBuilder.FontOverride(size: fontSize.isNaN ? DEFAULT_FONT_SIZE : fontSize),
textColor: textColor.cgColor,
fontOverride: SessionReplayWireframesBuilder.FontOverride(size: fontSize.isNaN ? RCTTextPropertiesDefaultFontSize : fontSize),
borderColor: attributes.layerBorderColor,
borderWidth: attributes.layerBorderWidth,
backgroundColor: attributes.backgroundColor,
Expand Down
Loading
Loading