Skip to content

Commit

Permalink
feat(android): implement bug reporting
Browse files Browse the repository at this point in the history
  • Loading branch information
abhaysood committed Feb 25, 2025
1 parent 75420d1 commit 5d3e1de
Show file tree
Hide file tree
Showing 120 changed files with 4,251 additions and 310 deletions.
2 changes: 1 addition & 1 deletion android/benchmarks/app/src/main/res/values/colors.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="msr_white">#FFFFFFFF</color>
</resources>
4 changes: 2 additions & 2 deletions android/benchmarks/app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<resources>
<!-- Base application theme. -->
<style name="Theme.Measure" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorOnPrimary">@color/msr_white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
Expand Down
2 changes: 1 addition & 1 deletion android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ measure-android = "0.10.0-SNAPSHOT"
androidx-activity-compose = "1.7.2"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.12.0"
androidx-core-ktx = "1.15.0"
androidx-junit = "1.1.5"
androidx-compose-ui = "1.4.3"
androidx-compose-material3 = "1.0.1"
Expand Down
2 changes: 1 addition & 1 deletion android/measure-android-gradle/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ MEASURE_PLUGIN_GROUP_ID=sh.measure
MEASURE_PLUGIN_ARTIFACT_ID=sh.measure.android.gradle.gradle.plugin
MEASURE_PLUGIN_VERSION_NAME=0.8.0-SNAPSHOT

RELEASE_SIGNING_ENABLED = true
RELEASE_SIGNING_ENABLED = false
25 changes: 25 additions & 0 deletions android/measure/api/measure.api
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,47 @@ public final class sh/measure/android/BuildConfig {
public final class sh/measure/android/Measure {
public static final field $stable I
public static final field INSTANCE Lsh/measure/android/Measure;
public final fun captureLayoutSnapshot (Landroid/app/Activity;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V
public static synthetic fun captureLayoutSnapshot$default (Lsh/measure/android/Measure;Landroid/app/Activity;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
public final fun captureScreenshot (Landroid/app/Activity;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V
public static synthetic fun captureScreenshot$default (Lsh/measure/android/Measure;Landroid/app/Activity;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
public static final fun clearUserId ()V
public final fun createSpanBuilder (Ljava/lang/String;)Lsh/measure/android/tracing/SpanBuilder;
public final fun disableShakeToLaunchBugReport ()V
public final fun enableShakeToLaunchBugReport (Z)V
public static synthetic fun enableShakeToLaunchBugReport$default (Lsh/measure/android/Measure;ZILjava/lang/Object;)V
public final fun getCurrentTime ()J
public final fun getSessionId ()Ljava/lang/String;
public final fun getTraceParentHeaderKey ()Ljava/lang/String;
public final fun getTraceParentHeaderValue (Lsh/measure/android/tracing/Span;)Ljava/lang/String;
public final fun imageUriToAttachment (Landroid/content/Context;Landroid/net/Uri;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)V
public static final fun init (Landroid/content/Context;)V
public static final fun init (Landroid/content/Context;Lsh/measure/android/config/MeasureConfig;)V
public static synthetic fun init$default (Landroid/content/Context;Lsh/measure/android/config/MeasureConfig;ILjava/lang/Object;)V
public final fun isShakeToLaunchBugReportEnabled ()Z
public final fun launchBugReportActivity (ZLjava/util/Map;)V
public static synthetic fun launchBugReportActivity$default (Lsh/measure/android/Measure;ZLjava/util/Map;ILjava/lang/Object;)V
public final fun setShakeListener (Lsh/measure/android/bugreport/ShakeListener;)V
public static final fun setUserId (Ljava/lang/String;)V
public final fun start ()V
public final fun startSpan (Ljava/lang/String;)Lsh/measure/android/tracing/Span;
public final fun startSpan (Ljava/lang/String;J)Lsh/measure/android/tracing/Span;
public final fun stop ()V
public final fun trackBugReport (Ljava/lang/String;Ljava/util/List;Ljava/util/Map;)V
public static synthetic fun trackBugReport$default (Lsh/measure/android/Measure;Ljava/lang/String;Ljava/util/List;Ljava/util/Map;ILjava/lang/Object;)V
public final fun trackEvent (Ljava/lang/String;Ljava/util/Map;Ljava/lang/Long;)V
public static synthetic fun trackEvent$default (Lsh/measure/android/Measure;Ljava/lang/String;Ljava/util/Map;Ljava/lang/Long;ILjava/lang/Object;)V
public static final fun trackHandledException (Ljava/lang/Throwable;)V
public static final fun trackScreenView (Ljava/lang/String;)V
}

public final class sh/measure/android/MsrAttachment {
public static final field $stable I
public final fun getBytes ()[B
public final fun getName ()Ljava/lang/String;
public final fun getType ()Ljava/lang/String;
}

public abstract interface class sh/measure/android/attributes/AttributeValue {
public static final field Companion Lsh/measure/android/attributes/AttributeValue$Companion;
public abstract fun getValue ()Ljava/lang/Object;
Expand Down Expand Up @@ -139,6 +160,10 @@ public final class sh/measure/android/attributes/StringAttr : sh/measure/android
public final synthetic fun unbox-impl ()Ljava/lang/String;
}

public abstract interface class sh/measure/android/bugreport/ShakeListener {
public abstract fun onShake ()V
}

public final class sh/measure/android/config/MeasureConfig : sh/measure/android/config/IMeasureConfig {
public static final field $stable I
public fun <init> ()V
Expand Down
1 change: 1 addition & 0 deletions android/measure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)

implementation(libs.androidx.annotation)
implementation(libs.androidx.core.ktx)
implementation(libs.squareup.okio)
implementation(libs.squareup.curtains)

Expand Down
3 changes: 2 additions & 1 deletion android/measure/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />

<!--
Permissions required to ensure the tests do not get blocked by
Permissions required to ensure the android tests do not crash
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<application
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package sh.measure.android

import android.view.View
import android.view.ViewGroup
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import org.hamcrest.Description
import org.hamcrest.Matcher
import org.hamcrest.TypeSafeMatcher

/**
* Helper function to wait for a view to be displayed with a timeout.
* @param viewMatcher The matcher for the view to find
* @param timeoutMillis Maximum time to wait in milliseconds
* @param intervalMillis Time to wait between checks in milliseconds
* @return True if the view was found within the timeout, false otherwise
*/
fun waitForViewToBeDisplayed(
viewMatcher: Matcher<View>,
timeoutMillis: Long = 3000,
intervalMillis: Long = 100,
): Boolean {
val startTime = System.currentTimeMillis()
var viewFound = false

while (!viewFound && System.currentTimeMillis() - startTime < timeoutMillis) {
try {
onView(viewMatcher).check(matches(isDisplayed()))
viewFound = true
} catch (e: NoMatchingViewException) {
Thread.sleep(intervalMillis)
}
}

return viewFound
}

/**
* Returns a matcher that matches a view that is the nth child of a parent view that matches the given parent matcher.
*
* This matcher can be used to find a specific child view at a certain position within a parent view,
* which is useful when multiple similar child views exist and you need to target a specific one by position.
*
* @param parentMatcher The matcher that will match the parent of the view
* @param childPosition The position of the child view to match (0-based index)
* @return A Matcher<View> that matches a view at the specified child position within a parent matching parentMatcher
*/
fun nthChildOf(parentMatcher: Matcher<View>, childPosition: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("with $childPosition child view of type parentMatcher")
}

override fun matchesSafely(view: View): Boolean {
if (view.parent !is ViewGroup) return false
val parent = view.parent as ViewGroup

return parentMatcher.matches(parent) && parent.getChildAt(childPosition) == view
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,23 @@ class EventsTest {
}
}

@Test
fun tracksBugReportEvent() {
// Given
robot.initializeMeasure(MeasureConfig(enableLogging = true))
ActivityScenario.launch(TestActivity::class.java).use {
// When
it.moveToState(Lifecycle.State.RESUMED)
it.onActivity {
robot.trackBugReport("description", listOf())
}
triggerExport()

// Then
assertEventTracked(EventType.BUG_REPORT)
}
}

private fun String.containsEvent(eventType: String): Boolean {
return contains("\"type\":\"$eventType\"")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,9 @@ class EventsTestRobot {
}.build(),
)
}

fun trackBugReport(description: String, attachments: List<MsrAttachment>) {
Measure.trackBugReport(description, attachments)
device.waitForIdle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,9 @@ class FakeConfigProvider : ConfigProvider {
override val maxCheckpointsPerSpan: Int = 100
override val maxInMemorySignalsQueueSize: Int = 30
override val inMemorySignalsQueueFlushRateMs: Long = 3000
override val maxAttachmentsInBugReport: Int = 5
override val maxDescriptionLengthInBugReport: Int = 15
override val shakeAccelerationThreshold: Float = 3.5f
override val shakeMinTimeIntervalMs: Long = 1000
override val shakeSlop: Int = 2
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package sh.measure.android

import sh.measure.android.events.Event
import sh.measure.android.storage.SignalStore
import sh.measure.android.tracing.SpanData

internal class FakeSignalStore : SignalStore {
val trackedEvents = mutableListOf<Event<*>>()
val trackedSpans = mutableListOf<SpanData>()

override fun <T> store(event: Event<T>) {
trackedEvents.add(event)
}

override fun store(spanData: SpanData) {
trackedSpans.add(spanData)
}

override fun flush() {
// No-op
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package sh.measure.android

import android.app.Application
import android.content.res.Configuration
import android.view.ViewGroup
import androidx.test.espresso.Espresso
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import org.hamcrest.core.AllOf.allOf
import org.junit.Assert
import sh.measure.android.config.MeasureConfig
import sh.measure.android.events.EventType

internal class MsrBugReportActivityRobot {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.context.applicationContext
private val device = UiDevice.getInstance(instrumentation)
private val signalStore = FakeSignalStore()

fun initializeMeasure(config: MeasureConfig = MeasureConfig()): TestMeasureInitializer {
val initializer = TestMeasureInitializer(
application = context as Application,
inputConfig = config,
signalStore = signalStore,
)
Measure.initForInstrumentationTest(initializer)
return initializer
}

fun assertSendCtaEnabled(enabled: Boolean) {
onView(withId(R.id.tv_send)).check(matches(isDisplayed())).check { view, _ ->
Assert.assertEquals(enabled, view.isEnabled)
}
}

fun enterDescription(length: Int = 100) {
onView(withId(R.id.et_description)).perform(replaceText("a".repeat(length)))
.perform(closeSoftKeyboard())
device.waitForIdle()
}

fun assertBugReportActivityLaunched() {
waitForViewToBeDisplayed(withId(R.id.tv_title), 3000)
onView(withId(R.id.tv_title)).check(matches(isDisplayed()))
}

fun assertBugReportActivityNotVisible() {
device.waitForIdle()
onView(withId(R.id.tv_title)).check(doesNotExist())
}

fun assertTotalScreenshots(value: Int) {
onView(withId(R.id.sl_screenshots_container)).check { view, _ ->
val viewGroup = view as ViewGroup
Assert.assertEquals(value, viewGroup.childCount)
}
}

fun clickCloseButton() {
onView(withId(R.id.btn_close)).perform(click())
}

fun removeScreenshot(index: Int) {
onView(
allOf(
withId(R.id.closeButton),
isDescendantOfA(nthChildOf(withId(R.id.sl_screenshots_container), index)),
),
).perform(click())
}

fun clickSendCTA() {
Espresso.closeSoftKeyboard()
device.waitForIdle()
onView(withId(R.id.tv_send)).perform(click())
}

fun assetBugReportTracked(attachmentCount: Int = 0, userDefinedAttrCount: Int = 0) {
device.waitForIdle()
val event = signalStore.trackedEvents.find {
it.type == EventType.BUG_REPORT
}
Assert.assertNotNull(event)
Assert.assertEquals(attachmentCount, event?.attachments?.size)
Assert.assertEquals(userDefinedAttrCount, event?.userDefinedAttributes?.size)
}

fun triggerConfigurationChange() {
val currentOrientation = context.resources.configuration.orientation
when (currentOrientation) {
Configuration.ORIENTATION_PORTRAIT -> device.setOrientationLeft()
Configuration.ORIENTATION_LANDSCAPE -> device.setOrientationNatural()
}
device.waitForIdle()
}
}
Loading

0 comments on commit 5d3e1de

Please sign in to comment.