diff --git a/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/features/ThermalStateTest.kt b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/features/ThermalStateTest.kt new file mode 100644 index 000000000..5eb0647eb --- /dev/null +++ b/embrace-android-sdk/src/integrationTest/kotlin/io/embrace/android/embracesdk/features/ThermalStateTest.kt @@ -0,0 +1,123 @@ +package io.embrace.android.embracesdk.features + +import android.os.Build +import android.os.PowerManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.embrace.android.embracesdk.IntegrationTestRule +import io.embrace.android.embracesdk.arch.schema.EmbType +import io.embrace.android.embracesdk.config.remote.DataRemoteConfig +import io.embrace.android.embracesdk.config.remote.RemoteConfig +import io.embrace.android.embracesdk.fakes.fakeAutoDataCaptureBehavior +import io.embrace.android.embracesdk.fakes.fakeSdkModeBehavior +import io.embrace.android.embracesdk.findSpanAttribute +import io.embrace.android.embracesdk.findSpanSnapshotsOfType +import io.embrace.android.embracesdk.findSpansOfType +import io.embrace.android.embracesdk.internal.clock.nanosToMillis +import io.embrace.android.embracesdk.recordSession +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowActivityManager + +@Config(sdk = [Build.VERSION_CODES.Q], shadows = [ShadowActivityManager::class]) +@RunWith(AndroidJUnit4::class) +internal class ThermalStateFeatureTest { + + @Rule + @JvmField + val testRule: IntegrationTestRule = IntegrationTestRule() + + @Before + fun setUp() { + setUpThermalCapture() + } + + @Test + fun `single thermal state change generates a snapshot`() { + with(testRule) { + var startTimeMs = 0L + val message = checkNotNull(harness.recordSession { + startTimeMs = harness.overriddenClock.now() + + val dataSource = + checkNotNull(bootstrapper.dataSourceModule.thermalStateDataSource?.dataSource) + dataSource.handleThermalStateChange(PowerManager.THERMAL_STATUS_NONE) + }) + + val snapshots = message.findSpanSnapshotsOfType(EmbType.Performance.ThermalState) + assertEquals(1, snapshots.size) + val snapshot = snapshots.single() + + assertEquals("emb-thermal-state", snapshot.name) + assertEquals("perf.thermal_state", snapshot.findSpanAttribute("emb.type")) + assertEquals(PowerManager.THERMAL_STATUS_NONE.toString(), snapshot.findSpanAttribute("status")) + assertEquals(startTimeMs, snapshot.startTimeNanos.nanosToMillis()) + } + } + + @Test + fun `multiple thermal state changes generate spans`() { + val tickTimeMs = 3000L + with(testRule) { + var startTimeMs = 0L + val message = checkNotNull(harness.recordSession { + startTimeMs = harness.overriddenClock.now() + + val dataSource = + checkNotNull(bootstrapper.dataSourceModule.thermalStateDataSource?.dataSource) + dataSource.handleThermalStateChange(PowerManager.THERMAL_STATUS_CRITICAL) + harness.overriddenClock.tick(tickTimeMs) + dataSource.handleThermalStateChange(PowerManager.THERMAL_STATUS_MODERATE) + harness.overriddenClock.tick(tickTimeMs) + dataSource.handleThermalStateChange(PowerManager.THERMAL_STATUS_NONE) + }) + + val spans = message.findSpansOfType(EmbType.Performance.ThermalState) + assertEquals(2, spans.size) + + spans.forEach { + assertEquals("emb-thermal-state", it.name) + assertEquals("perf.thermal_state", it.findSpanAttribute("emb.type")) + } + val firstSpan = spans.first() + assertEquals(PowerManager.THERMAL_STATUS_CRITICAL.toString(), firstSpan.findSpanAttribute("status")) + assertEquals(startTimeMs, firstSpan.startTimeNanos.nanosToMillis()) + assertEquals(startTimeMs + tickTimeMs, firstSpan.endTimeNanos.nanosToMillis()) + val secondSpan = spans.last() + assertEquals(PowerManager.THERMAL_STATUS_MODERATE.toString(), secondSpan.findSpanAttribute("status")) + assertEquals(startTimeMs + tickTimeMs, secondSpan.startTimeNanos.nanosToMillis()) + assertEquals(startTimeMs + tickTimeMs * 2, secondSpan.endTimeNanos.nanosToMillis()) + + val snapshots = message.findSpanSnapshotsOfType(EmbType.Performance.ThermalState) + assertEquals(1, snapshots.size) + + val snapshot = snapshots.single() + assertEquals("emb-thermal-state", snapshot.name) + assertEquals("perf.thermal_state", snapshot.findSpanAttribute("emb.type")) + assertEquals(PowerManager.THERMAL_STATUS_NONE.toString(), snapshot.findSpanAttribute("status")) + assertEquals(startTimeMs + tickTimeMs * 2, snapshot.startTimeNanos.nanosToMillis()) + } + } + + private fun setUpThermalCapture() { + testRule.harness.overriddenConfigService.autoDataCaptureBehavior = + fakeAutoDataCaptureBehavior( + remoteCfg = { + RemoteConfig( + dataConfig = DataRemoteConfig(pctThermalStatusEnabled = 100.0f) + ) + } + ) + testRule.harness.overriddenConfigService.sdkModeBehavior = + fakeSdkModeBehavior( + remoteCfg = { + RemoteConfig( + pctBetaFeaturesEnabled = 100.0f + ) + } + ) + } +} diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/EmbType.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/EmbType.kt index dc2d4b7a3..4107e09f5 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/EmbType.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/EmbType.kt @@ -23,6 +23,8 @@ internal sealed class EmbType(type: String, subtype: String?) : TelemetryType { internal object NativeThreadBlockage : Performance("native_thread_blockage") internal object NativeThreadBlockageSample : Performance("native_thread_blockage_sample") + + internal object ThermalState : Performance("thermal_state") } /** diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/SchemaType.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/SchemaType.kt index 2446d2f89..7629b0f7d 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/SchemaType.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/arch/schema/SchemaType.kt @@ -336,4 +336,15 @@ internal sealed class SchemaType( ) .toNonNullMap() } + + internal class ThermalState( + status: Int + ) : SchemaType( + telemetryType = EmbType.Performance.ThermalState, + fixedObjectName = "thermal-state" + ) { + override val schemaAttributes = mapOf( + "status" to status.toString() + ) + } } diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/thermalstate/ThermalStateDataSource.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/thermalstate/ThermalStateDataSource.kt new file mode 100644 index 000000000..55194f20a --- /dev/null +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/capture/thermalstate/ThermalStateDataSource.kt @@ -0,0 +1,116 @@ +package io.embrace.android.embracesdk.capture.thermalstate + +import android.os.Build +import android.os.PowerManager +import androidx.annotation.RequiresApi +import io.embrace.android.embracesdk.arch.datasource.NoInputValidation +import io.embrace.android.embracesdk.arch.datasource.SpanDataSourceImpl +import io.embrace.android.embracesdk.arch.datasource.startSpanCapture +import io.embrace.android.embracesdk.arch.destination.StartSpanData +import io.embrace.android.embracesdk.arch.destination.StartSpanMapper +import io.embrace.android.embracesdk.arch.limits.UpToLimitStrategy +import io.embrace.android.embracesdk.arch.schema.SchemaType +import io.embrace.android.embracesdk.internal.Systrace +import io.embrace.android.embracesdk.internal.clock.Clock +import io.embrace.android.embracesdk.internal.spans.SpanService +import io.embrace.android.embracesdk.internal.utils.Provider +import io.embrace.android.embracesdk.logging.EmbLogger +import io.embrace.android.embracesdk.spans.EmbraceSpan +import io.embrace.android.embracesdk.worker.BackgroundWorker +import io.embrace.android.embracesdk.worker.TaskPriority +import java.util.concurrent.Executor + +@RequiresApi(Build.VERSION_CODES.Q) +internal class ThermalStateDataSource( + spanService: SpanService, + logger: EmbLogger, + private val backgroundWorker: BackgroundWorker, + private val clock: Clock, + powerManagerProvider: Provider +) : StartSpanMapper, SpanDataSourceImpl( + destination = spanService, + logger = logger, + limitStrategy = UpToLimitStrategy(logger) { MAX_CAPTURED_THERMAL_STATES } +) { + private companion object { + private const val MAX_CAPTURED_THERMAL_STATES = 100 + } + + private var thermalStatusListener: PowerManager.OnThermalStatusChangedListener? = null + + private val powerManager: PowerManager? by lazy(powerManagerProvider) + + private var span: EmbraceSpan? = null + + override fun enableDataCapture() { + backgroundWorker.submit(TaskPriority.LOW) { + Systrace.traceSynchronous("thermal-service-registration") { + thermalStatusListener = PowerManager.OnThermalStatusChangedListener { + handleThermalStateChange(it) + } + val pm = powerManager + if (pm != null) { + // Android API only accepts an executor. We don't want to directly expose those + // to everything in the codebase so we decorate the BackgroundWorker here as an + // alternative + val executor = Executor { + backgroundWorker.submit(runnable = it) + } + thermalStatusListener?.let { + pm.addThermalStatusListener(executor, it) + } + } + } + } + } + + override fun disableDataCapture() { + backgroundWorker.submit(TaskPriority.LOW) { + thermalStatusListener?.let { + powerManager?.removeThermalStatusListener(it) + thermalStatusListener = null + } + } + } + + fun handleThermalStateChange(status: Int?) { + if (status == null) { + return + } + + val timestamp = clock.now() + + // close previous span + if (span != null) { + captureSpanData( + countsTowardsLimits = false, + inputValidation = NoInputValidation, + captureAction = { + span?.stop(endTimeMs = timestamp) + } + ) + } + // start a new span with the new thermal state + captureSpanData( + countsTowardsLimits = true, + inputValidation = NoInputValidation + ) { + startSpanCapture(ThermalState(status, timestamp), ::toStartSpanData) + .apply { + span = this + } + } + } + + override fun toStartSpanData(obj: ThermalState): StartSpanData { + return StartSpanData( + schemaType = SchemaType.ThermalState(obj.status), + spanStartTimeMs = obj.timestamp + ) + } +} + +internal data class ThermalState( + val status: Int, + val timestamp: Long +) diff --git a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/injection/DataSourceModule.kt b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/injection/DataSourceModule.kt index ab882c2ed..2793e499a 100644 --- a/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/injection/DataSourceModule.kt +++ b/embrace-android-sdk/src/main/java/io/embrace/android/embracesdk/injection/DataSourceModule.kt @@ -16,6 +16,7 @@ import io.embrace.android.embracesdk.capture.crumbs.WebViewUrlDataSource import io.embrace.android.embracesdk.capture.memory.MemoryWarningDataSource import io.embrace.android.embracesdk.capture.powersave.LowPowerDataSource import io.embrace.android.embracesdk.capture.session.SessionPropertiesDataSource +import io.embrace.android.embracesdk.capture.thermalstate.ThermalStateDataSource import io.embrace.android.embracesdk.internal.utils.BuildVersionChecker import io.embrace.android.embracesdk.internal.utils.Provider import io.embrace.android.embracesdk.worker.WorkerName @@ -50,6 +51,7 @@ internal interface DataSourceModule { val networkStatusDataSource: DataSourceState val sigquitDataSource: DataSourceState val rnActionDataSource: DataSourceState + val thermalStateDataSource: DataSourceState? } internal class DataSourceModuleImpl( @@ -225,6 +227,30 @@ internal class DataSourceModuleImpl( ) } + override val thermalStateDataSource: DataSourceState? by dataSourceState { + DataSourceState( + factory = { thermalService }, + configGate = { + configService.autoDataCaptureBehavior.isThermalStatusCaptureEnabled() && + configService.sdkModeBehavior.isBetaFeaturesEnabled() + } + ) + } + + private val thermalService: ThermalStateDataSource? by singleton { + if (BuildVersionChecker.isAtLeast(Build.VERSION_CODES.Q)) { + ThermalStateDataSource( + spanService = otelModule.spanService, + logger = initModule.logger, + backgroundWorker = workerThreadModule.backgroundWorker(WorkerName.BACKGROUND_REGISTRATION), + clock = initModule.clock, + powerManagerProvider = { systemServiceModule.powerManager } + ) + } else { + null + } + } + private val configService = essentialServiceModule.configService override fun getDataSources(): List> = values diff --git a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/ThermalStateDataSourceTest.kt b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/ThermalStateDataSourceTest.kt new file mode 100644 index 000000000..9fecc969d --- /dev/null +++ b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/ThermalStateDataSourceTest.kt @@ -0,0 +1,67 @@ +package io.embrace.android.embracesdk + +import android.os.PowerManager +import io.embrace.android.embracesdk.arch.schema.EmbType +import io.embrace.android.embracesdk.capture.thermalstate.ThermalStateDataSource +import io.embrace.android.embracesdk.concurrency.BlockableExecutorService +import io.embrace.android.embracesdk.fakes.FakeClock +import io.embrace.android.embracesdk.fakes.FakeSpanService +import io.embrace.android.embracesdk.fakes.system.mockPowerManager +import io.embrace.android.embracesdk.logging.EmbLoggerImpl +import io.embrace.android.embracesdk.worker.BackgroundWorker +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +internal class ThermalStateDataSourceTest { + + private lateinit var dataSource: ThermalStateDataSource + private lateinit var spanWriter: FakeSpanService + private val mockPowerManager = mockPowerManager() + + @Before + fun setUp() { + spanWriter = FakeSpanService() + dataSource = ThermalStateDataSource( + spanWriter, + EmbLoggerImpl(), + BackgroundWorker(BlockableExecutorService()), + FakeClock(100), + ) { mockPowerManager } + } + + @Test + fun onThermalStateChanged() { + with(dataSource) { + handleThermalStateChange(PowerManager.THERMAL_STATUS_NONE) + handleThermalStateChange(PowerManager.THERMAL_STATUS_SEVERE) + handleThermalStateChange(PowerManager.THERMAL_STATUS_CRITICAL) + } + assertEquals(3, spanWriter.createdSpans.size) + spanWriter.createdSpans.forEach { + assertEquals(EmbType.Performance.ThermalState, it.type) + } + assertEquals(PowerManager.THERMAL_STATUS_NONE, spanWriter.createdSpans[0].attributes["status"]?.toInt()) + assertEquals(PowerManager.THERMAL_STATUS_SEVERE, spanWriter.createdSpans[1].attributes["status"]?.toInt()) + assertEquals(PowerManager.THERMAL_STATUS_CRITICAL, spanWriter.createdSpans[2].attributes["status"]?.toInt()) + } + + @Test + fun onLimitExceeded() { + repeat(250) { + dataSource.handleThermalStateChange(PowerManager.THERMAL_STATUS_SEVERE) + } + + assertEquals(100, spanWriter.createdSpans.size) + } + + @Test + fun onEnableAndDisable() { + verify(exactly = 0) { mockPowerManager.addThermalStatusListener(any(), any()) } + dataSource.enableDataCapture() + verify(exactly = 1) { mockPowerManager.addThermalStatusListener(any(), any()) } + dataSource.disableDataCapture() + verify(exactly = 1) { mockPowerManager.removeThermalStatusListener(any()) } + } +} diff --git a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/injection/DataSourceModuleImplTest.kt b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/injection/DataSourceModuleImplTest.kt index 10e04be74..2eb9b9f86 100644 --- a/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/injection/DataSourceModuleImplTest.kt +++ b/embrace-android-sdk/src/test/java/io/embrace/android/embracesdk/injection/DataSourceModuleImplTest.kt @@ -41,6 +41,7 @@ internal class DataSourceModuleImplTest { assertNotNull(module.networkStatusDataSource) assertNotNull(module.sigquitDataSource) assertNotNull(module.rnActionDataSource) - assertEquals(12, module.getDataSources().size) + assertNotNull(module.thermalStateDataSource) + assertEquals(13, module.getDataSources().size) } }