From 1e36d58a0c778b3564d6a240677aa5ccfdb38f7d Mon Sep 17 00:00:00 2001 From: Abdelhamid Nasser <38096011+abdelhamid-f-nasser@users.noreply.github.com> Date: Sat, 10 Feb 2024 07:23:36 +0200 Subject: [PATCH] feat: support react native buttons label extraction in user steps (#1109) Jira ID: - IBGCRASH-21078 - IBGCRASH-21213 --- CHANGELOG.md | 3 +- .../RNInstabugReactnativeModule.java | 4 + .../utils/RNTouchedViewExtractor.java | 167 ++++++++++++++++++ examples/default/src/App.tsx | 6 +- .../user-steps/BasicComponentsScreen.tsx | 148 ++++++++++++++-- 5 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 android/src/main/java/com/instabug/reactlibrary/utils/RNTouchedViewExtractor.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 936f525a5..5885120e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Added -- Support user identification using ID ([#1115](https://github.com/Instabug/Instabug-React-Native/pull/1115)) +- Support user identification using ID ([#1115](https://github.com/Instabug/Instabug-React-Native/pull/1115)). +- Support button detection and label extraction for repro steps ([#1109](https://github.com/Instabug/Instabug-React-Native/pull/1109)). ### Changed diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java index 32bf02a30..514b98f7d 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java @@ -27,6 +27,7 @@ import com.instabug.library.IssueType; import com.instabug.library.LogLevel; import com.instabug.library.ReproConfigurations; +import com.instabug.library.core.InstabugCore; import com.instabug.library.internal.module.InstabugLocale; import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.logging.InstabugLog; @@ -37,6 +38,7 @@ import com.instabug.reactlibrary.utils.EventEmitterModule; import com.instabug.reactlibrary.utils.MainThreadHandler; +import com.instabug.reactlibrary.utils.RNTouchedViewExtractor; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; @@ -133,6 +135,8 @@ public void init( MainThreadHandler.runOnMainThread(new Runnable() { @Override public void run() { + final RNTouchedViewExtractor rnTouchedViewExtractor = new RNTouchedViewExtractor(); + InstabugCore.setTouchedViewExtractorExtension(rnTouchedViewExtractor); final ArrayList keys = ArrayUtil.parseReadableArrayOfStrings(invocationEventValues); final ArrayList parsedInvocationEvents = ArgsRegistry.invocationEvents.getAll(keys); final InstabugInvocationEvent[] invocationEvents = parsedInvocationEvents.toArray(new InstabugInvocationEvent[0]); diff --git a/android/src/main/java/com/instabug/reactlibrary/utils/RNTouchedViewExtractor.java b/android/src/main/java/com/instabug/reactlibrary/utils/RNTouchedViewExtractor.java new file mode 100644 index 000000000..b500bd65c --- /dev/null +++ b/android/src/main/java/com/instabug/reactlibrary/utils/RNTouchedViewExtractor.java @@ -0,0 +1,167 @@ +package com.instabug.reactlibrary.utils; + +import android.text.TextUtils; +import android.view.View; +import android.view.ViewParent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.facebook.react.views.text.ReactTextView; +import com.facebook.react.views.view.ReactViewGroup; +import com.instabug.library.core.InstabugCore; +import com.instabug.library.visualusersteps.TouchedView; +import com.instabug.library.visualusersteps.TouchedViewExtractor; +import com.instabug.library.visualusersteps.VisualUserStepsHelper; + +public class RNTouchedViewExtractor implements TouchedViewExtractor { + + private final int depthTraversalLimit = 3; + + /** + * Determines whether the native Android SDK should depend on native extraction + * when a label is not found by the RNTouchedViewExtractor. + * + *

+ * - {@code RNTouchedViewExtractor} tries to find a label. + *
+ * - If it returns a label, the view is labeled with the one returned. + *
+ * - If it returns {@code null}: + *
+ * - If {@code shouldDependOnNative} is {@code true}, the native Android SDK + * will try to extract the label from the view. + *
+ * - If it's {@code false}, the Android SDK will label it {@code null} as returned + * from {@code RNTouchedViewExtractor} without trying to label it. + *

+ * + * @return {@code true} if the native Android SDK should depend on native extraction, + * {@code false} otherwise. + */ + @Override + public boolean getShouldDependOnNative() { + return true; + } + + + @Nullable + @Override + public TouchedView extract(@NonNull View view, @NonNull TouchedView touchedView) { + ReactViewGroup reactViewGroup = findReactButtonViewGroup(view); + // If no button is found return `null` to leave the extraction of the touched view to the native Android SDK. + if (reactViewGroup == null) return null; + return getExtractionStrategy(reactViewGroup).extract(reactViewGroup, touchedView); + } + + @Nullable + private ReactViewGroup findReactButtonViewGroup(@NonNull View startView) { + if (isReactButtonViewGroup(startView)) return (ReactViewGroup) startView; + ViewParent currentParent = startView.getParent(); + int depth = 1; + do { + if (currentParent == null || isReactButtonViewGroup(currentParent)) + return (ReactViewGroup) currentParent; + currentParent = currentParent.getParent(); + depth++; + } while (depth < depthTraversalLimit); + return null; + } + + private boolean isReactButtonViewGroup(@NonNull View view) { + return (view instanceof ReactViewGroup) && view.isFocusable() && view.isClickable(); + } + + private boolean isReactButtonViewGroup(@NonNull ViewParent viewParent) { + if (!(viewParent instanceof ReactViewGroup)) return false; + ReactViewGroup group = (ReactViewGroup) viewParent; + return group.isFocusable() && group.isClickable(); + } + + private ReactButtonExtractionStrategy getExtractionStrategy(ReactViewGroup reactButton) { + boolean isPrivateView = VisualUserStepsHelper.isPrivateView(reactButton); + if (isPrivateView) return new PrivateViewLabelExtractionStrategy(); + + int labelsCount = 0; + int groupsCount = 0; + for (int index = 0; index < reactButton.getChildCount(); index++) { + View currentView = reactButton.getChildAt(index); + if (currentView instanceof ReactTextView) { + + labelsCount++; + continue; + } + if (currentView instanceof ReactViewGroup) { + groupsCount++; + } + } + if (labelsCount > 1 || groupsCount > 0) return new MultiLabelsExtractionStrategy(); + if (labelsCount == 1) return new SingleLabelExtractionStrategy(); + return new NoLabelsExtractionStrategy(); + } + + interface ReactButtonExtractionStrategy { + @Nullable + TouchedView extract(ReactViewGroup reactButton, TouchedView touchedView); + } + + class MultiLabelsExtractionStrategy implements ReactButtonExtractionStrategy { + private final String MULTI_LABEL_BUTTON_PRE_STRING = "a button that contains \"%s\""; + + @Override + @Nullable + public TouchedView extract(ReactViewGroup reactButton, TouchedView touchedView) { + + touchedView.setProminentLabel( + InstabugCore.composeProminentLabelForViewGroup(reactButton, MULTI_LABEL_BUTTON_PRE_STRING) + ); + return touchedView; + } + } + + class PrivateViewLabelExtractionStrategy implements ReactButtonExtractionStrategy { + + private final String PRIVATE_VIEW_LABEL_BUTTON_PRE_STRING = "a button"; + + @Override + public TouchedView extract(ReactViewGroup reactButton, TouchedView touchedView) { + touchedView.setProminentLabel(PRIVATE_VIEW_LABEL_BUTTON_PRE_STRING); + return touchedView; + } + } + + class SingleLabelExtractionStrategy implements ReactButtonExtractionStrategy { + + @Override + public TouchedView extract(ReactViewGroup reactButton, TouchedView touchedView) { + ReactTextView targetLabel = null; + for (int index = 0; index < reactButton.getChildCount(); index++) { + View currentView = reactButton.getChildAt(index); + if (!(currentView instanceof ReactTextView)) continue; + targetLabel = (ReactTextView) currentView; + break; + } + if (targetLabel == null) return touchedView; + + String labelText = getLabelText(targetLabel); + touchedView.setProminentLabel(InstabugCore.composeProminentLabelFor(labelText, false)); + return touchedView; + } + + @Nullable + private String getLabelText(ReactTextView textView) { + String labelText = null; + if (!TextUtils.isEmpty(textView.getText())) { + labelText = textView.getText().toString(); + } else if (!TextUtils.isEmpty(textView.getContentDescription())) { + labelText = textView.getContentDescription().toString(); + } + return labelText; + } + } + + class NoLabelsExtractionStrategy implements ReactButtonExtractionStrategy { + @Override + public TouchedView extract(ReactViewGroup reactButton, TouchedView touchedView) { + return touchedView; + } + } +} diff --git a/examples/default/src/App.tsx b/examples/default/src/App.tsx index 6dff03618..7f302c5d1 100644 --- a/examples/default/src/App.tsx +++ b/examples/default/src/App.tsx @@ -3,7 +3,7 @@ import { StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer } from '@react-navigation/native'; -import Instabug, { InvocationEvent, LogLevel } from 'instabug-reactnative'; +import Instabug, { InvocationEvent, LogLevel, ReproStepsMode } from 'instabug-reactnative'; import { NativeBaseProvider } from 'native-base'; import { RootTabNavigator } from './navigation/RootTab'; @@ -17,6 +17,10 @@ export const App: React.FC = () => { invocationEvents: [InvocationEvent.floatingButton], debugLogsLevel: LogLevel.verbose, }); + + Instabug.setReproStepsConfig({ + all: ReproStepsMode.enabled, + }); }, []); return ( diff --git a/examples/default/src/screens/user-steps/BasicComponentsScreen.tsx b/examples/default/src/screens/user-steps/BasicComponentsScreen.tsx index 16aa55b8c..6a3ded568 100644 --- a/examples/default/src/screens/user-steps/BasicComponentsScreen.tsx +++ b/examples/default/src/screens/user-steps/BasicComponentsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { Alert, Button, @@ -10,24 +10,41 @@ import { Switch, useWindowDimensions, ActivityIndicator, + View, + TextInput, } from 'react-native'; import Slider from '@react-native-community/slider'; import { Center, HStack, ScrollView, VStack } from 'native-base'; +import Instabug from 'instabug-reactnative'; import { Screen } from '../../components/Screen'; import { Section } from '../../components/Section'; import { nativeBaseTheme } from '../../theme/nativeBaseTheme'; +import Icon from 'react-native-vector-icons/Ionicons'; import { InputField } from '../../components/InputField'; /** - * A screen that demonstates the usage of user steps with basic React Native components. + * A screen that demonstrates the usage of user steps with basic React Native components. * * This specific screen doesn't use NativeBase in some parts since we need to focus on * capturing React Native provided components rather than implementations built on top of it. */ export const BasicComponentsScreen: React.FC = () => { - const [isSwitchOn, setIsSwitchOn] = useState(false); const { width } = useWindowDimensions(); + const [isSwitchOn, setIsSwitchOn] = useState(false); + const textRef = useRef(null); + const imageRef = useRef(null); + const textInputRef = useRef(null); + const buttonRef = useRef