diff --git a/android/docs/internal-documentation.md b/android/docs/internal-documentation.md
index 6c6337ee5..e051ea5d3 100644
--- a/android/docs/internal-documentation.md
+++ b/android/docs/internal-documentation.md
@@ -6,6 +6,7 @@
* [Exceptions and ANRs export](#exceptions-and-anrs-export)
* [Thread management](#thread-management)
* [Configuration](#configuration)
+* [Testing](#testing)
# Storage
@@ -118,6 +119,9 @@ sensitive information from being sent or modifying the behavior of the SDK.
Any configuration change made to `MeasureConfig` is a public API change and must also result in
updating the documentation.
+See [README](../../docs/android/configuration-options.md) for more details about the
+available configurations.
+
## Applying configs
Configs which modify events, like removing fields or decision to drop events are all centralized in
@@ -129,10 +133,32 @@ changes
the color of the mask applied to the screenshot. These configs are applied at the time of collection
itself.
-## Remote config
+# Testing
+
+The SDK is tested using both unit tests and integration tests. Certain unit tests which require
+Android framework classes are run using Robolectric. The integration tests are run using Espresso
+and UI Automator.
+
+To run unit tests, use the following command:
+```shell
+./gradlew :measure:test
+```
+
+To run integration tests (requires a device), use the following command:
+```shell
+./gradlew :measure:connectedAndroidTest
+```
+
+The _Measure gradle plugin_ also contains both unit tests and functional tests. The functional tests
+are run using the [testkit by autonomous apps](https://github.com/autonomousapps/dependency-analysis-gradle-plugin/tree/main/testkit)
+and use JUnit5 for testing as it provides an easy way to run parameterized tests.
-Although not implemented yet, the config is expected to be modified from the dashboard, and the
-changes should be reflected in the SDK. Config received from the server is also expected to be
-persisted and used for subsequent initializations. Once implemented, the config passed in during
-initialization will be overridden by the config received from the server. During initialization, the
-the persisted config will be used over the config passed in during initialization.
+TO run the unit tests, use the following command:
+```shell
+./gradlew :measure-gradle-plugin:test
+```
+
+To run the functional tests, use the following command:
+```shell
+./gradlew :measure-gradle-plugin:functionalTest
+```
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 9c46a885f..0220eb342 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,5 +1,6 @@
[versions]
agp = "8.5.1"
+benchmarkJunit4 = "1.2.4"
bundletool = "1.17.0"
# android-tools is used for calculating app size and aab size by the gradle plugin.
# The version should remain compatible with lower versions of android gradle plugin. New versions
@@ -36,6 +37,7 @@ kotlinx-serialization-json = "1.6.2"
google-material = "1.11.0"
mockito-kotlin = "5.1.0"
nhaarman-mockito-kotlin = "2.2.0"
+orchestrator = "1.5.0"
semver = "1.1.2"
squareup-curtains = "1.2.4"
squareup-okhttp = "4.12.0"
@@ -53,6 +55,7 @@ mavenPublish = "0.29.0"
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
+androidx-benchmark-junit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "benchmarkJunit4" }
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro-junit4" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
@@ -77,6 +80,7 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose-ui" }
androidx-compose-runtime-android = { module = "androidx.compose.runtime:runtime-android", version.ref = "androidx-runtime-android" }
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
+androidx-orchestrator = { module = "androidx.test:orchestrator", version.ref = "orchestrator" }
agp = { module = "com.android.tools.build:gradle", version.ref = "agp" }
asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm-util" }
asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm-util" }
diff --git a/android/measure/build.gradle.kts b/android/measure/build.gradle.kts
index 0c8a865ee..93ae73277 100644
--- a/android/measure/build.gradle.kts
+++ b/android/measure/build.gradle.kts
@@ -62,6 +62,7 @@ android {
defaultConfig {
minSdk = 21
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments["clearPackageData"] = "true"
consumerProguardFiles("consumer-rules.pro")
}
@@ -88,6 +89,7 @@ android {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
}
}
buildFeatures {
@@ -138,7 +140,10 @@ fun configureSpotlessKotlin(spotlessExtension: SpotlessExtension) {
spotlessExtension.kotlin {
ktlint().apply {
editorConfigOverride(
- mapOf("max_line_length" to 2147483647),
+ mapOf(
+ "max_line_length" to 2147483647,
+ "ktlint_function_naming_ignore_when_annotated_with" to "Composable",
+ ),
)
}
target("src/**/*.kt")
@@ -183,4 +188,8 @@ dependencies {
androidTestImplementation(libs.androidx.activity.compose)
androidTestImplementation(libs.androidx.navigation.compose)
androidTestImplementation(libs.androidx.rules)
+ androidTestImplementation(libs.androidx.uiautomator)
+ androidTestImplementation(libs.squareup.okhttp.mockwebserver)
+ androidTestUtil(libs.androidx.orchestrator)
+ androidTestImplementation(libs.androidx.benchmark.junit4)
}
diff --git a/android/measure/src/androidTest/AndroidManifest.xml b/android/measure/src/androidTest/AndroidManifest.xml
index 67a63a697..2cbd45f79 100644
--- a/android/measure/src/androidTest/AndroidManifest.xml
+++ b/android/measure/src/androidTest/AndroidManifest.xml
@@ -1,5 +1,4 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt b/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt
new file mode 100644
index 000000000..6e4d51157
--- /dev/null
+++ b/android/measure/src/androidTest/java/sh/measure/android/MeasureEventsTest.kt
@@ -0,0 +1,683 @@
+package sh.measure.android
+
+import android.Manifest
+import androidx.benchmark.junit4.PerfettoTraceRule
+import androidx.benchmark.perfetto.ExperimentalPerfettoCaptureApi
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.IdlingRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import okhttp3.Headers
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Ignore
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import sh.measure.android.config.MeasureConfig
+import sh.measure.android.events.EventType
+import sh.measure.android.exceptions.ExceptionData
+import sh.measure.android.lifecycle.ActivityLifecycleType
+import sh.measure.android.lifecycle.AppLifecycleType
+import java.util.concurrent.TimeUnit
+
+/**
+ * Functional tests for the Measure SDK.
+ *
+ * The tests use the Measure SDK as it would be used in a real app. The only difference is that
+ * periodic export is disabled. The tests help verify that the SDK is able to track events
+ * and send them to the server.
+ *
+ * The tests use a MockWebServer to intercept the requests sent by the SDK and verify that the
+ * expected events are being sent.
+ *
+ * These tests also depend on `ANDROIDX_TEST_ORCHESTRATOR`, which is setup in
+ * the build.gradle file. Certain tests depend on the application being launched from scratch,
+ * which is made possible by adding `testInstrumentationRunnerArguments["clearPackageData"] = "true"`
+ *
+ * However, the tests do not verify the correctness of the data sent in the events. This can
+ * be improved in the future by adding a way to parse the multi-part requests made by the SDK.
+ *
+ * Also note that certain tests are ignored if the test harness cannot trigger the events
+ * in a reliable way, these must be verified manually.
+ *
+ * See [TestRobot] for helper methods to interact with the test app.
+ */
+@RunWith(AndroidJUnit4::class)
+class MeasureEventsTest {
+ private lateinit var robot: TestRobot
+ private val mockWebServer: MockWebServer = MockWebServer()
+
+ @OptIn(ExperimentalPerfettoCaptureApi::class)
+ @get:Rule
+ val perfettoRule = PerfettoTraceRule(enableUserspaceTracing = true)
+
+ @Before
+ fun setup() {
+ mockWebServer.start(port = 8080)
+ // mockWebServer.enqueue(MockResponse().setResponseCode(200))
+ robot = TestRobot()
+ }
+
+ @After
+ fun teardown() {
+ mockWebServer.shutdown()
+ }
+
+ @Test
+ fun tracksExceptionEvent() {
+ // Given
+ robot.disableDefaultExceptionHandler()
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity {
+ // When
+ robot.crashApp()
+ }
+ // Then
+ assertEventTracked(EventType.EXCEPTION)
+ }
+ }
+
+ @Test
+ fun givenScreenshotOnCrashEnabledThenTracksExceptionEventWithScreenshot() {
+ // Given
+ robot.disableDefaultExceptionHandler()
+ robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity {
+ // When
+ robot.crashApp()
+ }
+ val requestBody = getLastRequestBody()
+
+ // Then
+ assertEventTracked(requestBody, EventType.EXCEPTION)
+ assertScreenshot(requestBody, true)
+ }
+ }
+
+ @Test
+ fun givenScreenshotOnCrashDisabledThenTracksExceptionEventWithoutScreenshot() {
+ // Given
+ robot.disableDefaultExceptionHandler()
+ robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = false))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity {
+ // When
+ robot.crashApp()
+ }
+ val requestBody = getLastRequestBody()
+
+ // Then
+ assertEventTracked(requestBody, EventType.EXCEPTION)
+ assertScreenshot(requestBody, false)
+ }
+ }
+
+ @Test
+ fun tracksAnrEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ triggerAnr()
+
+ // Then
+ assertEventTracked(EventType.ANR)
+ }
+ }
+
+ @Test
+ fun givenScreenshotOnCrashEnabledThenTracksAnrEventWithScreenshot() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ triggerAnr()
+ val requestBody = getLastRequestBody()
+
+ // Then
+ assertEventTracked(requestBody, EventType.ANR)
+ assertScreenshot(requestBody, true)
+ }
+ }
+
+ @Test
+ fun givenScreenshotOnCrashDisabledThenTracksAnrEventWithoutScreenshot() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true, trackScreenshotOnCrash = false))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ triggerAnr()
+ val requestBody = getLastRequestBody()
+
+ // Then
+ assertEventTracked(requestBody, EventType.ANR)
+ assertScreenshot(requestBody, false)
+ }
+ }
+
+ @Test
+ fun tracksGestureViewClickEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.clickButton()
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.CLICK)
+ }
+ }
+
+ @Test
+ fun tracksGestureComposeClickEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.clickComposeButton()
+ triggerExport()
+ val requestBody = getLastRequestBody()
+
+ // Then
+ assertEventTracked(requestBody, EventType.CLICK)
+ // simply checking for click might introduce a false positive
+ // so we also check the expected body of the event
+ Assert.assertTrue(requestBody.contains("compose_button"))
+ Assert.assertTrue(requestBody.contains("androidx.compose.ui.platform.AndroidComposeView"))
+ }
+ }
+
+ @Test
+ fun tracksGestureLongClickEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.longClickButton()
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.LONG_CLICK)
+ }
+ }
+
+ @Test
+ fun tracksGestureScrollEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.scrollDown()
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.SCROLL)
+ }
+ }
+
+ @Test
+ fun tracksGestureComposeScrollEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.composeScrollDown()
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.SCROLL)
+ }
+ }
+
+ @Test
+ fun tracksLifecycleActivityEvents() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ it.moveToState(Lifecycle.State.DESTROYED)
+ triggerExport()
+ val body = getLastRequestBody()
+
+ // Then
+ Assert.assertTrue(body.containsEvent(EventType.LIFECYCLE_ACTIVITY))
+ Assert.assertTrue(body.contains(ActivityLifecycleType.CREATED))
+ Assert.assertTrue(body.contains(ActivityLifecycleType.RESUMED))
+ Assert.assertTrue(body.contains(ActivityLifecycleType.PAUSED))
+ Assert.assertTrue(body.contains(ActivityLifecycleType.DESTROYED))
+ }
+ }
+
+ @Test
+ fun tracksLifecycleApplicationEvents() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+
+ // When
+ robot.pressHomeButton()
+ triggerExport()
+ val body = getLastRequestBody()
+
+ // Then
+ Assert.assertTrue(body.containsEvent(EventType.LIFECYCLE_APP))
+ Assert.assertTrue(body.contains(AppLifecycleType.FOREGROUND))
+ Assert.assertTrue(body.contains(AppLifecycleType.BACKGROUND))
+ }
+ }
+
+ @Test
+ fun tracksColdLaunchEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ // WHen
+ it.moveToState(Lifecycle.State.RESUMED)
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.COLD_LAUNCH)
+ }
+ }
+
+ @Test
+ fun tracksWarmLaunchEvent() {
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.recreate()
+ triggerExport()
+ assertEventTracked(EventType.WARM_LAUNCH)
+ }
+ }
+
+ @Test
+ @Ignore("Unable to trigger hot launch in tests")
+ fun tracksHotLaunchEvent() {
+ // Implementation would go here if we could reliably trigger a hot launch in tests
+ }
+
+ @Test
+ @Ignore("Changing network requires a real internet connection to be available")
+ fun tracksNetworkChangeEvent() {
+ robot.grantPermissions(Manifest.permission.ACCESS_NETWORK_STATE)
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ val networkEnabled = robot.isInternetAvailable()
+ robot.enableWiFi(!networkEnabled)
+ robot.enableMobileData(!networkEnabled)
+ // Network state change takes some time to reflect, so wait for a bit
+ // an idle resource would be better.
+ Thread.sleep(3000)
+ triggerExport()
+
+ // reset network state
+ robot.enableWiFi(networkEnabled)
+ robot.enableMobileData(networkEnabled)
+ assertEventTracked(EventType.NETWORK_CHANGE)
+ }
+ }
+
+ @Test
+ fun givenDefaultConfigThenTracksHttpEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(activity, "http://example:8080")
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.HTTP)
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenUrlAllowlistContainsUrlThenTracksHttpEvent() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ httpUrlAllowlist = listOf("allowed"),
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(activity, "http://allowed.com")
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.HTTP)
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenUrlAllowlistDoesNotContainUrlThenDoesNotTrackHttpEvent() {
+ // GIven
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ httpUrlAllowlist = listOf("allowed.com"),
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(activity, "http://notallowed.com")
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ assertEventNotTracked(EventType.HTTP)
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenUrlBlocklistContainsUrlThenDoesNotTrackHttpEvent() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ httpUrlBlocklist = listOf("disallowed"),
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(activity, "http://disallowed.com")
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ assertEventNotTracked(EventType.HTTP)
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenTrackBodyAndTrackHeadersAreEnabledThenTracksHeaders() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ trackHttpBody = true,
+ trackHttpHeaders = true,
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(
+ activity,
+ "http://example.com",
+ headers = Headers.Builder().add("x-header-key", "x-header-value").build(),
+ )
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ val body = getLastRequestBody()
+ assertEventTracked(body, EventType.HTTP)
+ Assert.assertTrue(body.contains("x-header-key"))
+ Assert.assertTrue(body.contains("x-header-value"))
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenTrackHeadersIsDisabledThenDoesNotTrackHeaders() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ trackHttpBody = true,
+ trackHttpHeaders = false,
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(
+ activity,
+ "http://example.com",
+ headers = Headers.Builder().add("x-header-key", "x-header-value").build(),
+ )
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ val body = getLastRequestBody()
+ assertEventTracked(body, EventType.HTTP)
+ Assert.assertFalse(body.contains("x-header-key"))
+ Assert.assertFalse(body.contains("x-header-value"))
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenTrackBodyIsDisabledThenDoesNotTrackBody() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ trackHttpBody = false,
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(
+ activity,
+ "http://example.com",
+ headers = Headers.Builder().add("x-header-key", "x-header-value").build(),
+ requestBody = "request_body",
+ )
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+ val body = getLastRequestBody()
+
+ // Then
+ assertEventTracked(body, EventType.HTTP)
+ Assert.assertFalse(body.contains("request_body"))
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun givenTrackBodyIsEnabledThenTracksBody() {
+ // Given
+ robot.initializeMeasure(
+ MeasureConfig(
+ enableLogging = true,
+ trackHttpBody = true,
+ ),
+ )
+ ActivityScenario.launch(TestActivity::class.java).use {
+ it.moveToState(Lifecycle.State.RESUMED)
+ it.onActivity { activity ->
+ IdlingRegistry.getInstance().register(activity.httpIdlingResource)
+
+ // When
+ robot.makeNetworkRequest(
+ activity,
+ "http://example.com",
+ headers = Headers.Builder().add("x-header-key", "x-header-value").build(),
+ requestBody = "request_body",
+ )
+ activity.httpIdlingResource.registerIdleTransitionCallback {
+ triggerExport()
+
+ // Then
+ val body = getLastRequestBody()
+ assertEventTracked(body, EventType.HTTP)
+ Assert.assertTrue(body.contains("request_body"))
+ }
+ IdlingRegistry.getInstance().unregister(activity.httpIdlingResource)
+ }
+ }
+ }
+
+ @Test
+ fun tracksMemoryUsageEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ // When
+ it.moveToState(Lifecycle.State.RESUMED)
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.MEMORY_USAGE)
+ }
+ }
+
+ @Test
+ fun tracksCpuUsageEvent() {
+ // Given
+ robot.initializeMeasure(MeasureConfig(enableLogging = true))
+ ActivityScenario.launch(TestActivity::class.java).use {
+ // When
+ it.moveToState(Lifecycle.State.RESUMED)
+ triggerExport()
+
+ // Then
+ assertEventTracked(EventType.CPU_USAGE)
+ }
+ }
+
+ @Test
+ @Ignore("Unable to trigger trim memory callbacks in tests")
+ fun tracksTrimMemoryEvent() {
+ // Implementation would go here if we could reliably trigger trim memory in tests
+ }
+
+ private fun String.containsEvent(eventType: String): Boolean {
+ return contains("\"type\":\"$eventType\"")
+ }
+
+ private fun triggerExport() {
+ Measure.simulateAppCrash(
+ type = EventType.EXCEPTION,
+ data = ExceptionData(
+ exceptions = emptyList(),
+ threads = emptyList(),
+ handled = false,
+ foreground = true,
+ ),
+ timestamp = 987654321L,
+ attributes = mutableMapOf(),
+ attachments = mutableListOf(),
+ )
+ }
+
+ private fun triggerAnr() {
+ Measure.simulateAnr(
+ data = ExceptionData(
+ exceptions = emptyList(),
+ threads = emptyList(),
+ handled = false,
+ foreground = true,
+ ),
+ timestamp = 987654321L,
+ attributes = mutableMapOf(),
+ attachments = mutableListOf(),
+ )
+ }
+
+ private fun assertEventTracked(body: String, eventType: String) {
+ Assert.assertTrue(body.containsEvent(eventType))
+ }
+
+ private fun assertScreenshot(requestBody: String, expected: Boolean) {
+ if (expected) {
+ Assert.assertTrue(requestBody.contains("\"type\":\"screenshot\""))
+ } else {
+ Assert.assertFalse(requestBody.contains("\"type\":\"screenshot\""))
+ }
+ }
+
+ private fun assertEventTracked(eventType: String) {
+ val body = getLastRequestBody()
+ Assert.assertTrue(body.containsEvent(eventType))
+ }
+
+ private fun assertEventNotTracked(eventType: String) {
+ val body = getLastRequestBody()
+ Assert.assertFalse(body.containsEvent(eventType))
+ }
+
+ private fun getLastRequestBody(): String {
+ val request = mockWebServer.takeRequest(timeout = 500, unit = TimeUnit.MILLISECONDS)
+ Assert.assertNotNull(request)
+ return request!!.body.readUtf8()
+ }
+}
diff --git a/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt b/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt
new file mode 100644
index 000000000..537f26226
--- /dev/null
+++ b/android/measure/src/androidTest/java/sh/measure/android/NoopPeriodicEventExporter.kt
@@ -0,0 +1,17 @@
+package sh.measure.android
+
+import sh.measure.android.exporter.PeriodicEventExporter
+
+internal class NoopPeriodicEventExporter : PeriodicEventExporter {
+ override fun onAppForeground() {
+ // No-op
+ }
+
+ override fun onAppBackground() {
+ // No-op
+ }
+
+ override fun onColdLaunch() {
+ // No-op
+ }
+}
diff --git a/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt b/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt
index 85778a869..b423b97fd 100644
--- a/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt
+++ b/android/measure/src/androidTest/java/sh/measure/android/TestActivity.kt
@@ -1,12 +1,122 @@
package sh.measure.android
-import android.app.Activity
import android.os.Bundle
+import android.widget.Button
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.compose.ui.unit.dp
+import androidx.test.espresso.idling.CountingIdlingResource
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import sh.measure.android.okhttp.MeasureEventListenerFactory
import sh.measure.android.test.R
+import java.io.IOException
-class TestActivity : Activity() {
+class TestActivity : ComponentActivity() {
+ companion object {
+ const val IDLING_RES_HTTP_REQUEST = "http_request"
+ }
+
+ val httpIdlingResource = CountingIdlingResource(IDLING_RES_HTTP_REQUEST)
+
+ @OptIn(ExperimentalComposeUiApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_test)
+ findViewById