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