Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.sensors

import android.content.Context
import android.provider.Settings
import androidx.core.graphics.blue
import androidx.core.graphics.green
import androidx.core.graphics.red
Expand All @@ -11,8 +12,13 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.sensors.ProvidesSensor
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.common.sensors.SensorRepository
import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE
import io.homeassistant.companion.android.common.util.STATE_UNKNOWN
import javax.inject.Inject
import javax.inject.Singleton
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber

@Singleton
class DynamicColorSensorManager @Inject constructor(
Expand All @@ -21,6 +27,19 @@ class DynamicColorSensorManager @Inject constructor(
override val serverManager: ServerManager,
) : SensorManager {
companion object {
// See https://source.android.com/docs/core/display/dynamic-color#dynamic-13
private val TONAL_PALETTE_STYLES = listOf(
"EXPRESSIVE",
"FRUIT_SALAD",
"MONOCHROMATIC",
"RAINBOW",
"SPRITZ",
"TONAL_SPOT",
"VIBRANT",
)
private const val THEME_OVERLAY_JSON = "theme_customization_overlay_packages"
private const val THEME_STYLE = "android.theme.customization.theme_style"

@ProvidesSensor
val accentColorSensor = SensorManager.BasicSensor(
"accent_color",
Expand All @@ -30,16 +49,28 @@ class DynamicColorSensorManager @Inject constructor(
"mdi:palette",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
)

@ProvidesSensor
val tonalPaletteSensor = SensorManager.BasicSensor(
"tonal_palette",
"sensor",
commonR.string.sensor_name_tonal_palette_sensor,
commonR.string.sensor_description_tonal_palette_sensor,
"mdi:palette",
deviceClass = "enum",
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
)
}

override fun docsLink(): String {
return "https://companion.home-assistant.io/docs/core/sensors#dynamic-color-sensor"
}

override val name: Int
get() = commonR.string.sensor_name_dynamic_color

override suspend fun getAvailableSensors(): List<SensorManager.BasicSensor> {
return listOf(accentColorSensor)
return listOf(accentColorSensor, tonalPaletteSensor)
}

override fun requiredPermissions(sensorId: String): Array<String> {
Expand All @@ -48,6 +79,7 @@ class DynamicColorSensorManager @Inject constructor(

override suspend fun requestSensorUpdate() {
updateAccentColor(applicationContext)
updateTonalPalette(applicationContext)
}

override fun hasSensor(): Boolean {
Expand Down Expand Up @@ -77,4 +109,51 @@ class DynamicColorSensorManager @Inject constructor(
),
)
}

private suspend fun updateTonalPalette(applicationContext: Context) {
if (!isEnabled(tonalPaletteSensor)) {
return
}

suspend fun updateState(state: String) {
onSensorUpdated(
tonalPaletteSensor,
state,
tonalPaletteSensor.statelessIcon,
mapOf("options" to TONAL_PALETTE_STYLES),
)
}

val jsonString = try {
Settings.Secure.getString(applicationContext.contentResolver, THEME_OVERLAY_JSON)
} catch (ex: Exception) {
Timber.w(ex, "Exception reading %s", THEME_OVERLAY_JSON)
updateState(STATE_UNAVAILABLE)
return
Comment thread
jpelgrom marked this conversation as resolved.
}

if (jsonString == null) {
Timber.w("No value found for %s", THEME_OVERLAY_JSON)
updateState(STATE_UNAVAILABLE)
return
}

val jsonObject = try {
JSONObject(jsonString)
} catch (ex: JSONException) {
Timber.w(ex, "Exception parsing JSON for %s", THEME_OVERLAY_JSON)
updateState(STATE_UNKNOWN)
return
}

val themeStyle = try {
jsonObject.getString(THEME_STYLE)
} catch (ex: JSONException) {
Timber.w(ex, "Missing %s in JSON for %s", THEME_STYLE, THEME_OVERLAY_JSON)
updateState(STATE_UNKNOWN)
return
}

updateState(themeStyle)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
package io.homeassistant.companion.android.sensors

import android.content.Context
import android.os.Build
import android.provider.Settings
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import io.homeassistant.companion.android.common.sensors.SensorRepository
import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE
import io.homeassistant.companion.android.common.util.STATE_UNKNOWN
import io.homeassistant.companion.android.common.util.kotlinJsonMapper
import io.homeassistant.companion.android.database.sensor.Attribute
import io.homeassistant.companion.android.testing.unit.seedFakeAndroidId
import javax.inject.Inject
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.jupiter.api.assertNotNull
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid Robolectric? Which Android framework classes cannot be mocked?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Settings.Secure placement requires Android framework.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can mock it though?

@Test
fun testSettingsSecure() {
    mockkStatic(Settings.Secure::class)
    every { Settings.Secure.getString(any(), any()) } returns "my string"

    val result = dynamicColorSensor.getSecureSetting()
    assertEquals("my string", result)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does work for that call, yes. Trying it out and looking further, this will require substantially more mocking (mocking Context, DynamicColors.wrapContextIfAvailable, obtainStyledAttributes(), TypedArray.getColor(), TypedArray.recycle() and likely others.

At that point this will no longer be testing realistic framework behavior. Is there a particular concern with Robolectric? I see other tests in this package are using it already (such as ProvidedSensorSetTest, RequestAccurateLocationReceiverTest, SensorManagerCompletenessTest, SensorRepositoryTest). Given this sensor is heavily Android-specific, Robolectric allows us the most realistic test scenario.

@Config(sdk = [36], application = HiltTestApplication::class)
class DynamicColorSensorManagerTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)

@Inject
internal lateinit var sensorManager: DynamicColorSensorManager

@Inject
internal lateinit var sensorRepository: SensorRepository

@Before
fun setUp() {
getApplicationContext<Context>().seedFakeAndroidId()
hiltRule.inject()
}

@Config(maxSdk = Build.VERSION_CODES.R)
@Test
fun `Given SDK is lower than Android 12 then sensor manager is absent`() {
assertFalse(sensorManager.hasSensor())
}

@Config(minSdk = Build.VERSION_CODES.S)
@Test
fun `Given SDK is at least Android 12 then sensor manager is present`() {
assertTrue(sensorManager.hasSensor())
}

@Test
fun `Given dynamic color sensor when available sensors then includes color and palette sensors`() = runTest {
val availableSensors = sensorManager.getAvailableSensors()
assertTrue(availableSensors.contains(DynamicColorSensorManager.accentColorSensor))
assertTrue(availableSensors.contains(DynamicColorSensorManager.tonalPaletteSensor))
}
Comment thread
kcoppock marked this conversation as resolved.

@Test
fun `Given accent color sensor when required permissions then none specified`() {
assertArrayEquals(
emptyArray<String>(),
sensorManager.requiredPermissions(DynamicColorSensorManager.accentColorSensor.id),
)
}

@Test
fun `Given tonal palette sensor when required permissions then none specified`() {
assertArrayEquals(
emptyArray<String>(),
sensorManager.requiredPermissions(DynamicColorSensorManager.tonalPaletteSensor.id),
)
}

@Test
fun `Given enabled color sensor when request update then sets theme color`() = runTest {
val id = DynamicColorSensorManager.accentColorSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), true)

sensorManager.requestSensorUpdate()

val state = sensorRepository.get(id).single().state

// Default accent color for Robolectric
assertEquals("#475D92", state)

val attrs = getSensorAttributes(id)
val rgbColor = attrs.find { it.name == "rgb_color" }
assertNotNull(rgbColor)

// Fixed accent color for Robolectric is 475D92
assertEquals("[71,93,146]", rgbColor.value)
}

@Test
fun `Given disabled color sensor when request update then does not update state`() = runTest {
val id = DynamicColorSensorManager.accentColorSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), false)

sensorManager.requestSensorUpdate()

assertEquals("", sensorRepository.get(id).single().state)
}

@Test
fun `Given palette sensor when request update then sets palette variant`() = runTest {
val id = DynamicColorSensorManager.tonalPaletteSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), true)

Settings.Secure.putString(
getApplicationContext<Context>().contentResolver,
"theme_customization_overlay_packages",
"""{ "android.theme.customization.theme_style": "VIBRANT" }""",
)

sensorManager.requestSensorUpdate()

assertEquals("VIBRANT", sensorRepository.get(id).single().state)
}

@Test
fun `Given palette sensor when receives malformed input then state is unknown`() = runTest {
val id = DynamicColorSensorManager.tonalPaletteSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), true)

Settings.Secure.putString(
getApplicationContext<Context>().contentResolver,
"theme_customization_overlay_packages",
"""android.theme.customization.theme_style""",
)

sensorManager.requestSensorUpdate()

assertEquals(STATE_UNKNOWN, sensorRepository.get(id).single().state)
}

@Test
fun `Given palette sensor when receives null input then state is unavailable`() = runTest {
val id = DynamicColorSensorManager.tonalPaletteSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), true)

Settings.Secure.putString(
getApplicationContext<Context>().contentResolver,
"theme_customization_overlay_packages",
null,
)

sensorManager.requestSensorUpdate()

assertEquals(STATE_UNAVAILABLE, sensorRepository.get(id).single().state)
}

@Test
fun `Given palette sensor when updated then options are exhaustive`() = runTest {
val id = DynamicColorSensorManager.tonalPaletteSensor.id
sensorRepository.setSensorEnabled(id, listOf(1), true)

sensorManager.requestSensorUpdate()

val attrs = getSensorAttributes(id)
val options = attrs.find { it.name == "options" }?.value
assertNotNull(options)

// Serialized as ["EXPRESSIVE","RAINBOW",...]
val optionsUnwrapped = kotlinJsonMapper.decodeFromString<List<String>>(options)

assertEquals(
listOf(
"EXPRESSIVE",
"FRUIT_SALAD",
"MONOCHROMATIC",
"RAINBOW",
"SPRITZ",
"TONAL_SPOT",
"VIBRANT",
).sorted(),
Comment thread
jpelgrom marked this conversation as resolved.
optionsUnwrapped.sorted(),
)
}

private suspend fun getSensorAttributes(id: String): List<Attribute> {
val map = sensorRepository.getFull(id)
assertEquals(1, map.size)
return map.values.single()
}
}
2 changes: 2 additions & 0 deletions common/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,8 @@
<string name="sensor_description_battery_power">The current wattage of the device. To get the most out of this sensor consider using the \"Fast while charging\" sensor update frequency setting.\n\nIf the sensor\'s state or attributes are not as expected, you may need to adjust the current or voltage divisor to get a correct value. Some devices report these values in a different unit.\n- The "Battery current divisor" will convert from microamperes to amperes by default.\n- The "Battery voltage divisor" will convert from millivolts to volts by default.</string>
<string name="sensor_name_accent_color_sensor">Accent color</string>
<string name="sensor_description_accent_color_sensor">A hexadecimal color value for the dynamic accent color used in the device theme</string>
<string name="sensor_name_tonal_palette_sensor">Tonal palette</string>
<string name="sensor_description_tonal_palette_sensor">The name of the dynamic color palette theme currently in use</string>
<string name="sensor_name_dynamic_color">Dynamic color</string>
<string name="basic_sensor_name_screen_brightness">Screen brightness</string>
<string name="sensor_description_screen_brightness">The current screen brightness on the device, an attribute also indicates if auto-brightness is enabled</string>
Expand Down
Loading