diff --git a/android/benchmarks/app/src/main/res/values/colors.xml b/android/benchmarks/app/src/main/res/values/colors.xml
index f8c6127d3..3ef22891e 100644
--- a/android/benchmarks/app/src/main/res/values/colors.xml
+++ b/android/benchmarks/app/src/main/res/values/colors.xml
@@ -6,5 +6,5 @@
#FF03DAC5
#FF018786
#FF000000
- #FFFFFFFF
+ #FFFFFFFF
\ No newline at end of file
diff --git a/android/benchmarks/app/src/main/res/values/themes.xml b/android/benchmarks/app/src/main/res/values/themes.xml
index e152c92de..dc703deb0 100644
--- a/android/benchmarks/app/src/main/res/values/themes.xml
+++ b/android/benchmarks/app/src/main/res/values/themes.xml
@@ -1,10 +1,10 @@
-
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/measure/src/test/java/sh/measure/android/bugreport/BugReportCollectorImplTest.kt b/android/measure/src/test/java/sh/measure/android/bugreport/BugReportCollectorImplTest.kt
new file mode 100644
index 000000000..9cd2850fa
--- /dev/null
+++ b/android/measure/src/test/java/sh/measure/android/bugreport/BugReportCollectorImplTest.kt
@@ -0,0 +1,157 @@
+package sh.measure.android.bugreport
+
+import android.app.Activity.RESULT_CANCELED
+import android.app.Activity.RESULT_OK
+import android.app.Application
+import android.content.ClipData
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.net.Uri
+import androidx.concurrent.futures.ResolvableFuture
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mockito.any
+import org.mockito.Mockito.mock
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.isNull
+import org.mockito.kotlin.verify
+import sh.measure.android.events.Attachment
+import sh.measure.android.events.EventType
+import sh.measure.android.events.SignalProcessor
+import sh.measure.android.fakes.FakeConfigProvider
+import sh.measure.android.fakes.FakeIdProvider
+import sh.measure.android.fakes.FakeSessionManager
+import sh.measure.android.fakes.ImmediateExecutorService
+import sh.measure.android.fakes.NoopLogger
+import sh.measure.android.logger.Logger
+import sh.measure.android.storage.FileStorageImpl
+import sh.measure.android.utils.AndroidTimeProvider
+import sh.measure.android.utils.TestClock
+import java.io.File
+import java.io.FileOutputStream
+
+@RunWith(AndroidJUnit4::class)
+class BugReportCollectorImplTest {
+ private val logger: Logger = NoopLogger()
+ private val executorService = ImmediateExecutorService(ResolvableFuture.create())
+ private val signalProcessor: SignalProcessor = mock()
+ private val timeProvider = AndroidTimeProvider(TestClock.create())
+ private val application = InstrumentationRegistry.getInstrumentation().context as Application
+ private val sessionManager = FakeSessionManager()
+ private val fileStorage = FileStorageImpl(application.filesDir.path, logger)
+ private val idProvider = FakeIdProvider()
+ private val configProvider = FakeConfigProvider()
+ private val bugReportCollector = BugReportCollectorImpl(
+ logger = logger,
+ signalProcessor = signalProcessor,
+ timeProvider = timeProvider,
+ ioExecutor = executorService,
+ configProvider = configProvider,
+ sessionManager = sessionManager,
+ fileStorage = fileStorage,
+ idProvider = idProvider,
+ )
+
+ @Test
+ fun `tracks bug report event and updates session for reporting`() {
+ // Given
+ val attachmentsCaptor = argumentCaptor>()
+ val attachments = createTestFiles(count = 2).map {
+ ParcelableAttachment(it.name, it.path)
+ }
+ val uris = createTestFiles(count = 3).map { Uri.fromFile(it) }
+ val description = "description"
+
+ // When
+ bugReportCollector.track(application, description, attachments, uris)
+
+ // Then
+ verify(signalProcessor).track(
+ data = eq(BugReportData(description)),
+ timestamp = eq(timeProvider.now()),
+ type = eq(EventType.BUG_REPORT),
+ attributes = eq(emptyMap().toMutableMap()),
+ userDefinedAttributes = eq(emptyMap()),
+ attachments = attachmentsCaptor.capture(),
+ threadName = any(),
+ sessionId = isNull(),
+ userTriggered = eq(false),
+ )
+ assertEquals(5, attachmentsCaptor.firstValue.size)
+ assertTrue(sessionManager.markedSessionWithBugReport)
+ }
+
+ @Test
+ fun `onImagePickedResult should load multiple URIs from clipData`() {
+ val clipData = createTestClipData(3)
+ val intent = createTestIntent(clipData = clipData)
+
+ val result = bugReportCollector.onImagePickedResult(application, RESULT_OK, intent)
+
+ assertEquals(3, result.size)
+ }
+
+ @Test
+ fun `onImagePickedResult should load single URI from intent data`() {
+ val uri = Uri.parse("content://test/1")
+ val intent = createTestIntent(singleUri = uri)
+
+ val result = bugReportCollector.onImagePickedResult(application, RESULT_OK, intent)
+
+ assertEquals(1, result.size)
+ assertEquals(uri, result.first())
+ }
+
+ @Test
+ fun `onImagePickedResult should return empty list when resultCode is not OK`() {
+ val uri = Uri.parse("content://test/1")
+ val intent = createTestIntent(singleUri = uri)
+
+ val result = bugReportCollector.onImagePickedResult(application, RESULT_CANCELED, intent)
+
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `onImagePickedResult should return empty list when intent data is null`() {
+ val result = bugReportCollector.onImagePickedResult(application, RESULT_OK, null)
+ assertTrue(result.isEmpty())
+ }
+
+ private fun createTestClipData(@Suppress("SameParameterValue") uriCount: Int = 3): ClipData {
+ val uris = createTestFiles(uriCount).map { Uri.fromFile(it) }
+ return ClipData.newUri(application.contentResolver, "test", uris[0]).apply {
+ uris.drop(1).forEach { uri ->
+ addItem(ClipData.Item(uri))
+ }
+ }
+ }
+
+ private fun createTestIntent(clipData: ClipData? = null, singleUri: Uri? = null): Intent {
+ return Intent().apply {
+ this.clipData = clipData
+ this.data = singleUri
+ }
+ }
+
+ private fun createTestFiles(count: Int = 2): List {
+ val testBitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888).apply {
+ Canvas(this).drawColor(Color.RED)
+ }
+
+ return List(count) { index ->
+ File(application.filesDir, "test_screenshot${index + 1}.png").also { file ->
+ FileOutputStream(file).use { out ->
+ testBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+ }
+ }
+ }
+}
diff --git a/android/measure/src/test/java/sh/measure/android/events/UserTriggeredEventCollectorImplTest.kt b/android/measure/src/test/java/sh/measure/android/events/UserTriggeredEventCollectorImplTest.kt
index c2b932974..6400f11fb 100644
--- a/android/measure/src/test/java/sh/measure/android/events/UserTriggeredEventCollectorImplTest.kt
+++ b/android/measure/src/test/java/sh/measure/android/events/UserTriggeredEventCollectorImplTest.kt
@@ -1,11 +1,15 @@
package sh.measure.android.events
+import org.junit.Assert
import org.junit.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.eq
import org.mockito.kotlin.verify
import sh.measure.android.exceptions.ExceptionData
+import sh.measure.android.fakes.FakeConfigProvider
import sh.measure.android.fakes.FakeProcessInfoProvider
import sh.measure.android.fakes.TestData
import sh.measure.android.navigation.ScreenViewData
@@ -17,11 +21,13 @@ class UserTriggeredEventCollectorImplTest {
private val signalProcessor: SignalProcessor = mock()
private val timeProvider = AndroidTimeProvider(TestClock.create())
private val processInfoProvider: ProcessInfoProvider = FakeProcessInfoProvider()
+ private val configProvider = FakeConfigProvider()
private val userTriggeredEventCollector = UserTriggeredEventCollectorImpl(
signalProcessor,
timeProvider,
processInfoProvider,
+ configProvider,
)
@Test
@@ -51,7 +57,39 @@ class UserTriggeredEventCollectorImplTest {
}
@Test
- fun `disables collection un unregistered`() {
+ fun `tracks bug report event with attachments`() {
+ val data = TestData.getBugReportData()
+ val screenshot = TestData.getMsrAttachment()
+ val attachmentsCaptor = argumentCaptor>()
+
+ userTriggeredEventCollector.register()
+ val screenshots = listOf(screenshot)
+ userTriggeredEventCollector.trackBugReport(data.description, screenshots)
+ verify(signalProcessor).trackUserTriggered(
+ data = eq(data),
+ timestamp = eq(timeProvider.now()),
+ type = eq(EventType.BUG_REPORT),
+ attachments = attachmentsCaptor.capture(),
+ )
+ Assert.assertEquals(1, attachmentsCaptor.firstValue.size)
+ }
+
+ @Test
+ fun `tracks bug report event without attachments`() {
+ val data = TestData.getBugReportData()
+
+ userTriggeredEventCollector.register()
+ userTriggeredEventCollector.trackBugReport(data.description, listOf())
+ verify(signalProcessor).trackUserTriggered(
+ data = data,
+ timestamp = timeProvider.now(),
+ type = EventType.BUG_REPORT,
+ attachments = mutableListOf(),
+ )
+ }
+
+ @Test
+ fun `disables collection on unregistered`() {
val exception = Exception()
userTriggeredEventCollector.unregister()
userTriggeredEventCollector.trackHandledException(exception)
@@ -59,6 +97,7 @@ class UserTriggeredEventCollectorImplTest {
any(),
any(),
any(),
+ any(),
)
}
}
diff --git a/android/measure/src/test/java/sh/measure/android/fakes/FakeConfigProvider.kt b/android/measure/src/test/java/sh/measure/android/fakes/FakeConfigProvider.kt
index a20f4b8ae..814254d72 100644
--- a/android/measure/src/test/java/sh/measure/android/fakes/FakeConfigProvider.kt
+++ b/android/measure/src/test/java/sh/measure/android/fakes/FakeConfigProvider.kt
@@ -41,6 +41,8 @@ internal class FakeConfigProvider : ConfigProvider {
override val maxCheckpointsPerSpan: Int = 100
override var maxInMemorySignalsQueueSize: Int = 30
override val inMemorySignalsQueueFlushRateMs: Long = 3000
+ override val maxAttachmentsInBugReport: Int = 5
+ override val maxDescriptionLengthInBugReport: Int = 1000
var shouldTrackHttpBody = false
diff --git a/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt b/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt
index 13b7221ef..2ae22c7bb 100644
--- a/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt
+++ b/android/measure/src/test/java/sh/measure/android/fakes/FakeSessionManager.kt
@@ -7,6 +7,7 @@ internal class FakeSessionManager : SessionManager {
var crashedSession = ""
var crashedSessions = mutableListOf()
var onEventTracked = false
+ var markedSessionWithBugReport = false
override fun init() {
// no-op
@@ -24,6 +25,10 @@ internal class FakeSessionManager : SessionManager {
crashedSessions = sessionIds.toMutableList()
}
+ override fun markSessionWithBugReport() {
+ markedSessionWithBugReport = true
+ }
+
override fun onEventTracked(event: Event) {
onEventTracked = true
}
diff --git a/android/measure/src/test/java/sh/measure/android/fakes/TestData.kt b/android/measure/src/test/java/sh/measure/android/fakes/TestData.kt
index 675a6a2f6..af5dc4424 100644
--- a/android/measure/src/test/java/sh/measure/android/fakes/TestData.kt
+++ b/android/measure/src/test/java/sh/measure/android/fakes/TestData.kt
@@ -1,11 +1,14 @@
package sh.measure.android.fakes
+import sh.measure.android.MsrAttachment
import sh.measure.android.appexit.AppExit
import sh.measure.android.applaunch.ColdLaunchData
import sh.measure.android.applaunch.HotLaunchData
import sh.measure.android.applaunch.WarmLaunchData
import sh.measure.android.attributes.AttributeValue
+import sh.measure.android.bugreport.BugReportData
import sh.measure.android.events.Attachment
+import sh.measure.android.events.AttachmentType
import sh.measure.android.events.Event
import sh.measure.android.exceptions.ExceptionData
import sh.measure.android.exceptions.ExceptionFactory
@@ -550,4 +553,16 @@ internal object TestData {
timestamp = 98765432L,
)
}
+
+ fun getBugReportData(): BugReportData {
+ return BugReportData("Bug report description")
+ }
+
+ fun getMsrAttachment(
+ name: String = "attachment",
+ content: ByteArray = "content".toByteArray(),
+ type: String = AttachmentType.SCREENSHOT,
+ ): MsrAttachment {
+ return MsrAttachment(name, content, type = type)
+ }
}
diff --git a/android/measure/src/test/java/sh/measure/android/networkchange/InitialNetworkStateProviderTest.kt b/android/measure/src/test/java/sh/measure/android/networkchange/InitialNetworkStateProviderTest.kt
index 875f6adce..646fc76c1 100644
--- a/android/measure/src/test/java/sh/measure/android/networkchange/InitialNetworkStateProviderTest.kt
+++ b/android/measure/src/test/java/sh/measure/android/networkchange/InitialNetworkStateProviderTest.kt
@@ -136,10 +136,10 @@ internal class InitialNetworkStateProviderTest {
assertEquals(NetworkType.WIFI, networkType)
}
- @Test
// Ideally we would like to test on multiple versions, but specifying the SDK versions
// in @Config makes this test cause an OOM error after updating robolectric to 4.14
// @Config(sdk = [23, 33])
+ @Test
fun `returns correct network type above API 23`() {
shadowOf(context as Application).grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE)
val nc = ShadowNetworkCapabilities.newInstance()
diff --git a/android/measure/src/test/java/sh/measure/android/storage/DatabaseTest.kt b/android/measure/src/test/java/sh/measure/android/storage/DatabaseTest.kt
index c80873707..5e361c52e 100644
--- a/android/measure/src/test/java/sh/measure/android/storage/DatabaseTest.kt
+++ b/android/measure/src/test/java/sh/measure/android/storage/DatabaseTest.kt
@@ -748,14 +748,14 @@ class DatabaseTest {
}
@Test
- fun `markCrashedSessions marks multiple sessions as crashed and sets needs reporting`() {
+ fun `markSessionWithBugReport marks sets needs reporting to 1`() {
// given
database.insertSession(TestData.getSessionEntity("session-id-1"))
database.insertSession(TestData.getSessionEntity("session-id-2"))
database.insertSession(TestData.getSessionEntity("session-id-3"))
// when
- database.markCrashedSessions(listOf("session-id-1", "session-id-2"))
+ database.markSessionWithBugReport("session-id-2")
// then
val db = database.readableDatabase
@@ -769,13 +769,10 @@ class DatabaseTest {
null,
).use {
it.moveToFirst()
- assertEquals(1, it.getInt(it.getColumnIndex(SessionsTable.COL_CRASHED)))
- assertEquals(1, it.getInt(it.getColumnIndex(SessionsTable.COL_NEEDS_REPORTING)))
+ assertEquals(0, it.getInt(it.getColumnIndex(SessionsTable.COL_NEEDS_REPORTING)))
it.moveToNext()
- assertEquals(1, it.getInt(it.getColumnIndex(SessionsTable.COL_CRASHED)))
assertEquals(1, it.getInt(it.getColumnIndex(SessionsTable.COL_NEEDS_REPORTING)))
it.moveToNext()
- assertEquals(0, it.getInt(it.getColumnIndex(SessionsTable.COL_CRASHED)))
assertEquals(0, it.getInt(it.getColumnIndex(SessionsTable.COL_NEEDS_REPORTING)))
}
}
diff --git a/android/measure/src/test/java/sh/measure/android/utils/AttachmentHelperTest.kt b/android/measure/src/test/java/sh/measure/android/utils/AttachmentHelperTest.kt
new file mode 100644
index 000000000..56fe21904
--- /dev/null
+++ b/android/measure/src/test/java/sh/measure/android/utils/AttachmentHelperTest.kt
@@ -0,0 +1,111 @@
+package sh.measure.android.utils
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.net.Uri
+import android.os.Looper
+import androidx.concurrent.futures.ResolvableFuture
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric.buildActivity
+import org.robolectric.Shadows.shadowOf
+import sh.measure.android.MsrAttachment
+import sh.measure.android.TestLifecycleActivity
+import sh.measure.android.fakes.FakeConfigProvider
+import sh.measure.android.fakes.ImmediateExecutorService
+import sh.measure.android.fakes.NoopLogger
+import java.io.File
+import java.io.FileOutputStream
+
+@RunWith(AndroidJUnit4::class)
+class AttachmentHelperTest {
+ private val controller = buildActivity(TestLifecycleActivity::class.java)
+ private val logger = NoopLogger()
+ private val application = InstrumentationRegistry.getInstrumentation().context
+ private val executorService = ImmediateExecutorService(ResolvableFuture.create())
+ private val configProvider = FakeConfigProvider()
+ private val attachmentHelper = AttachmentHelper(logger, executorService, configProvider)
+
+ @Test
+ fun `captureScreenshot triggers onCapture callback on successful screenshot capture`() {
+ var attachment: MsrAttachment? = null
+ controller.setup()
+ attachmentHelper.captureScreenshot(controller.get(), { a ->
+ attachment = a
+ }, {})
+ shadowOf(Looper.getMainLooper()).idle()
+ Assert.assertNotNull(attachment)
+ }
+
+ @Test
+ fun `captureScreenshot triggers onError callback on unsuccessful screenshot capture`() {
+ var onErrorCalled = false
+ // Do not launch the activity, thereby failing to capture a screenshot
+ attachmentHelper.captureScreenshot(controller.get(), {}, {
+ onErrorCalled = true
+ })
+ shadowOf(Looper.getMainLooper()).idle()
+ Assert.assertEquals(true, onErrorCalled)
+ }
+
+ @Test
+ fun `captureLayoutSnapshot triggers onComplete on successful capture`() {
+ var attachment: MsrAttachment? = null
+ controller.setup()
+ attachmentHelper.captureLayoutSnapshot(controller.get(), { a ->
+ attachment = a
+ }, {})
+ Assert.assertNotNull(attachment)
+ }
+
+ @Test
+ fun `captureLayoutSnapshot triggers onError on unsuccessful capture`() {
+ var onErrorCalled = false
+ // Do not launch the activity, thereby failing to capture a snapshot
+ attachmentHelper.captureLayoutSnapshot(controller.get(), {}, {
+ onErrorCalled = true
+ })
+ Assert.assertEquals(true, onErrorCalled)
+ }
+
+ @Test
+ fun `imageUriToAttachment triggers onComplete on successful conversion`() {
+ val uri = Uri.fromFile(createTestImage())
+ var attachment: MsrAttachment? = null
+ controller.setup()
+ attachmentHelper.imageUriToAttachment(controller.get(), uri, { a -> attachment = a }, { })
+ shadowOf(Looper.getMainLooper()).idle()
+ Assert.assertNotNull(attachment)
+ }
+
+ @Test
+ fun `imageUriToAttachment triggers onError for invalid Uri`() {
+ val invalidUri =
+ Uri.parse("android.resource://${controller.get().packageName}/drawable/invalidUri")
+ var onErrorCalled = false
+ controller.setup()
+ attachmentHelper.imageUriToAttachment(
+ controller.get(),
+ invalidUri,
+ { },
+ { onErrorCalled = true },
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+ Assert.assertTrue(onErrorCalled)
+ }
+
+ private fun createTestImage(): File {
+ val testBitmap = Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888).apply {
+ Canvas(this).drawColor(Color.RED)
+ }
+ return File(application.filesDir, "test_screenshot.png").also { file ->
+ FileOutputStream(file).use { out ->
+ testBitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
+ }
+ }
+ }
+}
diff --git a/android/sample/src/main/java/sh/measure/sample/ExceptionDemoActivity.kt b/android/sample/src/main/java/sh/measure/sample/ExceptionDemoActivity.kt
index af07eca95..044a52cef 100644
--- a/android/sample/src/main/java/sh/measure/sample/ExceptionDemoActivity.kt
+++ b/android/sample/src/main/java/sh/measure/sample/ExceptionDemoActivity.kt
@@ -9,7 +9,6 @@ import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.switchmaterial.SwitchMaterial
import sh.measure.android.Measure
-import sh.measure.android.attributes.AttributesBuilder
import sh.measure.android.tracing.SpanStatus
import sh.measure.sample.fragments.AndroidXFragmentNavigationActivity
import sh.measure.sample.fragments.FragmentNavigationActivity
@@ -27,13 +26,7 @@ class ExceptionDemoActivity : AppCompatActivity() {
val span = Measure.startSpan("activity.onCreate")
setContentView(R.layout.activity_exception_demo)
findViewById
\ No newline at end of file
diff --git a/android/sample/src/main/res/values/strings.xml b/android/sample/src/main/res/values/strings.xml
index 16a3b707c..bd2ae8add 100644
--- a/android/sample/src/main/res/values/strings.xml
+++ b/android/sample/src/main/res/values/strings.xml
@@ -30,4 +30,5 @@
AndroidX Fragment Navigation
Create span
Start tracking
+ Report a bug
\ No newline at end of file
diff --git a/android/sample/src/main/res/values/themes.xml b/android/sample/src/main/res/values/themes.xml
index 2ea80aba7..98322b341 100644
--- a/android/sample/src/main/res/values/themes.xml
+++ b/android/sample/src/main/res/values/themes.xml
@@ -1,10 +1,10 @@
-
+